Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion crates/termlens/src/screen/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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("</title>\n");
let _ = writeln!(
out,
"<rect width=\"{width}\" height=\"{height}\" fill=\"{DEFAULT_BG}\"/>"
Expand Down
46 changes: 41 additions & 5 deletions crates/termlens/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -713,6 +717,9 @@ impl RecordingState {
pub struct Recorder {
state: Arc<Mutex<RecordingState>>,
shared: Arc<Monitor<EmuState>>,
/// 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 {
Expand Down Expand Up @@ -755,6 +762,7 @@ impl Recorder {
Ok(Recording {
frames: state.frames.iter().cloned().collect(),
dropped: state.dropped,
title: self.title.clone(),
})
}
}
Expand All @@ -765,6 +773,7 @@ impl Recorder {
pub struct Recording {
frames: Vec<(Duration, Screen)>,
dropped: u64,
title: String,
}

impl Recording {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -2579,6 +2614,7 @@ impl Terminal {
Recorder {
state,
shared: Arc::clone(&self.shared),
title: self.command_desc.clone(),
}
}

Expand Down
22 changes: 22 additions & 0 deletions crates/termlens/tests/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@ fn emit(steps: &[&str]) -> termlens::Result<Terminal> {
/// 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<Terminal> {
emit(&[
"--raw",
r"\e]0;myapp <2> & co\a",
"--raw",
r"\e[1;36mmyapp\e[0m \e[7m> Alpha\e[0m 汉字\n",
"--raw",
Expand Down Expand Up @@ -116,6 +125,19 @@ fn the_three_renderings_are_pure_functions_of_the_screen() -> termlens::Result<(
svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"270\" height=\"72\""),
"{svg}"
);
// The accessible name (#316): the root is a single image, and its first
// child says what the image is — the size, and the application's own
// title after it, escaped like any other arbitrary text.
assert!(svg.contains(" role=\"img\">"), "{svg}");
let title_at = svg.find("<title>").expect("a <title>");
assert!(
svg[..title_at].ends_with(">\n"),
"the title is the first child, not buried: {svg}"
);
assert!(
svg.contains("<title>termlens screen, 30x4: myapp &lt;2&gt; &amp; co</title>"),
"the title is escaped, not pasted: {svg}"
);
assert!(
svg.contains("汉字</text>"),
"a wide character is one glyph: {svg}"
Expand Down
108 changes: 108 additions & 0 deletions crates/termlens/tests/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
);

// 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(&timestamp),
"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(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -2084,7 +2084,7 @@ expression: s
]
],
"state": {
"title": "",
"title": "myapp <2> & co",
"alternate_screen": false,
"bracketed_paste": false,
"application_cursor": false,
Expand Down
3 changes: 2 additions & 1 deletion crates/termlens/tests/snapshots/export__styled_svg.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
source: crates/termlens/tests/export.rs
expression: svg
---
<svg xmlns="http://www.w3.org/2000/svg" width="270" height="72" viewBox="0 0 270 72" font-family="'JetBrains Mono', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" font-size="14">
<svg xmlns="http://www.w3.org/2000/svg" width="270" height="72" viewBox="0 0 270 72" font-family="'JetBrains Mono', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" font-size="14" role="img">
<title>termlens screen, 30x4: myapp &lt;2&gt; &amp; co</title>
<rect width="270" height="72" fill="#1e1e1e"/>
<text x="0" y="14" fill="#11a8cd" xml:space="preserve" font-weight="bold">myapp</text>
<rect x="63" y="0" width="63" height="18" fill="#d4d4d4"/>
Expand Down