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("