Files
jjf/src/tui.rs
T
swaits 6ce9cb2b17 feat(dispatch): walk subcommands and detect revset flag from help
Two real failures motivated this:

- `jjf bm main` (where `bm` is a user alias for `bookmark move`) ran
  `jj bm main -r <id>` — but bookmark move uses `--to`, not `-r`.
- `jjf tag set v0.2.0` errored with "doesn't take revisions" because
  jjf only inspected `jj tag --help` (parent) instead of walking
  into the `set` leaf, which does take `-r`.

Replace the boolean `supports_revisions()` (a substring match on
"REVSET") with `RevsetTarget { leaf, passthrough, flag }`:

- Walks multi-level subcommands by parsing each parent's `Commands:`
  block — including `[aliases: …]` annotations, so `jjf tag s` walks
  to `tag set`. Aliases like `bm` work transparently because `jj bm
  --help` already emits the resolved leaf's help.
- Parses the leaf's `Options:` block and picks the revset flag in
  priority order: `-r`/`--revision[s]` first, then `-t, --to`.
  `--from` is intentionally not a fallback (different semantics from
  "the revision the picker chose").
- Bypasses the picker entirely when the user already passed a revset
  flag in their args, so `jjf describe -r @-` runs verbatim instead
  of double-injecting `-r`.

`exec` and `command_line` now take `&RevsetTarget` and emit the
resolved flag; signatures thread through `tui.rs` and `main.rs`.

New unit tests cover the help parsers and command-line construction
against captured fixtures of `jj tag set --help`, `jj bookmark move
--help`, and `jj tag --help`.
2026-05-04 12:58:14 -06:00

948 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{HashMap, HashSet};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::jj::{self, PreviewParts, Row};
const PREVIEW_MIN_COLS: u16 = 80;
// SGR constants
const SGR_RESET: &[u8] = b"\x1b[0m";
const SGR_RESET_SHORT: &[u8] = b"\x1b[m";
const SGR_DIM: &[u8] = b"\x1b[2m";
const SGR_NORMAL_WEIGHT: &[u8] = b"\x1b[22m";
const SGR_BOLD: &[u8] = b"\x1b[1m";
const SGR_FG_CYAN: &[u8] = b"\x1b[36m";
const SGR_FG_YELLOW: &[u8] = b"\x1b[33m";
const ERASE_TO_EOL: &[u8] = b"\x1b[K";
const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
// Synchronized output (DEC Mode 2026): Kitty / WezTerm / foot / recent iTerm
// honor these brackets and present the buffered redraw atomically; other
// terminals ignore them. Eliminates tearing on supported terminals.
const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
const SYNC_END: &[u8] = b"\x1b[?2026l";
/// The cursor-row "highlight" SGR. Dark gray bg on dark themes, reverse
/// video on light themes (since indexed-color 237 disappears on light).
fn cursor_bg() -> &'static [u8] {
static CACHE: OnceLock<&'static [u8]> = OnceLock::new();
CACHE.get_or_init(|| {
if is_light_terminal() {
b"\x1b[7m"
} else {
b"\x1b[48;5;237m"
}
})
}
fn is_light_terminal() -> bool {
if let Ok(v) = std::env::var("COLORFGBG")
&& let Some(bg_str) = v.split(';').next_back()
&& let Ok(bg) = bg_str.parse::<u8>()
{
return bg == 7 || bg == 15;
}
false
}
const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
struct App {
rows: Vec<Row>,
filter: String,
cursor: usize,
view_offset: usize,
selected: HashSet<usize>,
filtered: Vec<usize>,
matcher: Matcher,
last_height: usize,
preview_cache: HashMap<usize, PreviewParts>,
}
impl App {
fn new(rows: Vec<Row>) -> Self {
let n = rows.len();
let mut app = Self {
rows,
filter: String::new(),
cursor: 0,
view_offset: 0,
selected: HashSet::new(),
filtered: (0..n).collect(),
matcher: Matcher::new(Config::DEFAULT),
last_height: 0,
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 {
if !self.preview_cache.contains_key(&row_idx) {
let cid = self.rows[row_idx].change_id_short.clone();
self.preview_cache.insert(row_idx, jj::show_summary(&cid));
}
self.preview_cache.get(&row_idx).unwrap()
}
fn refilter(&mut self) {
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();
} 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 {
rows,
matcher,
filter,
..
} = self;
let mut hbuf: Vec<char> = Vec::new();
let mut nbuf: Vec<char> = Vec::new();
let q_lower = filter.to_lowercase();
let mut scored: Vec<(usize, u32)> = rows
.iter()
.enumerate()
.filter(|(_, row)| !row.is_connector())
.filter_map(|(i, row)| {
score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s))
})
.collect();
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.cursor = self.next_selectable(0, 1).unwrap_or(0);
self.view_offset = 0;
}
fn move_cursor(&mut self, delta: isize) {
let len = self.filtered.len();
if len == 0 {
return;
}
let max = len as isize - 1;
let target_pos = (self.cursor as isize + delta).clamp(0, max) as usize;
// 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();
}
}
fn jump_top(&mut self) {
self.cursor = self.next_selectable(0, 1).unwrap_or(0);
self.adjust_view();
}
fn jump_bottom(&mut self) {
if self.filtered.is_empty() {
return;
}
let last = self.filtered.len() - 1;
self.cursor = self.next_selectable(last, -1).unwrap_or(last);
self.adjust_view();
}
fn adjust_view(&mut self) {
if self.last_height == 0 {
return;
}
if self.cursor < self.view_offset {
self.view_offset = self.cursor;
} else if self.cursor >= self.view_offset + self.last_height {
self.view_offset = self.cursor + 1 - self.last_height;
}
}
fn toggle_select(&mut self) {
if let Some(&row_idx) = self.filtered.get(self.cursor)
&& !self.row_is_connector(row_idx)
&& !self.selected.insert(row_idx)
{
self.selected.remove(&row_idx);
}
}
fn pending_ids(&self) -> Vec<String> {
if self.selected.is_empty() {
if let Some(&row_idx) = self.filtered.get(self.cursor) {
return vec![short_id(&self.rows[row_idx])];
}
return Vec::new();
}
let mut indices: Vec<usize> = self.selected.iter().copied().collect();
indices.sort_unstable();
indices
.into_iter()
.map(|i| short_id(&self.rows[i]))
.collect()
}
}
fn short_id(row: &Row) -> String {
if row.change_id_prefix.is_empty() {
row.change_id_short.clone()
} else {
row.change_id_prefix.clone()
}
}
fn score_row(
row: &Row,
query: &str,
q_lower: &str,
matcher: &mut Matcher,
hbuf: &mut Vec<char>,
nbuf: &mut Vec<char>,
) -> Option<u32> {
let mut best: u32 = 0;
let mut hit = false;
let cp = row.change_id_prefix.to_lowercase();
let cs = row.change_id_short.to_lowercase();
let kp = row.commit_id_prefix.to_lowercase();
let ks = row.commit_id_short.to_lowercase();
if !cp.is_empty() && cp.starts_with(q_lower) {
best = best.max(1_000_000);
hit = true;
}
if cs.starts_with(q_lower) {
best = best.max(100_000);
hit = true;
}
if !kp.is_empty() && kp.starts_with(q_lower) {
best = best.max(10_000);
hit = true;
}
if ks.starts_with(q_lower) {
best = best.max(1_000);
hit = true;
}
let h = Utf32Str::new(&row.plain, hbuf);
let n = Utf32Str::new(query, nbuf);
if let Some(score) = matcher.fuzzy_match(h, n) {
best = best.max(score as u32);
hit = true;
}
if hit { Some(best) } else { None }
}
pub fn run(
rows: Vec<Row>,
target: &jj::RevsetTarget,
) -> Result<Option<Vec<String>>> {
install_panic_hook();
let mut tty = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.context("open /dev/tty")?;
crossterm::terminal::enable_raw_mode().context("enable raw mode")?;
// Full-screen alt-screen mode: saves the user's screen, gives us the
// whole terminal as a clean canvas, restores on exit. Resize behavior
// becomes trivial because we own the entire display.
tty.write_all(b"\x1b[?1049h").context("enter alt screen")?;
tty.write_all(HIDE_CURSOR).context("hide cursor")?;
tty.flush().ok();
let result = run_loop(&mut tty, rows, target);
let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
let _ = tty.flush();
let _ = crossterm::terminal::disable_raw_mode();
result
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") {
let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
let _ = tty.flush();
}
original(info);
}));
}
fn run_loop(
tty: &mut File,
rows: Vec<Row>,
target: &jj::RevsetTarget,
) -> Result<Option<Vec<String>>> {
let mut app = App::new(rows);
let (mut term_cols, mut term_rows) = crossterm::terminal::size().unwrap_or((80, 24));
// Chrome rows: input(1) + cmd_preview(1) + hint(1) = 3.
app.last_height = (term_rows as usize).saturating_sub(3);
loop {
render(tty, &mut app, term_rows, term_cols, target)?;
if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
continue;
}
match event::read().context("event read failed")? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if let Some(action) = handle_key(&mut app, key) {
return Ok(action);
}
}
Event::Resize(new_cols, new_rows) => {
// Alt-screen mode: trivial. Just update dims and redraw.
term_cols = new_cols;
term_rows = new_rows;
app.last_height = (term_rows as usize).saturating_sub(3);
let _ = write!(tty, "\x1b[2J");
let _ = tty.flush();
}
_ => {}
}
}
}
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<Vec<String>>> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl {
match key.code {
KeyCode::Char('c') => return Some(None),
KeyCode::Char('n') | KeyCode::Char('j') => {
app.move_cursor(1);
return None;
}
KeyCode::Char('p') | KeyCode::Char('k') => {
app.move_cursor(-1);
return None;
}
KeyCode::Char('a') => {
app.jump_top();
return None;
}
KeyCode::Char('e') => {
app.jump_bottom();
return None;
}
KeyCode::Char('h') => {
app.filter.pop();
app.refilter();
return None;
}
KeyCode::Char('u') => {
app.filter.clear();
app.refilter();
return None;
}
KeyCode::Char('w') => {
delete_word(&mut app.filter);
app.refilter();
return None;
}
_ => return None,
}
}
match key.code {
KeyCode::Esc => Some(None),
KeyCode::Enter => Some(Some(app.pending_ids())),
KeyCode::Tab => {
app.toggle_select();
None
}
KeyCode::Up => {
app.move_cursor(-1);
None
}
KeyCode::Down => {
app.move_cursor(1);
None
}
KeyCode::PageUp => {
app.move_cursor(-(app.last_height.max(1) as isize));
None
}
KeyCode::PageDown => {
app.move_cursor(app.last_height.max(1) as isize);
None
}
KeyCode::Home => {
app.jump_top();
None
}
KeyCode::End => {
app.jump_bottom();
None
}
KeyCode::Backspace => {
app.filter.pop();
app.refilter();
None
}
KeyCode::Char(c) => {
app.filter.push(c);
app.refilter();
None
}
_ => None,
}
}
fn delete_word(s: &mut String) {
while s.ends_with(' ') {
s.pop();
}
while let Some(c) = s.chars().last() {
if c.is_whitespace() {
break;
}
s.pop();
}
}
fn render(
tty: &mut File,
app: &mut App,
term_rows: u16,
term_cols: u16,
target: &jj::RevsetTarget,
) -> Result<()> {
let viewport_y: u16 = 0;
let viewport_height = term_rows;
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
buf.extend_from_slice(SYNC_BEGIN);
let log_height = app.last_height;
let preview_enabled = term_cols >= PREVIEW_MIN_COLS;
// Layout (1-indexed columns), with a 2-col separator between picker and box:
// cols 1..=left_w picker pane (text + padding for non-cursor;
// text only, no bg padding, for cursor)
// cols left_w+1 .. left_w+2 2-col separator
// col left_w+3 box left edge `│` (non-cursor)
// cols left_w+4 .. left_w+5 2-col gutter inside box
// cols left_w+6 .. term_cols preview content
//
// On the cursor row the picker text is NOT padded; instead a dynamic
// arrow fills from the text end through the separator and peeks into
// the box by exactly one column:
// col Y+1 ` ` (one space after text)
// col Y+2 `●` (arrow start)
// cols Y+3 .. left_w+3 `─` (variable-length body)
// col left_w+4 `▶` (peeks 1 col into the box)
// col left_w+5 ` `
// col left_w+6 preview content (aligned with non-cursor)
let (left_w, right_x, right_w): (usize, u16, usize) = if preview_enabled {
let lw = (term_cols / 2).saturating_sub(3) as usize;
let rx = (lw + 2) as u16; // col where `│` lives (1-indexed): lw+3 = rx+1
let rw = (term_cols as usize).saturating_sub(lw + 2);
(lw, rx, rw)
} else {
(term_cols as usize, term_cols, 0)
};
// Preview pane layout: top border (`┌─────`) sits on the *input row*,
// 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
// `●─▶<line>` on the cursor's row, drawing the eye from picker
// selection into preview content.
let preview_gutter_w = 3;
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
let preview_rows = log_height; // top border is on input row, not log area
let preview_lines: Vec<Vec<u8>> = if preview_enabled {
if let Some(&row_idx) = app.filtered.get(app.cursor) {
let parts = app.preview_for(row_idx);
build_preview_lines(parts, preview_rows, preview_content_w)
} else {
Vec::new()
}
} else {
Vec::new()
};
// Input row (left only).
write!(buf, "\x1b[{};1H", viewport_y + 1)?;
buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_CYAN);
buf.extend_from_slice("❯ ".as_bytes());
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(app.filter.as_bytes());
// Reverse-video caret on a single space — matches fzf/lazygit/telescope
// convention; survives any colorscheme without competing with the cyan ❯.
buf.extend_from_slice(b"\x1b[7m \x1b[27m");
buf.extend_from_slice(ERASE_TO_EOL);
// Top border for the preview pane — drawn at the input row's vertical
// position so the box "starts" alongside the search field. Lives at
// col left_w+3 (= right_x + 1), past the 2-col separator from the picker.
if preview_enabled {
write!(buf, "\x1b[{};{}H", viewport_y + 1, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("┌".as_bytes());
for _ in 1..right_w {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
}
// Log rows.
let visible: Vec<(usize, usize)> = app
.filtered
.iter()
.enumerate()
.skip(app.view_offset)
.take(log_height)
.map(|(filt_idx, &row_idx)| (filt_idx, row_idx))
.collect();
for i in 0..log_height {
let row_y = viewport_y + 2 + i as u16; // input row + offset
write!(buf, "\x1b[{};1H", row_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let on_cursor = visible
.get(i)
.is_some_and(|&(filt_idx, _)| filt_idx == app.cursor);
let visible_used: usize = if let Some(&(filt_idx, row_idx)) = visible.get(i) {
let row = &app.rows[row_idx];
let is_cursor = filt_idx == app.cursor;
let is_selected = app.selected.contains(&row_idx);
render_log_row(&mut buf, row, is_cursor, is_selected, left_w)
} else {
0
};
if preview_enabled {
if on_cursor {
// Dynamic arrow inline, starting at col visible_used+1:
// ` ` ● ─×N ▶ ` ` then preview content at col left_w+6.
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("●".as_bytes());
let dash_count = (left_w + 1).saturating_sub(visible_used);
for _ in 0..dash_count {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(SGR_FG_CYAN);
buf.extend_from_slice("▶".as_bytes());
buf.extend_from_slice(SGR_RESET);
buf.push(b' ');
} else {
// Position past the picker pane: 2-col sep, then `│ `, then content.
write!(buf, "\x1b[{};{}H", row_y, left_w as u16 + 1)?;
buf.push(b' ');
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("│ ".as_bytes());
buf.extend_from_slice(SGR_RESET);
}
if let Some(line) = preview_lines.get(i) {
buf.extend_from_slice(line);
buf.extend_from_slice(SGR_RESET);
}
}
}
// Command preview row — `▶ jj describe -m 'foo' -r 'vx'` in bright green.
// Tells the user exactly what Enter will run, with the resolved `-r '<id>'`
// updated live as the cursor moves or selections toggle.
let cmd_y = viewport_y + 1 + log_height as u16 + 1;
write!(buf, "\x1b[{};1H", cmd_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let cmd = jj::command_line(target, &app.pending_ids());
let cmd_max = (term_cols as usize).saturating_sub(2); // for "▶ "
let cmd_visible: String = if cmd.chars().count() <= cmd_max {
cmd
} else if cmd_max == 0 {
String::new()
} else {
let mut s: String = cmd.chars().take(cmd_max.saturating_sub(1)).collect();
s.push('…');
s
};
buf.extend_from_slice(b"\x1b[1;32m"); // bold green
buf.extend_from_slice("▶ ".as_bytes());
buf.extend_from_slice(cmd_visible.as_bytes());
buf.extend_from_slice(SGR_RESET);
// 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;
write!(buf, "\x1b[{};1H", hint_y)?;
let counts = if app.selected.is_empty() {
format!("[{}/{}] ", app.filtered.len(), app.rows.len())
} else {
format!(
"[{}/{} · {} selected] ",
app.filtered.len(),
app.rows.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(counts.as_bytes());
buf.extend_from_slice(b"type filter");
for (key, label) in pairs.iter().take(keep) {
write_hint_pair(&mut buf, key.as_bytes(), label.as_bytes());
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
// Park cursor at end of input line.
let input_col = 2 + app.filter.chars().count() as u16 + 1;
write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?;
let _ = viewport_height;
let _ = right_x;
buf.extend_from_slice(SYNC_END);
tty.write_all(&buf)?;
tty.flush()?;
Ok(())
}
fn write_hint_pair(buf: &mut Vec<u8>, key: &[u8], label: &[u8]) {
buf.extend_from_slice(" · ".as_bytes());
buf.extend_from_slice(SGR_NORMAL_WEIGHT);
buf.extend_from_slice(key);
buf.extend_from_slice(SGR_DIM);
buf.push(b' ');
buf.extend_from_slice(label);
}
/// Pack description + files into `available_rows`. The file list always wins —
/// description is clipped (with a dim ellipsis appended to its last kept line)
/// to whatever room is left after the files (and a blank separator) are placed.
/// Long description/file lines are word-wrapped to `width` columns.
fn build_preview_lines(parts: &PreviewParts, available_rows: usize, width: usize) -> Vec<Vec<u8>> {
let mut desc_lines: Vec<Vec<u8>> = Vec::new();
if !parts.description.is_empty() {
for line in split_lines(&parts.description) {
desc_lines.extend(wrap_ansi(&line, width));
}
}
let mut file_lines: Vec<Vec<u8>> = Vec::new();
if !parts.files.is_empty() {
for line in split_lines(&parts.files) {
file_lines.extend(wrap_ansi(&line, width));
}
}
let blank_sep = if !desc_lines.is_empty() && !file_lines.is_empty() {
1
} else {
0
};
let max_desc = available_rows.saturating_sub(file_lines.len() + blank_sep);
let kept_desc = desc_lines.len().min(max_desc);
let clipped = desc_lines.len() > kept_desc;
let mut out: Vec<Vec<u8>> = Vec::with_capacity(kept_desc + blank_sep + file_lines.len());
for (i, line) in desc_lines.iter().take(kept_desc).enumerate() {
if clipped && i + 1 == kept_desc {
let mut last = line.clone();
last.extend_from_slice(b" \x1b[2m\xe2\x80\xa6\x1b[0m");
out.push(last);
} else {
out.push(line.clone());
}
}
if blank_sep == 1 && kept_desc > 0 {
out.push(Vec::new());
}
for line in file_lines {
out.push(line);
}
out
}
/// Word-wrap a line containing ANSI escapes to `width` visible columns.
/// Splits on spaces; a word longer than `width` overflows on its own line
/// rather than mid-word breaking. ANSI escape sequences carry through and
/// don't count toward visible width.
fn wrap_ansi(line: &[u8], width: usize) -> Vec<Vec<u8>> {
if width == 0 || visible_width(line) <= width {
return vec![line.to_vec()];
}
let mut out: Vec<Vec<u8>> = Vec::new();
let mut current: Vec<u8> = Vec::new();
let mut current_visible = 0usize;
let mut word: Vec<u8> = Vec::new();
let mut word_visible = 0usize;
let mut i = 0;
while i < line.len() {
if line[i] == 0x1b && line.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < line.len() && !line[i].is_ascii_alphabetic() {
i += 1;
}
if i < line.len() {
i += 1;
}
// Append ANSI to the in-progress word so styling travels with it.
word.extend_from_slice(&line[start..i]);
} else if line[i] == b' ' {
// End-of-word: try to fit `word` into `current`.
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
current_visible += 1;
}
current.extend_from_slice(&word);
current_visible += word_visible;
} else {
// Doesn't fit: flush current, start new line with the word.
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
current_visible = word_visible;
}
word.clear();
word_visible = 0;
i += 1;
} else {
let lead = line[i];
let len = utf8_char_len(lead);
let end = (i + len).min(line.len());
word.extend_from_slice(&line[i..end]);
word_visible += 1;
i = end;
}
}
// Final word
if word_visible > 0 {
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
}
current.extend_from_slice(&word);
} else {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
}
}
if !current.is_empty() {
out.push(current);
}
out
}
/// Render the picker portion of one log row. Returns the 1-indexed column
/// after the rendered content (i.e. the col where the arrow's leading space
/// would be drawn for the cursor row).
///
/// Non-cursor rows are padded with normal spaces to fill `width`. Cursor rows
/// have bg highlight on actual text only and are NOT padded — the dynamic
/// arrow fills the rest.
fn render_log_row(
buf: &mut Vec<u8>,
row: &Row,
is_cursor: bool,
is_selected: bool,
width: usize,
) -> usize {
let gutter_w = 2;
let content_w = width.saturating_sub(gutter_w);
// Gutter: yellow `▎ ` if selected, else two spaces.
if is_selected {
buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_YELLOW);
buf.extend_from_slice("▎ ".as_bytes());
buf.extend_from_slice(SGR_RESET);
} else {
buf.extend_from_slice(b" ");
}
let truncated = truncate_ansi(&row.styled, content_w);
let used = visible_width(&truncated);
if is_cursor {
let bg = cursor_bg();
buf.extend_from_slice(bg);
inject_bg_into(buf, &truncated, bg);
buf.extend_from_slice(SGR_RESET);
gutter_w + used
} else {
buf.extend_from_slice(&truncated);
buf.extend_from_slice(SGR_RESET);
let pad = content_w.saturating_sub(used);
for _ in 0..pad {
buf.push(b' ');
}
width
}
}
fn split_lines(bytes: &[u8]) -> Vec<Vec<u8>> {
bytes.split(|&b| b == b'\n').map(|l| l.to_vec()).collect()
}
/// Count visible chars (post-CSI-strip) in a UTF-8 byte slice.
fn visible_width(bytes: &[u8]) -> usize {
let mut w = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
let lead = bytes[i];
let len = utf8_char_len(lead);
i = (i + len).min(bytes.len());
w += 1;
}
}
w
}
/// Truncate `bytes` so visible (non-CSI, non-continuation) chars fit in `max`.
/// All CSI sequences are preserved; only printable chars are counted.
fn truncate_ansi(bytes: &[u8], max: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut visible = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
out.extend_from_slice(&bytes[start..i]);
} else {
if visible >= max {
break;
}
let lead = bytes[i];
let len = utf8_char_len(lead);
let end = (i + len).min(bytes.len());
out.extend_from_slice(&bytes[i..end]);
visible += 1;
i = end;
}
}
out
}
fn utf8_char_len(lead: u8) -> usize {
if lead < 0x80 || (0x80..0xc0).contains(&lead) {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
}
}
/// Copy `src` into `dst`, but after every embedded `ESC [ 0 m` or `ESC [ m`
/// reset, append `bg` so the cursor-row background survives mid-row resets.
fn inject_bg_into(dst: &mut Vec<u8>, src: &[u8], bg: &[u8]) {
let mut i = 0;
while i < src.len() {
if src[i] == 0x1b && src.get(i + 1) == Some(&b'[') {
if src[i..].starts_with(SGR_RESET) {
dst.extend_from_slice(SGR_RESET);
dst.extend_from_slice(bg);
i += SGR_RESET.len();
continue;
}
if src[i..].starts_with(SGR_RESET_SHORT) {
dst.extend_from_slice(SGR_RESET_SHORT);
dst.extend_from_slice(bg);
i += SGR_RESET_SHORT.len();
continue;
}
}
dst.push(src[i]);
i += 1;
}
}