diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index f99066b..2649adc 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -1178,6 +1178,24 @@ 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( session: &TerminalSession, full: bool, @@ -1186,16 +1204,14 @@ fn screenshot( let rows = grid(session, full); match path { Some(path) => { - let svg = crate::render::svg::render_svg( - &rows, - session.cols, - session - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .emu - .as_ref(), - ); + 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, emu, cursor_in(&rows, emu)); + 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 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>;