Files
jjf/src/tui.rs
T
swaits d2740008e9 feat(ux): more refinements
- Custom oneline template (drops timestamp + commit-id hash from the
  left pane); keeps change-id with shortest-unique-prefix highlighting,
  bookmarks, conflict/empty labels, and the description first-line.
  More room for the actually-useful columns.
- Drop the dim '── <change_id> ────' header above the preview; the
  cursor row tee (├) now carries the 'this is what you're looking at'
  signal.
- Box-char frame for the preview pane: top border (┌────) and left
  border (│), with ├ replacing │ on the cursor row to draw the eye
  from picker selection into preview content.
- Dynamic description clipping: the description fills whatever rows
  are left after the file list (ellipsis on overflow). Replaces the
  hard 3-line cap, which under-used vertical space for short file
  lists and was buggy (didn't count blank lines within description).
- Bump VIEWPORT_MIN from 15 to 20 so the preview pane has enough
  vertical room to be useful by default.
2026-04-29 18:12:40 -06:00

887 lines
28 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();
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(),
}
}
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() {
self.filtered = (0..self.rows.len()).collect();
} else {
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_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 = 0;
self.view_offset = 0;
}
fn move_cursor(&mut self, delta: isize) {
let len = self.filtered.len();
if len == 0 {
return;
}
let mut c = self.cursor as isize + delta;
if c < 0 {
c = 0;
}
let max = len as isize - 1;
if c > max {
c = max;
}
self.cursor = c as usize;
self.adjust_view();
}
fn jump_top(&mut self) {
self.cursor = 0;
self.adjust_view();
}
fn jump_bottom(&mut self) {
if !self.filtered.is_empty() {
self.cursor = self.filtered.len() - 1;
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.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>,
subcommand: &str,
passthrough: &[String],
) -> 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, subcommand, passthrough);
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>,
subcommand: &str,
passthrough: &[String],
) -> 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, subcommand, passthrough)?;
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,
subcommand: &str,
passthrough: &[String],
) -> 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(subcommand, passthrough, &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 (full width, below cmd preview).
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()
)
};
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice(counts.as_bytes());
buf.extend_from_slice(b"type filter");
write_hint_pair(&mut buf, "↑↓/^N^P".as_bytes(), b"nav");
write_hint_pair(&mut buf, b"tab", b"select");
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(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;
}
}