From 4ce5544c4103cd1ecd069fcfb8f68d3fa7b475d2 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:48:31 +0700 Subject: [PATCH 1/2] feat(record): span a recording and date its asciicast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Recording::duration()` is the arithmetic every caller was writing over `frames().last()`, and #309 needed it anyway: the asciicast v2 header defines `timestamp`, `duration` and `title` and termlens emitted none of them, so a .cast attached to a bug report or a CI artifact opened in asciinema with no date and no name. The title is the command the terminal spawned, carried onto `Recording` at `stop()` because that is the only route back to it. The timestamp is taken at export and omitted when the clock is set before the epoch, rather than written as 0 — a reader that meets no timestamp shows none, where one that meets 1970 shows a wrong date. Closes #307 Closes #309 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/termlens/src/terminal.rs | 46 ++++++++++++-- crates/termlens/tests/record.rs | 108 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index ee18fb1..37d4da8 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -9,13 +9,17 @@ use std::collections::VecDeque; use std::ffi::{OsStr, OsString}; use std::fmt; +// The asciicast header is assembled key by key because two of its keys +// are optional (#309); `write!` into a String needs this in scope, and the +// anonymous import keeps `Write` meaning `io::Write` everywhere else. +use std::fmt::Write as _; use std::io::{self, Read, Write}; use std::panic::{self, AssertUnwindSafe}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Mutex, PoisonError}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize}; @@ -713,6 +717,9 @@ impl RecordingState { pub struct Recorder { state: Arc>, shared: Arc>, + /// The command being recorded, for the asciicast `title` (#309). Carried + /// from the terminal because `stop` has no other way back to it. + title: String, } impl fmt::Debug for Recorder { @@ -755,6 +762,7 @@ impl Recorder { Ok(Recording { frames: state.frames.iter().cloned().collect(), dropped: state.dropped, + title: self.title.clone(), }) } } @@ -765,6 +773,7 @@ impl Recorder { pub struct Recording { frames: Vec<(Duration, Screen)>, dropped: u64, + title: String, } impl Recording { @@ -794,6 +803,19 @@ impl Recording { self.dropped } + /// How long the recording spans: the timestamp of its last frame, and + /// [`Duration::ZERO`] when no frame was recorded. + /// + /// The span is measured between [`Terminal::record`] and the end of the + /// last **complete** frame — not the program's lifetime. A program that + /// draws its final repaint and then sits at a prompt for a second has a + /// recording a second shorter than its run, and one whose last repaint + /// was never bracketed by DEC 2026 markers has no frame to end at. + #[must_use] + pub fn duration(&self) -> Duration { + self.frames.last().map_or(Duration::ZERO, |(at, _)| *at) + } + /// The recording as an [asciicast v2] document: a header line, then one /// event per frame at its timestamp, each a full repaint — clear, home, /// then the frame through [`Screen::to_ansi`] — which is what the format @@ -807,10 +829,23 @@ impl Recording { .frames .first() .map_or((80, 24), |(_, frame)| frame.size()); - let mut out = format!( - "{{\"version\": 2, \"width\": {cols}, \"height\": {rows}, \ - \"env\": {{\"TERM\": \"xterm-256color\"}}}}\n" - ); + // The header is one line of JSON, assembled key by key because two of + // the four optional keys the v2 spec defines may be absent (#309). + // Without them a player shows a recording with no date and no name, + // which is the first question asked of a file attached to a bug + // report. Order follows the spec's own listing. + let mut out = format!("{{\"version\": 2, \"width\": {cols}, \"height\": {rows}"); + // A clock set before the epoch has no honest unix timestamp, so the + // key is omitted rather than written as 0 — a reader that meets no + // timestamp shows none, where one that meets 1970 shows a wrong date. + if let Ok(since_epoch) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + let _ = write!(out, ", \"timestamp\": {}", since_epoch.as_secs()); + } + let _ = write!(out, ", \"duration\": {:.6}", self.duration().as_secs_f64()); + if !self.title.is_empty() { + let _ = write!(out, ", \"title\": {}", json_string(&self.title)); + } + out.push_str(", \"env\": {\"TERM\": \"xterm-256color\"}}\n"); for (at, frame) in &self.frames { let mut data = String::from("\x1b[H\x1b[2J"); // `to_ansi` ends every row with a newline, the bottom one @@ -2579,6 +2614,7 @@ impl Terminal { Recorder { state, shared: Arc::clone(&self.shared), + title: self.command_desc.clone(), } } diff --git a/crates/termlens/tests/record.rs b/crates/termlens/tests/record.rs index 0bd8475..f48497d 100644 --- a/crates/termlens/tests/record.rs +++ b/crates/termlens/tests/record.rs @@ -198,3 +198,111 @@ fn the_asciicast_replays_every_row_of_every_frame() -> termlens::Result<()> { } Ok(()) } + +/// `duration()` is the span a recording covers, and the arithmetic every +/// caller was writing by hand over `frames().last()` (#307). +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY closes a DEC 2026 bracket before the content it wrapped, so a recorded frame never holds what was drawn (#149)" +)] +fn a_recording_spans_up_to_its_last_frame() -> termlens::Result<()> { + let mut t = emit( + Terminal::builder(), + &["READY", "--wait", "--raw", BURST, " DONE", "--wait"], + )?; + t.wait_until(|s| s.contains("READY"))?; + let rec = t.record(); + t.send(Key::Enter)?; + t.wait_until(|s| s.contains("DONE"))?; + let frames = rec.stop()?; + + let last = frames.frames().last().expect("three frames").0; + assert_eq!(frames.duration(), last, "the span ends at the last frame"); + assert!( + frames + .frames() + .iter() + .all(|(at, _)| *at <= frames.duration()), + "no frame lands after the span ends: {:?}", + frames + .frames() + .iter() + .map(|(at, _)| *at) + .collect::>() + ); + + // A recorder that starts after the last repaint has nothing to span. + // The refusal in `stop` is about the *application* never bracketing an + // update, not about this recorder having missed them all, so this is a + // recording with no frames rather than an error. + let empty = t.record().stop()?; + assert!(empty.is_empty()); + assert_eq!(empty.duration(), Duration::ZERO, "no frames, no span"); + + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// The asciicast header carries the optional keys a player shows: when the +/// recording was taken and what was recorded (#309). Without them a file +/// attached to a bug report answers neither question. +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY closes a DEC 2026 bracket before the content it wrapped, so a recorded frame never holds what was drawn (#149)" +)] +fn the_asciicast_header_dates_and_names_the_recording() -> termlens::Result<()> { + let mut t = emit( + Terminal::builder(), + &["READY", "--wait", "--raw", BURST, " DONE", "--wait"], + )?; + t.wait_until(|s| s.contains("READY"))?; + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("a clock after 1970") + .as_secs(); + let rec = t.record(); + t.send(Key::Enter)?; + t.wait_until(|s| s.contains("DONE"))?; + let frames = rec.stop()?; + + let cast = frames.to_asciicast(); + let header = cast.lines().next().expect("a header line"); + let parsed: serde_json::Value = + serde_json::from_str(header).expect("the header is still one line of valid JSON"); + + let timestamp = parsed["timestamp"].as_u64().expect("unix seconds"); + let after = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("a clock after 1970") + .as_secs(); + assert!( + (before..=after).contains(×tamp), + "exported now, not at some other time: {timestamp} outside {before}..={after}" + ); + + // The title is the command, which here carries the fixture's own + // backslash escapes — so it also proves the key is JSON-escaped rather + // than pasted in. + let title = parsed["title"].as_str().expect("a title"); + assert!(title.contains("emit"), "the command recorded: {title}"); + assert!(title.contains(BURST), "its arguments too: {title}"); + + let duration = parsed["duration"].as_f64().expect("a duration"); + assert!( + (duration - frames.duration().as_secs_f64()).abs() < 1e-6, + "the header's duration is the recording's: {duration}" + ); + + // The keys the header always had are unchanged. + assert_eq!(parsed["version"], 2); + assert_eq!(parsed["width"], 40); + assert_eq!(parsed["height"], 6); + assert_eq!(parsed["env"]["TERM"], "xterm-256color"); + + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} From 69abf7fa0e764bc8d602caca013a1e004c3fd427 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:48:31 +0700 Subject: [PATCH 2/2] feat(screen): give an exported SVG an accessible name `to_svg` produced an with no , no role and no aria-label. These images exist to be attached to a pull request or a bug report -- that is what #248 added them for and what .github/actions/report uploads -- and an image with no accessible name is announced as nothing at all. The root now carries role="img" and its first child is a reading `termlens screen, 80x24`, with the application's own `Screen::title` after it when one was set, escaped through the same helper the text rows use. The `styled` fixture sets a title holding `<` and `&` so the shared snapshot covers the escaping; it sets one explicitly because ConPTY supplies a title of its own when the application does not, and the snapshot is shared with the Windows leg. Closes #316 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 21 ++++++++++++++++++ crates/termlens/src/screen/render.rs | 21 +++++++++++++++++- crates/termlens/tests/export.rs | 22 +++++++++++++++++++ .../snapshots/export__json__styled_json.snap | 2 +- .../tests/snapshots/export__styled_svg.snap | 3 ++- 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdebd25..5bcb542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,27 @@ reads that marker. ## [Unreleased] +### Added + +- `Recording::duration()` — how long a recording spans, `Duration::ZERO` + when it holds no frame (#307). The span runs from `Terminal::record` to + the end of the last *complete* frame, not the program's lifetime; it is + the `frames().last()` arithmetic every caller was writing by hand. + +- The asciicast header carries `timestamp`, `duration` and `title` (#309). + A `.cast` attached to a bug report or a CI artifact now has a date and a + name in `asciinema` and in the web player, where before it had neither. + The title is the command the terminal spawned; the timestamp is taken at + export, and is omitted rather than written as `0` if the clock is set + before the epoch. + +- `Screen::to_svg` gives the image an accessible name: `role="img"` on the + root and a `<title>` first child reading `termlens screen, 80x24`, with + the application's own `Screen::title` after it when one was set (#316). + These files exist to be attached to a pull request, where an image with + no accessible name is announced as nothing at all. The title is escaped + with the same helper the text rows use. + ## [0.11.0] - 2026-09-11 ### Changed diff --git a/crates/termlens/src/screen/render.rs b/crates/termlens/src/screen/render.rs index 7206c5f..066f1bd 100644 --- a/crates/termlens/src/screen/render.rs +++ b/crates/termlens/src/screen/render.rs @@ -173,6 +173,12 @@ impl Screen { /// monospace fallback font stack and no font embedding — enough for a /// bug report or a README, not a typeset transcript. Concealed text is /// drawn as blanks, as a terminal shows it; dim is opacity. + /// + /// The root carries `role="img"` and a `<title>` naming the picture — + /// `termlens screen, 80x24`, and the application's own + /// [`title`](Self::title) after it when one was set. These files are made + /// to be attached to a pull request or a bug report, where an image with + /// no accessible name is announced as nothing at all. #[must_use] pub fn to_svg(&self) -> String { const CELL_W: u32 = 9; @@ -184,8 +190,21 @@ impl Screen { out, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" \ viewBox=\"0 0 {width} {height}\" font-family=\"'JetBrains Mono', 'Fira Code', \ - Menlo, Consolas, 'DejaVu Sans Mono', monospace\" font-size=\"14\">" + Menlo, Consolas, 'DejaVu Sans Mono', monospace\" font-size=\"14\" \ + role=\"img\">" ); + // The accessible name, and the first child because that is where a + // reader looks for it (#316). The size alone is a poor name but an + // honest one; the application's own title is the better one when it + // set one, and it is arbitrary text, so it is escaped like any cell. + out.push_str("<title>"); + let mut name = format!("termlens screen, {}x{}", self.cols(), self.rows()); + if !self.title().is_empty() { + name.push_str(": "); + name.push_str(self.title()); + } + escape_xml(&name, &mut out); + out.push_str("\n"); let _ = writeln!( out, "" diff --git a/crates/termlens/tests/export.rs b/crates/termlens/tests/export.rs index 12cdf87..661bdff 100644 --- a/crates/termlens/tests/export.rs +++ b/crates/termlens/tests/export.rs @@ -19,8 +19,17 @@ fn emit(steps: &[&str]) -> termlens::Result { /// A small styled screen every rendering below is made from: a bold cyan /// title, a reverse-video highlight, a dim note, a concealed field, a wide /// character and an RGB background. +/// +/// It sets its window title too, and to something holding `<` and `&`: the +/// SVG names itself with it (#316), so the rendering has to escape it. The +/// title is set explicitly rather than left unset because ConPTY sets one of +/// its own when the application does not, and the snapshots below are shared +/// with the Windows leg — an application that sets its own title reads back +/// exactly as set there (`observe.rs` asserts that on every platform). fn styled() -> termlens::Result { emit(&[ + "--raw", + r"\e]0;myapp <2> & co\a", "--raw", r"\e[1;36mmyapp\e[0m \e[7m> Alpha\e[0m 汉字\n", "--raw", @@ -116,6 +125,19 @@ fn the_three_renderings_are_pure_functions_of_the_screen() -> termlens::Result<( svg.starts_with(""), "{svg}"); + let title_at = svg.find("").expect("a "); + assert!( + svg[..title_at].ends_with(">\n"), + "the title is the first child, not buried: {svg}" + ); + assert!( + svg.contains("<title>termlens screen, 30x4: myapp <2> & co"), + "the title is escaped, not pasted: {svg}" + ); assert!( svg.contains("汉字"), "a wide character is one glyph: {svg}" diff --git a/crates/termlens/tests/snapshots/export__json__styled_json.snap b/crates/termlens/tests/snapshots/export__json__styled_json.snap index f0dcaec..be86eda 100644 --- a/crates/termlens/tests/snapshots/export__json__styled_json.snap +++ b/crates/termlens/tests/snapshots/export__json__styled_json.snap @@ -2084,7 +2084,7 @@ expression: s ] ], "state": { - "title": "", + "title": "myapp <2> & co", "alternate_screen": false, "bracketed_paste": false, "application_cursor": false, diff --git a/crates/termlens/tests/snapshots/export__styled_svg.snap b/crates/termlens/tests/snapshots/export__styled_svg.snap index 3f9d890..65d9b2e 100644 --- a/crates/termlens/tests/snapshots/export__styled_svg.snap +++ b/crates/termlens/tests/snapshots/export__styled_svg.snap @@ -2,7 +2,8 @@ source: crates/termlens/tests/export.rs expression: svg --- - + +termlens screen, 30x4: myapp <2> & co myapp