Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4ae09646
|
No files matched your search
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-04-29
|
||||||
|
|
||||||
|
Picker now renders jj's real graph (merge connectors and all), with the
|
||||||
|
cursor/filter aware of decorative rows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Graph rendering now uses jj's native `--graph` output instead of a
|
||||||
|
custom one-row-per-commit reconstruction. Merge connectors
|
||||||
|
(`├─┬─╮`, `├─╯`, `╰─╯`) appear exactly as `jj log` draws them, so the
|
||||||
|
picker is a 1:1 visual match for the real log.
|
||||||
|
- Cursor row arrow glyphs changed from `├──►` to `●──▶` so the
|
||||||
|
selection arrow doesn't visually clash with the graph chrome.
|
||||||
|
- Hint row at the bottom of the picker drops trailing key/label pairs
|
||||||
|
(`esc quit`, `^U clear`, …) until it fits on one row. Previously a
|
||||||
|
narrow terminal would wrap the hint and push the search bar off the
|
||||||
|
top of the screen.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Connector / decorative graph rows (`├─╮`, `╰─╯`, `~`, …) are kept in
|
||||||
|
the picker for visual fidelity with `jj log`. Cursor navigation
|
||||||
|
(arrows, Page{Up,Down}, Home, End, Ctrl-N/P/J/K, Ctrl-A/E) skips over
|
||||||
|
them; selection is silently ignored on connector rows.
|
||||||
|
- Filtering (any non-empty query) excludes connector rows, since they
|
||||||
|
can't match a query and a stray `├─╮` between filtered hits would
|
||||||
|
carry no useful information.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Long descriptions no longer split across rows when the user has
|
||||||
|
`ui.log-word-wrap = true` configured globally — the internal
|
||||||
|
`jj log` invocation now forces `ui.log-word-wrap=false` so a single
|
||||||
|
commit always renders on a single picker row.
|
||||||
|
|
||||||
|
[0.2.0]: https://github.com/swaits/jjf/releases/tag/v0.2.0
|
||||||
|
|
||||||
## [0.1.0] — 2026-04-29
|
## [0.1.0] — 2026-04-29
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|||||||
Generated
+1
-1
@@ -99,7 +99,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jjf"
|
name = "jjf"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jjf"
|
name = "jjf"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ command that's about to run.
|
|||||||
❯ vx ┌────────────────────────────────
|
❯ vx ┌────────────────────────────────
|
||||||
○ tovs chore(release): prepare 0.1.0 │ - Add LICENSE (MIT)
|
○ tovs chore(release): prepare 0.1.0 │ - Add LICENSE (MIT)
|
||||||
@ xnkx feat(ux): more refinements │ - Add README with install,
|
@ xnkx feat(ux): more refinements │ - Add README with install,
|
||||||
○ ssnu feat(ux): batch UX improvements ├──►│ usage, keybindings…
|
○ ssnu feat(ux): batch UX improvements ●──▶│ usage, keybindings…
|
||||||
○ lyxo fix(history): drop jjf entry │
|
○ lyxo fix(history): drop jjf entry │
|
||||||
○ tqvr feat: two-column layout │ M Cargo.toml
|
○ tqvr feat: two-column layout │ M Cargo.toml
|
||||||
○ yksz feat: default to 'show' │ A LICENSE
|
○ yksz feat: default to 'show' │ A LICENSE
|
||||||
|
|||||||
@@ -4,10 +4,24 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
const SEP: u8 = 0x1f;
|
const SEP: u8 = 0x1f;
|
||||||
|
|
||||||
// Custom oneline template: drop the timestamp and commit id (rarely useful in
|
// Custom oneline template emitting field-separated values. We run jj with
|
||||||
// the picker, and the commit-id hex steals real estate), keep change-id with
|
// its native `--graph` so the merge connectors (`├─┬─╮`, `╰─╯`) render
|
||||||
// shortest-unique-prefix highlighting, bookmarks, conflict/empty labels, and
|
// exactly as they do in `jj log` — the picker treats those connector-only
|
||||||
// the first line of the description.
|
// rows as decoration and keeps the cursor on commit rows.
|
||||||
|
//
|
||||||
|
// The template MUST emit a single line per commit (no embedded `\n`,
|
||||||
|
// `ui.log-word-wrap=false`), or jj's graph drawer would inject continuation
|
||||||
|
// rows into our one-line invariant.
|
||||||
|
//
|
||||||
|
// Fields (\x1f-separated, leading `\x1f` separates jj's graph chrome from
|
||||||
|
// our fields):
|
||||||
|
// 0 graph chrome (everything jj prepended before the first \x1f)
|
||||||
|
// 1 change_id.short() — full 12-char change id
|
||||||
|
// 2 change_id.shortest().prefix() — shortest unique prefix
|
||||||
|
// 3 commit_id.short() — full 12-char commit id
|
||||||
|
// 4 commit_id.shortest().prefix() — shortest unique prefix
|
||||||
|
// 5 payload — change-id-prefix-highlighted, bookmarks, conflict/empty,
|
||||||
|
// description.first_line()
|
||||||
const TEMPLATE: &str = concat!(
|
const TEMPLATE: &str = concat!(
|
||||||
"\"\\x1f\" ++ ",
|
"\"\\x1f\" ++ ",
|
||||||
"change_id.short() ++ \"\\x1f\" ++ ",
|
"change_id.short() ++ \"\\x1f\" ++ ",
|
||||||
@@ -23,6 +37,8 @@ const TEMPLATE: &str = concat!(
|
|||||||
))",
|
))",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Either a commit row (selectable) or a connector row (decoration only —
|
||||||
|
/// `├─┬─╮`, `│ │`, `~`, …). Connectors have empty `change_id_short`.
|
||||||
pub struct Row {
|
pub struct Row {
|
||||||
pub change_id_short: String,
|
pub change_id_short: String,
|
||||||
pub change_id_prefix: String,
|
pub change_id_prefix: String,
|
||||||
@@ -32,10 +48,22 @@ pub struct Row {
|
|||||||
pub styled: Vec<u8>,
|
pub styled: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Row {
|
||||||
|
pub fn is_connector(&self) -> bool {
|
||||||
|
self.change_id_short.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn capture_log() -> Result<Vec<Row>> {
|
pub fn capture_log() -> Result<Vec<Row>> {
|
||||||
let output = Command::new("jj")
|
let output = Command::new("jj")
|
||||||
.args([
|
.args([
|
||||||
"--ignore-working-copy",
|
"--ignore-working-copy",
|
||||||
|
// Force off any user `ui.log-word-wrap = true` — wrapping would
|
||||||
|
// split a description into a continuation row that looks
|
||||||
|
// syntactically identical to a graph connector row, breaking the
|
||||||
|
// one-row-per-commit invariant the picker relies on.
|
||||||
|
"--config",
|
||||||
|
"ui.log-word-wrap=false",
|
||||||
"log",
|
"log",
|
||||||
"--color=always",
|
"--color=always",
|
||||||
"-r",
|
"-r",
|
||||||
@@ -58,14 +86,15 @@ pub fn capture_log() -> Result<Vec<Row>> {
|
|||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(row) = parse_row(raw) {
|
rows.push(parse_row(raw));
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_row(bytes: &[u8]) -> Option<Row> {
|
/// Parse one line of `jj log --graph` output. A line with our 5 `\x1f`
|
||||||
|
/// separators is a commit row; anything else is a connector / `~` /
|
||||||
|
/// continuation row that we keep as decoration but render unselectable.
|
||||||
|
fn parse_row(bytes: &[u8]) -> Row {
|
||||||
let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
|
let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
|
||||||
let mut start = 0;
|
let mut start = 0;
|
||||||
for (i, &b) in bytes.iter().enumerate() {
|
for (i, &b) in bytes.iter().enumerate() {
|
||||||
@@ -78,7 +107,17 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if parts.len() < 5 {
|
if parts.len() < 5 {
|
||||||
return None;
|
// Connector / `~` / pure-graph row — keep as decoration only.
|
||||||
|
let styled = bytes.to_vec();
|
||||||
|
let plain = strip_ansi(&styled);
|
||||||
|
return Row {
|
||||||
|
change_id_short: String::new(),
|
||||||
|
change_id_prefix: String::new(),
|
||||||
|
commit_id_short: String::new(),
|
||||||
|
commit_id_prefix: String::new(),
|
||||||
|
plain,
|
||||||
|
styled,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
parts.push(&bytes[start..]);
|
parts.push(&bytes[start..]);
|
||||||
|
|
||||||
@@ -89,24 +128,19 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
|
|||||||
let commit_id_prefix = strip_ansi(parts[4]);
|
let commit_id_prefix = strip_ansi(parts[4]);
|
||||||
let payload = parts[5];
|
let payload = parts[5];
|
||||||
|
|
||||||
if change_id_short.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut styled = Vec::with_capacity(graph.len() + payload.len());
|
let mut styled = Vec::with_capacity(graph.len() + payload.len());
|
||||||
styled.extend_from_slice(graph);
|
styled.extend_from_slice(graph);
|
||||||
styled.extend_from_slice(payload);
|
styled.extend_from_slice(payload);
|
||||||
|
|
||||||
let plain = strip_ansi(&styled);
|
let plain = strip_ansi(&styled);
|
||||||
|
|
||||||
Some(Row {
|
Row {
|
||||||
change_id_short,
|
change_id_short,
|
||||||
change_id_prefix,
|
change_id_prefix,
|
||||||
commit_id_short,
|
commit_id_short,
|
||||||
commit_id_prefix,
|
commit_id_prefix,
|
||||||
plain,
|
plain,
|
||||||
styled,
|
styled,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice.
|
/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice.
|
||||||
|
|||||||
+91
-26
@@ -68,7 +68,7 @@ struct App {
|
|||||||
impl App {
|
impl App {
|
||||||
fn new(rows: Vec<Row>) -> Self {
|
fn new(rows: Vec<Row>) -> Self {
|
||||||
let n = rows.len();
|
let n = rows.len();
|
||||||
Self {
|
let mut app = Self {
|
||||||
rows,
|
rows,
|
||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -78,7 +78,39 @@ impl App {
|
|||||||
matcher: Matcher::new(Config::DEFAULT),
|
matcher: Matcher::new(Config::DEFAULT),
|
||||||
last_height: 0,
|
last_height: 0,
|
||||||
preview_cache: HashMap::new(),
|
preview_cache: HashMap::new(),
|
||||||
|
};
|
||||||
|
// Land the initial cursor on the first commit (connectors are
|
||||||
|
// unselectable, so a commit log starting with a `~` placeholder
|
||||||
|
// shouldn't park the cursor on it).
|
||||||
|
app.cursor = app.next_selectable(0, 1).unwrap_or(0);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether row `i` (in `self.rows`) is a connector / decorative
|
||||||
|
/// graph row that the cursor should skip over.
|
||||||
|
fn row_is_connector(&self, row_idx: usize) -> bool {
|
||||||
|
self.rows
|
||||||
|
.get(row_idx)
|
||||||
|
.is_some_and(|r| r.is_connector())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the nearest selectable position in `self.filtered` starting at
|
||||||
|
/// `from`, scanning by `step` (`1` = down, `-1` = up). Returns `None` if
|
||||||
|
/// every entry in the search direction is a connector.
|
||||||
|
fn next_selectable(&self, from: usize, step: isize) -> Option<usize> {
|
||||||
|
let len = self.filtered.len();
|
||||||
|
if len == 0 {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
let mut idx = from as isize;
|
||||||
|
while (0..len as isize).contains(&idx) {
|
||||||
|
let row_idx = self.filtered[idx as usize];
|
||||||
|
if !self.row_is_connector(row_idx) {
|
||||||
|
return Some(idx as usize);
|
||||||
|
}
|
||||||
|
idx += step;
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preview_for(&mut self, row_idx: usize) -> &PreviewParts {
|
fn preview_for(&mut self, row_idx: usize) -> &PreviewParts {
|
||||||
@@ -91,8 +123,13 @@ impl App {
|
|||||||
|
|
||||||
fn refilter(&mut self) {
|
fn refilter(&mut self) {
|
||||||
if self.filter.is_empty() {
|
if self.filter.is_empty() {
|
||||||
|
// Empty query: show every row including connectors so the user
|
||||||
|
// sees the full graph chrome.
|
||||||
self.filtered = (0..self.rows.len()).collect();
|
self.filtered = (0..self.rows.len()).collect();
|
||||||
} else {
|
} else {
|
||||||
|
// Non-empty query: only commits can match. Connectors carry no
|
||||||
|
// searchable content, so dropping them here also keeps the
|
||||||
|
// result list compact and free of orphaned graph fragments.
|
||||||
let App {
|
let App {
|
||||||
rows,
|
rows,
|
||||||
matcher,
|
matcher,
|
||||||
@@ -105,6 +142,7 @@ impl App {
|
|||||||
let mut scored: Vec<(usize, u32)> = rows
|
let mut scored: Vec<(usize, u32)> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
.filter(|(_, row)| !row.is_connector())
|
||||||
.filter_map(|(i, row)| {
|
.filter_map(|(i, row)| {
|
||||||
score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s))
|
score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s))
|
||||||
})
|
})
|
||||||
@@ -112,7 +150,7 @@ impl App {
|
|||||||
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
self.filtered = scored.into_iter().map(|(i, _)| i).collect();
|
self.filtered = scored.into_iter().map(|(i, _)| i).collect();
|
||||||
}
|
}
|
||||||
self.cursor = 0;
|
self.cursor = self.next_selectable(0, 1).unwrap_or(0);
|
||||||
self.view_offset = 0;
|
self.view_offset = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,28 +159,34 @@ impl App {
|
|||||||
if len == 0 {
|
if len == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut c = self.cursor as isize + delta;
|
|
||||||
if c < 0 {
|
|
||||||
c = 0;
|
|
||||||
}
|
|
||||||
let max = len as isize - 1;
|
let max = len as isize - 1;
|
||||||
if c > max {
|
let target_pos = (self.cursor as isize + delta).clamp(0, max) as usize;
|
||||||
c = max;
|
// Scan past connector rows in the direction of motion. If we hit a
|
||||||
|
// wall (e.g. PageDown near the end with only connectors below), fall
|
||||||
|
// back to scanning the opposite direction so we always land on a
|
||||||
|
// commit.
|
||||||
|
let primary_step: isize = if delta >= 0 { 1 } else { -1 };
|
||||||
|
if let Some(t) = self
|
||||||
|
.next_selectable(target_pos, primary_step)
|
||||||
|
.or_else(|| self.next_selectable(target_pos, -primary_step))
|
||||||
|
{
|
||||||
|
self.cursor = t;
|
||||||
|
self.adjust_view();
|
||||||
}
|
}
|
||||||
self.cursor = c as usize;
|
|
||||||
self.adjust_view();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jump_top(&mut self) {
|
fn jump_top(&mut self) {
|
||||||
self.cursor = 0;
|
self.cursor = self.next_selectable(0, 1).unwrap_or(0);
|
||||||
self.adjust_view();
|
self.adjust_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jump_bottom(&mut self) {
|
fn jump_bottom(&mut self) {
|
||||||
if !self.filtered.is_empty() {
|
if self.filtered.is_empty() {
|
||||||
self.cursor = self.filtered.len() - 1;
|
return;
|
||||||
self.adjust_view();
|
|
||||||
}
|
}
|
||||||
|
let last = self.filtered.len() - 1;
|
||||||
|
self.cursor = self.next_selectable(last, -1).unwrap_or(last);
|
||||||
|
self.adjust_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn adjust_view(&mut self) {
|
fn adjust_view(&mut self) {
|
||||||
@@ -158,6 +202,7 @@ impl App {
|
|||||||
|
|
||||||
fn toggle_select(&mut self) {
|
fn toggle_select(&mut self) {
|
||||||
if let Some(&row_idx) = self.filtered.get(self.cursor)
|
if let Some(&row_idx) = self.filtered.get(self.cursor)
|
||||||
|
&& !self.row_is_connector(row_idx)
|
||||||
&& !self.selected.insert(row_idx)
|
&& !self.selected.insert(row_idx)
|
||||||
{
|
{
|
||||||
self.selected.remove(&row_idx);
|
self.selected.remove(&row_idx);
|
||||||
@@ -431,9 +476,9 @@ fn render(
|
|||||||
// arrow fills from the text end through the separator and peeks into
|
// arrow fills from the text end through the separator and peeks into
|
||||||
// the box by exactly one column:
|
// the box by exactly one column:
|
||||||
// col Y+1 ` ` (one space after text)
|
// col Y+1 ` ` (one space after text)
|
||||||
// col Y+2 `├` (arrow start)
|
// col Y+2 `●` (arrow start)
|
||||||
// cols Y+3 .. left_w+3 `─` (variable-length body)
|
// cols Y+3 .. left_w+3 `─` (variable-length body)
|
||||||
// col left_w+4 `►` (peeks 1 col into the box)
|
// col left_w+4 `▶` (peeks 1 col into the box)
|
||||||
// col left_w+5 ` `
|
// col left_w+5 ` `
|
||||||
// col left_w+6 preview content (aligned with non-cursor)
|
// col left_w+6 preview content (aligned with non-cursor)
|
||||||
let (left_w, right_x, right_w): (usize, u16, usize) = if preview_enabled {
|
let (left_w, right_x, right_w): (usize, u16, usize) = if preview_enabled {
|
||||||
@@ -448,7 +493,7 @@ fn render(
|
|||||||
// Preview pane layout: top border (`┌─────`) sits on the *input row*,
|
// Preview pane layout: top border (`┌─────`) sits on the *input row*,
|
||||||
// not the first log row, so log rows align 1:1 with preview content
|
// not the first log row, so log rows align 1:1 with preview content
|
||||||
// rows. Each preview content row is `│ <line>` (3-char gutter) or
|
// rows. Each preview content row is `│ <line>` (3-char gutter) or
|
||||||
// `├─►<line>` on the cursor's row, drawing the eye from picker
|
// `●─▶<line>` on the cursor's row, drawing the eye from picker
|
||||||
// selection into preview content.
|
// selection into preview content.
|
||||||
let preview_gutter_w = 3;
|
let preview_gutter_w = 3;
|
||||||
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
|
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
|
||||||
@@ -521,17 +566,17 @@ fn render(
|
|||||||
if preview_enabled {
|
if preview_enabled {
|
||||||
if on_cursor {
|
if on_cursor {
|
||||||
// Dynamic arrow inline, starting at col visible_used+1:
|
// Dynamic arrow inline, starting at col visible_used+1:
|
||||||
// ` ` ├ ─×N ► ` ` then preview content at col left_w+6.
|
// ` ` ● ─×N ▶ ` ` then preview content at col left_w+6.
|
||||||
buf.push(b' ');
|
buf.push(b' ');
|
||||||
buf.extend_from_slice(SGR_DIM);
|
buf.extend_from_slice(SGR_DIM);
|
||||||
buf.extend_from_slice("├".as_bytes());
|
buf.extend_from_slice("●".as_bytes());
|
||||||
let dash_count = (left_w + 1).saturating_sub(visible_used);
|
let dash_count = (left_w + 1).saturating_sub(visible_used);
|
||||||
for _ in 0..dash_count {
|
for _ in 0..dash_count {
|
||||||
buf.extend_from_slice("─".as_bytes());
|
buf.extend_from_slice("─".as_bytes());
|
||||||
}
|
}
|
||||||
buf.extend_from_slice(SGR_RESET);
|
buf.extend_from_slice(SGR_RESET);
|
||||||
buf.extend_from_slice(SGR_FG_CYAN);
|
buf.extend_from_slice(SGR_FG_CYAN);
|
||||||
buf.extend_from_slice("►".as_bytes());
|
buf.extend_from_slice("▶".as_bytes());
|
||||||
buf.extend_from_slice(SGR_RESET);
|
buf.extend_from_slice(SGR_RESET);
|
||||||
buf.push(b' ');
|
buf.push(b' ');
|
||||||
} else {
|
} else {
|
||||||
@@ -572,7 +617,9 @@ fn render(
|
|||||||
buf.extend_from_slice(cmd_visible.as_bytes());
|
buf.extend_from_slice(cmd_visible.as_bytes());
|
||||||
buf.extend_from_slice(SGR_RESET);
|
buf.extend_from_slice(SGR_RESET);
|
||||||
|
|
||||||
// Hint row (full width, below cmd preview).
|
// Hint row — must fit on a single line. If it wrapped, the wrapped
|
||||||
|
// tail would push the input row off the top of the terminal. So drop
|
||||||
|
// pairs from the right until the row fits in `term_cols`.
|
||||||
let hint_y = cmd_y + 1;
|
let hint_y = cmd_y + 1;
|
||||||
write!(buf, "\x1b[{};1H", hint_y)?;
|
write!(buf, "\x1b[{};1H", hint_y)?;
|
||||||
let counts = if app.selected.is_empty() {
|
let counts = if app.selected.is_empty() {
|
||||||
@@ -585,14 +632,32 @@ fn render(
|
|||||||
app.selected.len()
|
app.selected.len()
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let pairs: &[(&str, &str)] = &[
|
||||||
|
("↑↓/^N^P", "nav"),
|
||||||
|
("tab", "select"),
|
||||||
|
("enter", "run"),
|
||||||
|
("^U", "clear"),
|
||||||
|
("esc", "quit"),
|
||||||
|
];
|
||||||
|
let base_w = counts.chars().count() + "type filter".chars().count();
|
||||||
|
let max_w = term_cols as usize;
|
||||||
|
let mut used = base_w;
|
||||||
|
let mut keep = 0usize;
|
||||||
|
for (key, label) in pairs {
|
||||||
|
// " · " (3) + key + " " (1) + label
|
||||||
|
let w = 3 + key.chars().count() + 1 + label.chars().count();
|
||||||
|
if used + w > max_w {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
used += w;
|
||||||
|
keep += 1;
|
||||||
|
}
|
||||||
buf.extend_from_slice(SGR_DIM);
|
buf.extend_from_slice(SGR_DIM);
|
||||||
buf.extend_from_slice(counts.as_bytes());
|
buf.extend_from_slice(counts.as_bytes());
|
||||||
buf.extend_from_slice(b"type filter");
|
buf.extend_from_slice(b"type filter");
|
||||||
write_hint_pair(&mut buf, "↑↓/^N^P".as_bytes(), b"nav");
|
for (key, label) in pairs.iter().take(keep) {
|
||||||
write_hint_pair(&mut buf, b"tab", b"select");
|
write_hint_pair(&mut buf, key.as_bytes(), label.as_bytes());
|
||||||
write_hint_pair(&mut buf, b"enter", b"run");
|
}
|
||||||
write_hint_pair(&mut buf, b"^U", b"clear");
|
|
||||||
write_hint_pair(&mut buf, b"esc", b"quit");
|
|
||||||
buf.extend_from_slice(SGR_RESET);
|
buf.extend_from_slice(SGR_RESET);
|
||||||
buf.extend_from_slice(ERASE_TO_EOL);
|
buf.extend_from_slice(ERASE_TO_EOL);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user