From d440a4c3932a7a560a3f2b0fff55940c6752b258 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 4 Aug 2026 16:54:16 -0400 Subject: [PATCH] feat(render): draw the cursor in screenshots A screenshot showed the grid but never where the terminal was about to write, so a reader could not tell an editor's caret position, whether a program had hidden the cursor, or which mode it was in. The emulator now reports visibility (`DECTCEM`) and shape (`DECSCUSR`), and the renderer draws the cursor after the text pass: a block fills the cell, an underline sits on its bottom edge, and a bar on its left. A block redraws the character beneath it in the cell's background color, so it stays readable rather than being swallowed. The redraw goes through the same path as the text pass, which keeps two cases right that a naive redraw gets wrong: a double-width character is covered across both of its cells instead of being clipped and squashed into one, and a nerd font glyph comes back as a vector glyph rather than as a character the text font has no glyph for. Blink is deliberately not represented; a screenshot is a single moment, and a blinking cursor is drawn in the half of the cycle where it shows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas Rebasing onto a main that had since gained window titles folded the follow-up commits into this one, so it also carries: the cursor row counted as a `usize`, since a full render is as long as the scrollback and a profile can set that past what a `u16` holds, and the removal of a doc line left documenting a constant. --- crates/shell-use/src/engine.rs | 35 ++- crates/shell-use/src/render/svg.rs | 283 +++++++++++++++++-- crates/shell-use/src/terminal/alacritty.rs | 22 +- crates/shell-use/src/terminal/conformance.rs | 49 ++++ crates/shell-use/src/terminal/emu.rs | 25 ++ 5 files changed, 386 insertions(+), 28 deletions(-) diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index 8d08b90..205d524 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -1351,6 +1351,27 @@ fn do_snapshot( } } +/// Where to draw the cursor within `rows`, or `None` when the terminal is not +/// showing one. +/// +/// `Emulator::cursor` is relative to the visible screen, so a full screenshot +/// has to push it down past the scrollback that precedes it. +fn cursor_in( + rows: &[Vec], + emu: &dyn crate::terminal::emu::Emulator, +) -> Option<(u16, usize)> { + if !emu.cursor_visible() { + return None; + } + let (x, y) = emu.cursor(); + let (_, screen) = emu.size(); + // Counted in `usize`: a full render is as long as the scrollback, which a + // profile can set past what a `u16` row would hold, and a wrapped offset + // draws the cursor on a plausible but wrong line. + let history = rows.len().saturating_sub(screen as usize); + Some((x, history + y as usize)) +} + fn screenshot( session: &TerminalSession, full: bool, @@ -1359,17 +1380,19 @@ fn screenshot( let (rows, title) = grid_with_title(session, full, true); match path { Some(path) => { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let emu = state.emu.as_ref(); let svg = crate::render::svg::render_svg( &rows, session.cols, - session - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .emu - .as_ref(), + emu, + cursor_in(&rows, emu), title.as_deref(), ); + drop(state); std::fs::write(&path, svg) .map_err(|error| ShellUseError::internal(error.to_string()))?; Ok(ScreenshotResult::Path(path)) diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index a86c509..96999dd 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -12,8 +12,8 @@ use std::fmt::Write; use super::nerd_font::NerdFont; use crate::profile::{ColorSlot, Rgb}; -use crate::terminal::cell::{truncate_to_columns, Attrs, EmuCell}; -use crate::terminal::emu::Emulator; +use crate::terminal::cell::{truncate_to_columns, Attrs, EmuCell, CONTINUATION}; +use crate::terminal::emu::{CursorShape, Emulator}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; @@ -142,14 +142,83 @@ fn write_title(out: &mut String, title: &str, width: f32, colors: &dyn Emulator) ); } -/// Render a grid to a standalone SVG document. +/// How much of a cell the thin cursor shapes cover. +const CURSOR_THICKNESS: f32 = 2.0; + +/// Draw the cursor over the cell it sits on. +/// +/// A block is filled and the character redrawn in the cell's background color, +/// which is how a terminal keeps the character under a block cursor readable. +/// It is drawn after the text pass so the block covers the first, normally +/// colored draw of that character. +fn write_cursor( + out: &mut String, + rows: &[Vec], + (cx, cy): (u16, usize), + colors: &dyn Emulator, + nerd_font: &NerdFont, +) { + let Some(row) = rows.get(cy) else { + return; + }; + let cell = cell_at(row, cx as usize); + // A double-width character stores its second half as a continuation cell, + // so the cursor has to cover both or it clips the glyph down the middle. + let span = if row + .get(cx as usize + 1) + .is_some_and(|next| next.ch == CONTINUATION) + { + 2.0 + } else { + 1.0 + }; + let w = span * CELL_W; + let x = MARGIN_X + cx as f32 * CELL_W; + let y = HEADER_H + cy as f32 * CELL_H; + let fill = hex(colors.color(ColorSlot::Cursor)); + + let (rx, ry, rw, rh) = match colors.cursor_shape() { + CursorShape::Block => (x, y, w, CELL_H), + CursorShape::Underline => (x, y + CELL_H - CURSOR_THICKNESS, w, CURSOR_THICKNESS), + CursorShape::Bar => (x, y, CURSOR_THICKNESS, CELL_H), + }; + let _ = write!( + out, + r#""# + ); + + if colors.cursor_shape() != CursorShape::Block || cell.ch.trim().is_empty() { + return; + } + // Redraw exactly as the text pass would, so a vector glyph comes back as a + // glyph rather than as a character the text font may not even have. + let under = hex(bg_of(cell, colors)); + let (text, run_x_adjust) = nerd_font.prepare_run(&cell.ch, w, CELL_W); + if !text.trim().is_empty() { + let _ = write!( + out, + r#"{esc}"#, + baseline = y + FONT_BASELINE, + esc = escape(&text), + ); + } + for c in cell.ch.chars() { + nerd_font.write_use(out, c, (x, y), (CELL_W, CELL_H), run_x_adjust, &under); + } +} + +/// Render the grid. `cursor` is where to draw the cursor *within `rows`*, so a +/// caller passing scrollback has already offset it, and `None` means the +/// terminal is not showing one. `title` is the window title a program set, +/// drawn in the title bar, and `None` leaves the bar bare. /// -/// `title` is the window title a program set, drawn in the title bar. `None` -/// leaves the bar bare, exactly as it was before titles were tracked. +/// Its row is a `usize` because it indexes `rows`, which for a full-history +/// render is as long as the scrollback and so is not bounded by the screen. pub fn render_svg( rows: &[Vec], cols: u16, colors: &dyn Emulator, + cursor: Option<(u16, usize)>, title: Option<&str>, ) -> String { let nerd_font = NerdFont::new(rows, FONT_SIZE); @@ -260,6 +329,10 @@ pub fn render_svg( } } + if let Some(at) = cursor { + write_cursor(&mut out, rows, at, colors, &nerd_font); + } + out.push_str(""); out } @@ -286,6 +359,169 @@ mod tests { } } + /// Each shape draws something recognisably different. + /// + /// A block covers the cell, an underline sits on the bottom edge, and a + /// bar on the left, so all three are checked by the rectangle they emit + /// rather than by merely appearing. + #[test] + fn each_cursor_shape_draws_its_own_rectangle() { + use crate::terminal::emu::Emulator; + let rows = vec![vec![cell("x", None, None)]]; + let cursor_fill = hex(Profile::default().colors.cursor); + + let mut emu = colors(); + let block = render_svg(&rows, 1, &emu, Some((0, 0)), None); + assert!( + block.contains(&format!( + r#"width="10.00" height="21.00" fill="{cursor_fill}""# + )), + "a block covers the whole cell: {block}" + ); + + emu.process(b"\x1b[4 q"); + let underline = render_svg(&rows, 1, &emu, Some((0, 0)), None); + assert!( + underline.contains(&format!( + r#"width="10.00" height="2.00" fill="{cursor_fill}""# + )), + "an underline is a thin full-width bar: {underline}" + ); + + emu.process(b"\x1b[6 q"); + let bar = render_svg(&rows, 1, &emu, Some((0, 0)), None); + assert!( + bar.contains(&format!( + r#"width="2.00" height="21.00" fill="{cursor_fill}""# + )), + "a bar is a thin full-height stripe: {bar}" + ); + } + + /// The character under a block cursor is redrawn in the cell background, + /// which is how a terminal keeps it readable rather than hiding it behind + /// the block. + #[test] + fn a_block_cursor_keeps_its_character_readable() { + let rows = vec![vec![cell("Z", None, None)]]; + let svg = render_svg(&rows, 1, &colors(), Some((0, 0)), None); + let background = hex(Profile::default().colors.background); + assert!( + svg.contains(&format!(r#"fill="{background}""#)) && svg.matches(">Z<").count() == 2, + "the character is drawn again, in the background color: {svg}" + ); + } + + /// A double-width character keeps both of its halves. + /// + /// The second half lives in a continuation cell, so a cursor sized to one + /// cell would cover half the glyph and redraw it squashed into that half. + #[test] + fn a_block_cursor_covers_a_double_width_character() { + let rows = vec![vec![ + cell("日", None, None), + cell(CONTINUATION, None, None), + cell("a", None, None), + ]]; + let svg = render_svg(&rows, 3, &colors(), Some((0, 0)), None); + let cursor_fill = hex(Profile::default().colors.cursor); + assert!( + svg.contains(&format!( + r#"width="20.00" height="21.00" fill="{cursor_fill}""# + )), + "the block spans both halves: {svg}" + ); + assert!( + svg.contains( + r#"textLength="20.00" lengthAdjust="spacingAndGlyphs" xml:space="preserve">日<"# + ), + "the redraw is given both halves too, so it is not squashed: {svg}" + ); + } + + /// A vector glyph under a block cursor comes back as a glyph. + /// + /// Nerd font characters are drawn as `` references and masked out of + /// the text run, so redrawing one as text would emit a character the text + /// font has no glyph for and the block would simply swallow it. + #[test] + fn a_block_cursor_redraws_a_vector_glyph() { + let rows = vec![vec![cell("\u{f115}", None, None)]]; + let background = hex(Profile::default().colors.background); + let svg = render_svg(&rows, 1, &colors(), Some((0, 0)), None); + assert_eq!( + svg.matches("")); assert!(svg.contains("textLength")); @@ -356,7 +592,7 @@ mod tests { #[test] fn emits_window_chrome() { - let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &colors(), None); + let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &colors(), None, None); assert!(svg.contains("<")); } @@ -381,7 +617,7 @@ mod tests { #[test] fn background_run_emitted_for_non_default_bg() { let rows = vec![vec![cell(" ", None, Some(Color::from_index(4)))]]; - let svg = render_svg(&rows, 1, &colors(), None); + let svg = render_svg(&rows, 1, &colors(), None, None); assert!( svg.contains(&hex(colors().color(ColorSlot::Indexed(4)))), "slot 4 is painted with the profile color" @@ -396,7 +632,7 @@ mod tests { cell(glyph, None, None), cell("b", None, None), ]]; - let svg = render_svg(&rows, 3, &colors(), None); + let svg = render_svg(&rows, 3, &colors(), None, None); assert!(svg.contains(r#"")); @@ -435,8 +672,8 @@ mod tests { #[test] fn draws_the_window_title_centred_in_the_bar() { let rows = vec![vec![cell("x", None, None); 40]]; - let bare = render_svg(&rows, 40, &colors(), None); - let titled = render_svg(&rows, 40, &colors(), Some("vim: notes.md")); + let bare = render_svg(&rows, 40, &colors(), None, None); + let titled = render_svg(&rows, 40, &colors(), None, Some("vim: notes.md")); assert!( !bare.contains("text-anchor=\"middle\""), @@ -459,7 +696,7 @@ mod tests { fn truncates_a_title_that_does_not_fit() { let rows = vec![vec![cell("x", None, None); 20]]; let long = "a-very-long-window-title-that-cannot-possibly-fit"; - let svg = render_svg(&rows, 20, &colors(), Some(long)); + let svg = render_svg(&rows, 20, &colors(), None, Some(long)); assert!(!svg.contains(long), "the full title cannot have been drawn"); let drawn = svg @@ -480,7 +717,7 @@ mod tests { #[test] fn budgets_a_wide_glyph_title_by_column() { let rows = vec![vec![cell("x", None, None); 24]]; - let svg = render_svg(&rows, 24, &colors(), Some(&"你".repeat(40))); + let svg = render_svg(&rows, 24, &colors(), None, Some(&"你".repeat(40))); let drawn = svg .split("text-anchor=\"middle\" xml:space=\"preserve\">") .nth(1) @@ -508,7 +745,13 @@ mod tests { #[test] fn escapes_markup_in_the_title() { let rows = vec![vec![cell("x", None, None); 40]]; - let svg = render_svg(&rows, 40, &colors(), Some("")); + let svg = render_svg( + &rows, + 40, + &colors(), + None, + Some(""), + ); assert!(!svg.contains("