From c26b8a205f53e6c5ee381293814cfc2171d2365b 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 --- crates/shell-use/src/engine.rs | 23 +- crates/shell-use/src/render/svg.rs | 251 +++++++++++++++++-- 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, 352 insertions(+), 18 deletions(-) diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index 584b780..129cd20 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -868,12 +868,31 @@ 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, u16)> { + if !emu.cursor_visible() { + return None; + } + let (x, y) = emu.cursor(); + let (_, screen) = emu.size(); + let history = rows.len().saturating_sub(screen as usize) as u16; + Some((x, y.saturating_add(history))) +} + fn screenshot(s: &Session, full: bool, path: Option) -> Response { let rows = grid(s, full); match path { Some(path) => { - let svg = - crate::render::svg::render_svg(&rows, s.cols, s.state.lock().unwrap().emu.as_ref()); + let st = s.state.lock().unwrap(); + let emu = st.emu.as_ref(); + let svg = crate::render::svg::render_svg(&rows, s.cols, emu, cursor_in(&rows, emu)); match std::fs::write(&path, svg) { Ok(()) => Response::with(json!({ "path": path })), Err(e) => Response::internal(e.to_string()), diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index ff15d99..ffe5bb9 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -11,9 +11,9 @@ use std::fmt::Write; use super::nerd_font::NerdFont; -use crate::profile::Rgb; -use crate::terminal::cell::{Attrs, EmuCell}; -use crate::terminal::emu::Emulator; +use crate::profile::{ColorSlot, Rgb}; +use crate::terminal::cell::{Attrs, EmuCell, CONTINUATION}; +use crate::terminal::emu::{CursorShape, Emulator}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; @@ -103,7 +103,80 @@ fn run_text(row: &[EmuCell], start: usize, end: usize) -> String { } /// Render a grid to a standalone SVG document. -pub fn render_svg(rows: &[Vec], cols: u16, colors: &dyn Emulator) -> String { +/// 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, u16), + colors: &dyn Emulator, + nerd_font: &NerdFont, +) { + let Some(row) = rows.get(cy as usize) 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. +pub fn render_svg( + rows: &[Vec], + cols: u16, + colors: &dyn Emulator, + cursor: Option<(u16, u16)>, +) -> String { let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; let x0 = MARGIN_X; @@ -209,6 +282,10 @@ pub fn render_svg(rows: &[Vec], cols: u16, colors: &dyn Emulator) -> St } } + if let Some(at) = cursor { + write_cursor(&mut out, rows, at, colors, &nerd_font); + } + out.push_str(""); out } @@ -235,6 +312,151 @@ 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))); + 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))); + 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))); + 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))); + 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))); + 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))); + assert_eq!( + svg.matches("")); assert!(svg.contains("textLength")); @@ -305,7 +527,7 @@ mod tests { #[test] fn emits_window_chrome() { - let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &colors()); + let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &colors(), None); assert!(svg.contains("<")); } @@ -330,7 +552,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()); + let svg = render_svg(&rows, 1, &colors(), None); assert!( svg.contains(&hex(colors().color(ColorSlot::Indexed(4)))), "slot 4 is painted with the profile color" @@ -345,7 +567,7 @@ mod tests { cell(glyph, None, None), cell("b", None, None), ]]; - let svg = render_svg(&rows, 3, &colors()); + let svg = render_svg(&rows, 3, &colors(), None); assert!(svg.contains(r#"")); diff --git a/crates/shell-use/src/terminal/alacritty.rs b/crates/shell-use/src/terminal/alacritty.rs index 5fdb9c3..f858dbc 100644 --- a/crates/shell-use/src/terminal/alacritty.rs +++ b/crates/shell-use/src/terminal/alacritty.rs @@ -10,8 +10,9 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line}; use alacritty_terminal::term::cell::Flags as AlacFlags; use alacritty_terminal::term::test::TermSize; -use alacritty_terminal::term::{Config as AlacConfig, Term}; +use alacritty_terminal::term::{Config as AlacConfig, Term, TermMode}; use alacritty_terminal::vte::ansi; +use alacritty_terminal::vte::ansi::CursorShape as AlacCursorShape; use alacritty_terminal::vte::ansi::NamedColor; use alacritty_terminal::vte::ansi::Rgb as AlacRgb; @@ -19,7 +20,7 @@ use compact_str::{CompactString, ToCompactString}; use crate::profile::{xterm_color, ColorSlot, Profile, Rgb}; use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle, CONTINUATION}; -use crate::terminal::emu::Emulator; +use crate::terminal::emu::{CursorShape, Emulator}; /// Alacritty's palette colors arrive either as a `Named` variant or an index; /// both funnel through [`Color::from_index`] so a given slot always yields the @@ -288,6 +289,23 @@ impl Emulator for AlacrittyEmu { } } + fn cursor_visible(&self) -> bool { + // `Hidden` is a shape alacritty uses for a cursor it will not draw, so + // it means the same thing as the mode being off. + self.term.mode().contains(TermMode::SHOW_CURSOR) + && self.term.cursor_style().shape != AlacCursorShape::Hidden + } + + fn cursor_shape(&self) -> CursorShape { + match self.term.cursor_style().shape { + AlacCursorShape::Underline => CursorShape::Underline, + AlacCursorShape::Beam => CursorShape::Bar, + // `HollowBlock` is what alacritty draws for an unfocused window, + // which a headless terminal has no notion of. + _ => CursorShape::Block, + } + } + fn cursor(&self) -> (u16, u16) { let p = self.term.grid().cursor.point; let y = p.line.0.max(0).min(self.rows as i32 - 1) as u16; diff --git a/crates/shell-use/src/terminal/conformance.rs b/crates/shell-use/src/terminal/conformance.rs index 713629c..7b2820d 100644 --- a/crates/shell-use/src/terminal/conformance.rs +++ b/crates/shell-use/src/terminal/conformance.rs @@ -561,6 +561,55 @@ macro_rules! emulator_conformance_tests { ); } + /// A program can hide the cursor and show it again. + /// + /// Full-screen programs hide it while they repaint, so a backend that + /// ignored `DECTCEM` would leave a cursor in every screenshot taken + /// mid-draw, parked wherever the last write happened to end. + #[test] + fn conformance_the_cursor_can_be_hidden_and_shown() { + let mut e = conformance_emu(10, 3, 100); + assert!( + e.cursor_visible(), + "a terminal starts by showing its cursor" + ); + + e.process(b"\x1b[?25l"); + assert!(!e.cursor_visible(), "DECTCEM off hides it"); + + e.process(b"\x1b[?25h"); + assert!(e.cursor_visible(), "DECTCEM on shows it again"); + } + + /// The cursor takes the shape a program asks for with `DECSCUSR`. + /// + /// Each shape has a blinking and a steady form, and both report the + /// same shape: a screenshot is one moment, so the blink is not part of + /// what a backend has to agree on. + #[test] + fn conformance_the_cursor_takes_the_shape_it_is_given() { + use $crate::terminal::emu::CursorShape; + let mut e = conformance_emu(10, 3, 100); + assert_eq!(e.cursor_shape(), CursorShape::Block, "block is the default"); + + for (sequence, expected) in [ + (&b"\x1b[3 q"[..], CursorShape::Underline), + (b"\x1b[4 q", CursorShape::Underline), + (b"\x1b[5 q", CursorShape::Bar), + (b"\x1b[6 q", CursorShape::Bar), + (b"\x1b[1 q", CursorShape::Block), + (b"\x1b[2 q", CursorShape::Block), + ] { + e.process(sequence); + assert_eq!( + e.cursor_shape(), + expected, + "{:?} selects {expected:?}", + String::from_utf8_lossy(sequence) + ); + } + } + /// A color query is answered with the session's configured color. /// /// Programs query the background to decide whether they are on a light diff --git a/crates/shell-use/src/terminal/emu.rs b/crates/shell-use/src/terminal/emu.rs index edbcb41..7b01730 100644 --- a/crates/shell-use/src/terminal/emu.rs +++ b/crates/shell-use/src/terminal/emu.rs @@ -13,6 +13,19 @@ use crate::profile::{ColorSlot, Rgb}; use crate::terminal::cell::{Color, EmuCell}; +/// The shape a terminal draws its cursor as, set with `DECSCUSR` (`CSI Ps SP q`). +/// +/// The specification defines three, each in a blinking and a steady form. The +/// blink is not represented: a screenshot is a single moment, and a blinking +/// cursor is drawn in the half of that cycle where it is visible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CursorShape { + #[default] + Block, + Underline, + Bar, +} + /// A headless terminal emulator: bytes in, cell grid out. /// /// Implementations must be `Send`; the daemon shares the emulator across its @@ -34,8 +47,20 @@ pub trait Emulator: Send { fn size(&self) -> (u16, u16); /// Cursor position as `(x, y)` (column, row), 0-based, clamped to screen. + /// + /// Always relative to the visible screen, never to the scrollback, so a + /// caller drawing over `full_rows` has to offset it by the history above. fn cursor(&self) -> (u16, u16); + /// Whether the cursor is being drawn, which programs toggle with + /// `DECTCEM` (`CSI ?25 h` and `l`). Full-screen programs routinely hide it + /// while repainting, so a screenshot that ignored this would show a cursor + /// parked wherever the last write happened to leave it. + fn cursor_visible(&self) -> bool; + + /// The shape the cursor is currently drawn as. + fn cursor_shape(&self) -> CursorShape; + /// Visible screen as rows of cells. Always `rows` entries of `cols` cells. fn viewable_rows(&self) -> Vec>;