diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7da6b623..9b9db03a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,6 +58,9 @@ jobs:
- run: cargo clippy -p tui-test-rs --all-targets --no-default-features -- -D warnings
- run: cargo build
- run: cargo test --workspace -- --test-threads=1
+ # The recording renderer and its font catalog are behind a feature that
+ # is off by default, so the line above never compiles them in.
+ - run: cargo test -p tui-test-rs --features recording-font-jetbrains-mono-styles -- --test-threads=1
- name: Install Python binding
run: python -m pip install --disable-pip-version-check -e ./bindings/python
diff --git a/README.md b/README.md
index 34a2b924..61449bc2 100644
--- a/README.md
+++ b/README.md
@@ -375,6 +375,37 @@ directory = "./traces"
screen-history-limit = 10
```
+Set the appearance of screenshots and recordings with `[recording.style]`:
+
+```toml
+[recording.style]
+font_size = 18
+canvas_background = "#05050a"
+canvas_padding = 30
+
+[recording.style.window]
+background = "#15151f"
+foreground = "#c8c8e0"
+divider = "#2a2a3a"
+
+[recording.style.border]
+width = 1
+color = "#3a3a52"
+radius = 12
+
+[profiles.default.colors]
+background = "#0b0b12"
+foreground = "#d8d8e8"
+```
+
+
+
+
+
+Profiles override individual keys and inherit the rest from the file.
+See the [style types and defaults](crates/tui-test/src/render/style.rs) for all options.
+The [CLI reference](references/cli.md#styling-screenshots-and-recordings) explains profile overrides.
+
The CLI checks the current directory, the platform config directory, then `~/.tui-test`. Use `--config PATH` or `TUI_TEST_CONFIG` to select a file.
### Shells and backends
diff --git a/bindings/go/native/src/input.rs b/bindings/go/native/src/input.rs
index 56fc26ff..e43bbefe 100644
--- a/bindings/go/native/src/input.rs
+++ b/bindings/go/native/src/input.rs
@@ -215,6 +215,7 @@ pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result {
backend,
shell,
profile,
+ style: Default::default(),
cols: u16_option(value.cols, "cols")?.unwrap_or(80),
rows: u16_option(value.rows, "rows")?.unwrap_or(30),
cwd: unsafe { value.cwd.optional()? },
diff --git a/bindings/go/native/src/lib.rs b/bindings/go/native/src/lib.rs
index 152579d3..52f0521f 100644
--- a/bindings/go/native/src/lib.rs
+++ b/bindings/go/native/src/lib.rs
@@ -102,6 +102,7 @@ pub unsafe extern "C" fn tui_run(
args: input::strings(args, args_len)?,
backend: o.backend,
profile: o.profile,
+ style: o.style,
cols: o.cols,
rows: o.rows,
cwd: o.cwd,
diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs
index 608c4026..4322a0be 100644
--- a/bindings/js/native/lib.rs
+++ b/bindings/js/native/lib.rs
@@ -1009,6 +1009,7 @@ fn open_options(
Ok(CoreOpenOptions {
backend: value.backend.map(Into::into).unwrap_or_default(),
profile,
+ style: Default::default(),
shell: value.shell.map(Into::into),
cols: match value.cols {
Some(cols) => u16_value(cols, "cols")?,
@@ -1041,6 +1042,7 @@ fn run_options(
Ok(CoreRunOptions {
backend: value.backend.map(Into::into).unwrap_or_default(),
profile,
+ style: Default::default(),
program: value.program,
args: value.args.unwrap_or_default(),
cols: match value.cols {
diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs
index 3943023c..e7ba9140 100644
--- a/bindings/js/test/integration.test.mjs
+++ b/bindings/js/test/integration.test.mjs
@@ -369,7 +369,8 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
await su.screenshot(screenshotPath, { zoom: 0.5 });
assert.match(
await readFile(screenshotPath, "utf8"),
- /width="139" height="92" viewBox="0 0 278 184"/,
+ // Follows the default style; update deliberately if a gap moves.
+ /width="139" height="94" viewBox="0 0 278 188"/,
);
}
await su.startRecording(path, { format, fps: 30, zoom: 0.5 });
@@ -382,11 +383,11 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
assert.deepEqual(bytes.subarray(0, 8), Buffer.from("\x89PNG\r\n\x1a\n", "latin1"));
assert.ok(bytes.includes(Buffer.from("acTL")));
assert.equal(bytes.readUInt32BE(16), 278);
- assert.equal(bytes.readUInt32BE(20), 184);
+ assert.equal(bytes.readUInt32BE(20), 188);
} else {
assert.equal(bytes.subarray(0, 6).toString("ascii"), "GIF89a");
assert.equal(bytes.readUInt16LE(6), 278);
- assert.equal(bytes.readUInt16LE(8), 184);
+ assert.equal(bytes.readUInt16LE(8), 188);
}
}
} finally {
diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs
index cdce8759..185dc810 100644
--- a/bindings/python/native/src/lib.rs
+++ b/bindings/python/native/src/lib.rs
@@ -228,6 +228,7 @@ impl NativeSession {
Operation::Open(OpenOptions {
backend: parse_backend(backend.as_deref())?,
profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?,
+ style: Default::default(),
shell: parse_shell(shell.as_deref())?,
cols: integer_u16(&cols, "cols")?,
rows: integer_u16(&rows, "rows")?,
@@ -311,6 +312,7 @@ impl NativeSession {
Operation::Run(RunOptions {
backend: parse_backend(backend.as_deref())?,
profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?,
+ style: Default::default(),
program,
args,
cols: integer_u16(&cols, "cols")?,
diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py
index 8a57d30b..7ba90c02 100644
--- a/bindings/python/tests/test_integration.py
+++ b/bindings/python/tests/test_integration.py
@@ -211,8 +211,10 @@ async def scenario():
str(screenshot), zoom=0.5
)
self.assertIn(
- 'width="139" height="92" '
- 'viewBox="0 0 278 184"',
+ # Follows the default style; update
+ # deliberately if a default gap moves.
+ 'width="139" height="94" '
+ 'viewBox="0 0 278 188"',
screenshot.read_text(encoding="utf-8"),
)
await su.start_recording(
@@ -231,7 +233,7 @@ async def scenario():
int.from_bytes(data[16:20], "big"), 278
)
self.assertEqual(
- int.from_bytes(data[20:24], "big"), 184
+ int.from_bytes(data[20:24], "big"), 188
)
else:
self.assertEqual(data[:6], b"GIF89a")
@@ -239,7 +241,7 @@ async def scenario():
int.from_bytes(data[6:8], "little"), 278
)
self.assertEqual(
- int.from_bytes(data[8:10], "little"), 184
+ int.from_bytes(data[8:10], "little"), 188
)
run(scenario())
diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs
index 3587ad4c..5154c891 100644
--- a/crates/tui-test-cli/src/main.rs
+++ b/crates/tui-test-cli/src/main.rs
@@ -309,6 +309,7 @@ fn build_request(command: Command) -> anyhow::Result {
restart,
timeouts: settings.timeouts.with_overrides(timeouts.into()),
recording: Box::new(settings.recording),
+ style: Box::new(settings.style.clone()),
trace: settings.trace,
diagnostics: tui_test::DiagnosticRetentionOptions {
screen_history_limit: diagnostics
@@ -348,6 +349,7 @@ fn build_request(command: Command) -> anyhow::Result {
restart,
timeouts: settings.timeouts.with_overrides(timeouts.into()),
recording: Box::new(settings.recording),
+ style: Box::new(settings.style.clone()),
trace: settings.trace,
diagnostics: tui_test::DiagnosticRetentionOptions {
screen_history_limit: diagnostics
diff --git a/crates/tui-test-cli/src/protocol.rs b/crates/tui-test-cli/src/protocol.rs
index 4d92cd80..0f880350 100644
--- a/crates/tui-test-cli/src/protocol.rs
+++ b/crates/tui-test-cli/src/protocol.rs
@@ -31,6 +31,10 @@ pub enum Request {
/// project-local config against.
#[serde(default)]
profile: tui_test::profile::Profile,
+ /// How this session's screenshots and recordings are drawn, resolved
+ /// from the config file by the client for the same reason.
+ #[serde(default)]
+ style: Box,
cols: u16,
rows: u16,
cwd: Option,
@@ -438,6 +442,7 @@ impl Request {
program,
backend,
profile,
+ style,
cols,
rows,
cwd,
@@ -457,6 +462,7 @@ impl Request {
Ok(Operation::Run(RunOptions {
backend,
profile,
+ style: *style,
program: executable,
args: parts.collect(),
cols,
@@ -472,6 +478,7 @@ impl Request {
Ok(Operation::Open(OpenOptions {
backend,
profile,
+ style: *style,
shell,
cols,
rows,
@@ -839,6 +846,7 @@ mod tests {
program: None,
backend: Backend::default(),
profile: Default::default(),
+ style: Box::default(),
cols: 80,
rows: 30,
cwd: None,
diff --git a/crates/tui-test-cli/tests/session_lifecycle.rs b/crates/tui-test-cli/tests/session_lifecycle.rs
index 3b1aa980..2ea3942b 100644
--- a/crates/tui-test-cli/tests/session_lifecycle.rs
+++ b/crates/tui-test-cli/tests/session_lifecycle.rs
@@ -364,7 +364,8 @@ fn screenshots_dispatch_by_extension_without_changing_svg_output() {
sandbox.ok(&["screenshot", zoomed_svg.to_str().unwrap(), "--zoom", "2"]);
let zoomed_svg = std::fs::read_to_string(zoomed_svg).unwrap();
assert!(
- zoomed_svg.contains(r#"width="236" height="326" viewBox="0 0 118 163""#),
+ // Follows the default style; update deliberately if a gap moves.
+ zoomed_svg.contains(r#"width="236" height="334" viewBox="0 0 118 167""#),
"unexpected zoomed SVG dimensions: {zoomed_svg}"
);
@@ -376,7 +377,7 @@ fn screenshots_dispatch_by_extension_without_changing_svg_output() {
let mut reader = decoder.read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut pixels).unwrap();
- assert_eq!((info.width, info.height), (236, 326));
+ assert_eq!((info.width, info.height), (236, 334));
let pixels = &pixels[..info.buffer_size()];
assert!(
contains_rgba(pixels, [128, 0, 0, 255]),
@@ -2698,7 +2699,9 @@ fn a_window_title_is_tracked_asserted_and_drawn() {
backend.as_str()
);
assert!(
- image.contains(r#"width="239" height="365" viewBox="0 0 478 730""#),
+ // The default style at zoom 0.5; update deliberately if a
+ // default gap moves.
+ image.contains(r#"width="239" height="367" viewBox="0 0 478 734""#),
"{} changed the SVG dimensions at zoom 0.5: {image}",
backend.as_str()
);
diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs
index 9f0c241f..a5206319 100644
--- a/crates/tui-test/src/api.rs
+++ b/crates/tui-test/src/api.rs
@@ -91,6 +91,9 @@ pub struct OpenOptions {
pub restart: bool,
pub timeouts: Timeouts,
pub recording: AutomaticRecording,
+ /// How this session's screenshots and recordings are drawn, resolved
+ /// from the config file by the client alongside `profile`.
+ pub style: crate::render::style::Style,
}
impl Default for OpenOptions {
@@ -107,6 +110,7 @@ impl Default for OpenOptions {
restart: false,
timeouts: Timeouts::default(),
recording: AutomaticRecording::default(),
+ style: crate::render::style::Style::default(),
}
}
}
@@ -129,6 +133,9 @@ pub struct RunOptions {
pub restart: bool,
pub timeouts: Timeouts,
pub recording: AutomaticRecording,
+ /// How this session's screenshots and recordings are drawn, resolved
+ /// from the config file by the client alongside `profile`.
+ pub style: crate::render::style::Style,
}
/// Clipboard text or regex.
diff --git a/crates/tui-test/src/diagnostics.rs b/crates/tui-test/src/diagnostics.rs
index 9ce8a53d..aa76db6b 100644
--- a/crates/tui-test/src/diagnostics.rs
+++ b/crates/tui-test/src/diagnostics.rs
@@ -858,12 +858,13 @@ impl FailureObservation {
}
pub(crate) fn svg(&self) -> String {
- crate::render::svg::render_svg_with_zoom(
+ crate::render::svg::render_svg(
&self.rows,
self.cols,
&self.render_state,
self.cursor,
self.title.as_deref(),
+ &crate::render::style::Style::default(),
1.0,
None,
)
diff --git a/crates/tui-test/src/diagnostics/html.rs b/crates/tui-test/src/diagnostics/html.rs
index 5c837b47..08a34a1e 100644
--- a/crates/tui-test/src/diagnostics/html.rs
+++ b/crates/tui-test/src/diagnostics/html.rs
@@ -72,7 +72,7 @@ struct FrameCell {
impl FrameCell {
fn capture(cell: &EmuCell, width: u8, colors: &dyn RenderColors) -> Self {
- let style = svg::style_of(cell, colors);
+ let style = svg::cell_paint(cell, colors);
Self {
char: cell.ch.to_string(),
link: cell.uri().map(str::to_string),
@@ -120,11 +120,16 @@ fn timeline_with_limit(
let mut timeline = Timeline {
schema_version: 1,
failure_screen_sequence: observation.screen_sequence,
- geometry: Geometry {
- grid_x: svg::CANVAS_PADDING as f32 + svg::MARGIN_X,
- grid_y: svg::CANVAS_PADDING as f32 + svg::HEADER_H + svg::CONTENT_PADDING_TOP,
- cell_width: svg::CELL_W,
- cell_height: svg::CELL_H,
+ // The trace viewer draws its own grid rather than the styled SVG, so
+ // it is placed with the default geometry.
+ geometry: {
+ let style = crate::render::style::Style::default();
+ Geometry {
+ grid_x: style.canvas_left() as f32 + style.content_left(),
+ grid_y: style.canvas_top() as f32 + style.header_height() + style.content_top(),
+ cell_width: style.cell_width(),
+ cell_height: style.cell_height(),
+ }
},
frames: sources
.values()
@@ -188,7 +193,7 @@ fn timeline_with_limit(
} else {
frame.omission = None;
let cursor = &frame.screen.cursor;
- frame.svg = Some(svg::render_svg_with_zoom(
+ frame.svg = Some(svg::render_svg(
&source.rows,
frame.screen.size.cols,
&source.render_state,
@@ -196,6 +201,7 @@ fn timeline_with_limit(
.visible
.then_some((cursor.column, usize::from(cursor.row))),
frame.screen.title.as_deref(),
+ &crate::render::style::Style::default(),
1.0,
None,
));
diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs
index 7cf35176..77d3d8c9 100644
--- a/crates/tui-test/src/engine.rs
+++ b/crates/tui-test/src/engine.rs
@@ -636,6 +636,7 @@ impl Engine {
program,
backend,
profile,
+ style,
cols,
rows,
cwd,
@@ -650,6 +651,7 @@ impl Engine {
None,
options.backend,
options.profile,
+ options.style.clone(),
options.cols,
options.rows,
options.cwd.clone(),
@@ -668,6 +670,7 @@ impl Engine {
Some(program),
options.backend,
options.profile,
+ options.style.clone(),
options.cols,
options.rows,
options.cwd.clone(),
@@ -679,6 +682,7 @@ impl Engine {
)
}
};
+ style.validate().map_err(TuiTestError::usage)?;
crate::terminal::pty::validate_size(cols, rows)?;
let cwd = match &spec.resolved_cwd {
Some(cwd) => cwd.clone(),
@@ -770,6 +774,7 @@ impl Engine {
program.clone(),
backend,
profile,
+ style,
cols,
rows,
Some(cwd),
@@ -4338,14 +4343,21 @@ fn screenshot(
let zoom = crate::api::resolve_zoom(zoom)?;
let format = ScreenshotFormat::infer(&path)?;
let snapshot = svg_snapshot(session, full);
+ let style = session
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .style
+ .clone();
match format {
ScreenshotFormat::Svg => {
- let svg = crate::render::svg::render_svg_with_zoom(
+ let svg = crate::render::svg::render_svg(
&snapshot.rows,
snapshot.cols,
&snapshot.render_state,
snapshot.cursor,
snapshot.title.as_deref(),
+ &style,
zoom,
background,
);
@@ -4367,6 +4379,7 @@ fn screenshot(
snapshot.cols,
rows,
zoom,
+ style,
background,
)
.map_err(|error| TuiTestError::internal(error.to_string()))?;
@@ -4430,6 +4443,7 @@ mod tests {
args: args.into_iter().map(str::to_string).collect(),
backend: defaults.backend,
profile: defaults.profile,
+ style: defaults.style,
cols: 80,
rows: 24,
cwd: None,
@@ -5311,6 +5325,7 @@ mod tests {
backend: crate::Backend::Alacritty,
shell: None,
profile,
+ style: crate::render::style::Style::default(),
cols: 87,
rows: 29,
cwd: Some(cwd.clone()),
@@ -5376,6 +5391,9 @@ mod tests {
&snapshot.render_state,
snapshot.cursor,
snapshot.title.as_deref(),
+ &crate::render::style::Style::default(),
+ 1.0,
+ None,
);
assert_eq!(svg.matches('X').count(), 2, "text plus block redraw: {svg}");
diff --git a/crates/tui-test/src/profile.rs b/crates/tui-test/src/profile.rs
index a6420992..7805159f 100644
--- a/crates/tui-test/src/profile.rs
+++ b/crates/tui-test/src/profile.rs
@@ -329,31 +329,84 @@ impl Default for Profile {
}
}
-/// A profile as represented in `tui-test.toml`.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+/// Recording settings. Profiles inherit omitted keys from the file.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
-pub struct ConfigProfile {
- pub scrollback: usize,
- pub colors: Colors,
- pub timeouts: crate::api::Timeouts,
+pub struct RecordingConfig {
+ pub directory: Option,
+ /// How screenshots and recordings taken under this profile are drawn.
+ pub style: Option,
}
-impl Default for ConfigProfile {
- fn default() -> Self {
+fn fill_missing(overlay: &mut toml::Value, base: &toml::Value) {
+ let (toml::Value::Table(overlay), toml::Value::Table(base)) = (overlay, base) else {
+ return;
+ };
+ for (key, value) in base {
+ match overlay.get_mut(key) {
+ Some(existing) => fill_missing(existing, value),
+ None => {
+ overlay.insert(key.clone(), value.clone());
+ }
+ }
+ }
+}
+
+impl RecordingConfig {
+ fn validate(&self) -> anyhow::Result<()> {
+ if self
+ .directory
+ .as_ref()
+ .is_some_and(|d| d.as_os_str().is_empty())
+ {
+ anyhow::bail!("recording directory must not be empty");
+ }
+ if let Some(style) = &self.style {
+ style.validate().map_err(|error| anyhow::anyhow!(error))?;
+ }
+ Ok(())
+ }
+
+ /// A relative directory means "beside this config", wherever the process
+ /// happens to be running from.
+ fn anchor(&mut self, parent: &Path) {
+ if let Some(directory) = self.directory.as_mut() {
+ if directory.is_relative() {
+ *directory = parent.join(&*directory);
+ }
+ }
+ if let Some(style) = self.style.as_mut() {
+ style.font.resolve_paths(parent);
+ }
+ }
+
+ fn over(&self, base: &RecordingConfig) -> Self {
Self {
- scrollback: DEFAULT_SCROLLBACK,
- colors: Colors::default(),
- timeouts: crate::api::Timeouts::default(),
+ directory: self.directory.clone().or_else(|| base.directory.clone()),
+ style: self.style.clone().or_else(|| base.style.clone()),
}
}
}
+/// A profile as represented in `tui-test.toml`.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct ConfigProfile {
+ pub scrollback: Option,
+ pub colors: Colors,
+ pub timeouts: crate::api::Timeouts,
+ /// Overrides for the file's `[recording]` table.
+ pub recording: RecordingConfig,
+}
+
/// Concrete session settings resolved from a config profile.
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[derive(Debug, Clone, Default, PartialEq)]
pub struct Settings {
pub profile: Profile,
pub timeouts: crate::api::Timeouts,
pub recording: crate::api::AutomaticRecording,
+ /// How this session's screenshots and recordings are drawn.
+ pub style: crate::render::style::Style,
pub diagnostics: crate::diagnostics::DiagnosticRetentionOptions,
pub trace: crate::diagnostics::TraceOptions,
}
@@ -362,11 +415,12 @@ impl From for Settings {
fn from(value: ConfigProfile) -> Self {
Self {
profile: Profile {
- scrollback: value.scrollback,
+ scrollback: value.scrollback.unwrap_or(DEFAULT_SCROLLBACK),
colors: value.colors,
},
timeouts: value.timeouts,
recording: crate::api::AutomaticRecording::default(),
+ style: crate::render::style::Style::default(),
diagnostics: crate::diagnostics::DiagnosticRetentionOptions::default(),
trace: crate::diagnostics::TraceOptions::default(),
}
@@ -378,15 +432,63 @@ impl From for Settings {
#[serde(default, deny_unknown_fields)]
pub struct ConfigFile {
pub profiles: BTreeMap,
- pub recording: crate::api::AutomaticRecording,
+ pub recording: RecordingConfig,
pub diagnostics: crate::diagnostics::DiagnosticRetentionOptions,
pub trace: crate::diagnostics::TraceOptions,
}
impl ConfigFile {
+ /// Merge raw keys before serde fills omitted fields with built-in defaults.
+ fn inherit_profile_styles(&mut self, toml_text: &str) -> anyhow::Result<()> {
+ let document: toml::Value = toml::from_str(toml_text)?;
+ let Some(base) = document
+ .get("recording")
+ .and_then(|recording| recording.get("style"))
+ else {
+ return Ok(());
+ };
+ let mut base = base.clone();
+ // Expand shorthand so per-side overrides inherit the uniform value.
+ for key in ["canvas_padding", "content_padding"] {
+ if let Some(value @ toml::Value::Integer(_)) = base.get_mut(key) {
+ *value = toml::Value::Table(
+ ["top", "right", "bottom", "left"]
+ .map(|side| (side.to_string(), value.clone()))
+ .into_iter()
+ .collect(),
+ );
+ }
+ }
+ let raw_profiles = document.get("profiles").and_then(toml::Value::as_table);
+ for (name, profile) in self.profiles.iter_mut() {
+ let Some(named) = raw_profiles
+ .and_then(|profiles| profiles.get(name))
+ .and_then(|profile| profile.get("recording"))
+ .and_then(|recording| recording.get("style"))
+ else {
+ continue;
+ };
+ let mut merged = named.clone();
+ fill_missing(&mut merged, &base);
+ profile.recording.style = Some(
+ merged
+ .try_into()
+ .map_err(|error| anyhow::anyhow!("profile {name:?}: {error}"))?,
+ );
+ }
+ Ok(())
+ }
+
pub fn parse(toml_text: &str) -> anyhow::Result {
- let config: Self = toml::from_str(toml_text)?;
+ let mut config: Self = toml::from_str(toml_text)?;
+ config.inherit_profile_styles(toml_text)?;
config.recording.validate()?;
+ for (name, profile) in &config.profiles {
+ profile
+ .recording
+ .validate()
+ .map_err(|error| anyhow::anyhow!("profile {name:?}: {error}"))?;
+ }
config.diagnostics.validate().map_err(anyhow::Error::msg)?;
config.trace.validate().map_err(anyhow::Error::msg)?;
Ok(config)
@@ -402,13 +504,13 @@ impl ConfigFile {
.map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?;
let mut config =
Self::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
- if let Some(directory) = config.recording.directory.as_mut() {
- if directory.is_relative() {
- *directory = path
- .parent()
- .unwrap_or_else(|| Path::new("."))
- .join(&*directory);
- }
+ let parent = path
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .to_path_buf();
+ config.recording.anchor(&parent);
+ for profile in config.profiles.values_mut() {
+ profile.recording.anchor(&parent);
}
if config.trace.directory.is_relative() {
config.trace.directory = path
@@ -428,7 +530,10 @@ impl ConfigFile {
/// The named profile and its session timeout defaults.
pub fn settings(&self, name: Option<&str>) -> anyhow::Result {
let profile = match name {
- Some(name) => self.profiles.get(name).copied().ok_or_else(|| {
+ Some(name) if name == DEFAULT_PROFILE && !self.profiles.contains_key(name) => {
+ Ok(ConfigProfile::default())
+ }
+ Some(name) => self.profiles.get(name).cloned().ok_or_else(|| {
let known: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
if known.is_empty() {
anyhow::anyhow!("no profile {name:?}; the config file defines none")
@@ -439,11 +544,17 @@ impl ConfigFile {
None => Ok(self
.profiles
.get(DEFAULT_PROFILE)
- .copied()
+ .cloned()
.unwrap_or_default()),
}?;
+ let recording = profile.recording.over(&self.recording);
let mut settings: Settings = profile.into();
- settings.recording = self.recording.clone();
+ // The engine derives recording retention from the trace mode.
+ settings.recording = crate::api::AutomaticRecording {
+ directory: recording.directory,
+ ..crate::api::AutomaticRecording::default()
+ };
+ settings.style = recording.style.unwrap_or_default();
settings.diagnostics = self.diagnostics;
settings.trace = self.trace.clone();
Ok(settings)
@@ -525,19 +636,26 @@ pub fn resolve_settings(
}
}
+/// The recording policy in force when no session recorded where its output
+/// went, used to explain why there is nothing to show.
+///
+/// Resolved through `settings` rather than off the file's own `[recording]`,
+/// so a `[profiles.default.recording]` is honored here exactly as it is when a
+/// session opens. A session opened under a *named* profile is not knowable
+/// from here, which is why this is only ever a fallback explanation.
pub fn resolve_recording(
explicit_config: Option<&Path>,
cwd: &Path,
) -> anyhow::Result {
if let Some(path) = explicit_config {
- return Ok(ConfigFile::load(path)?.recording);
+ return Ok(ConfigFile::load(path)?.settings(None)?.recording);
}
if let Some(path) = std::env::var_os("TUI_TEST_CONFIG").map(PathBuf::from) {
- return Ok(ConfigFile::load(&path)?.recording);
+ return Ok(ConfigFile::load(&path)?.settings(None)?.recording);
}
for path in default_search_paths(cwd) {
if path.is_file() {
- return Ok(ConfigFile::load(&path)?.recording);
+ return Ok(ConfigFile::load(&path)?.settings(None)?.recording);
}
}
Ok(crate::api::AutomaticRecording::default())
@@ -671,6 +789,306 @@ mod tests {
assert!(ConfigFile::parse("[recording]\ndirectory = \"\"\n").is_err());
}
+ #[test]
+ fn a_profile_style_overrides_the_file_style_key_by_key() {
+ let config = ConfigFile::parse(
+ "[recording.style]\nfont_size = 20\ncanvas_background = \"#ff0000\"\ncanvas_padding = 12\n\
+ \n[recording.style.window]\ntitle_bar = false\nforeground = \"#00ff00\"\n\
+ \n[profiles.docs.recording.style]\nfont_size = 24\n\
+ \n[profiles.docs.recording.style.window]\nforeground = \"#0000ff\"\n\
+ \n[profiles.plain]\n",
+ )
+ .unwrap();
+
+ let docs = config.settings(Some("docs")).unwrap().style;
+ assert_eq!(docs.font_size, 24.0, "the key the profile names wins");
+ assert_eq!(
+ docs.canvas_background,
+ Rgb::new(255, 0, 0),
+ "a key the profile does not name keeps the file's value"
+ );
+ assert_eq!(docs.canvas_top(), 12);
+ assert_eq!(
+ docs.window.foreground,
+ Rgb::new(0, 0, 255),
+ "the rule reaches into sub-tables"
+ );
+ assert!(
+ !docs.window.title_bar,
+ "and a sibling inside that sub-table still comes from the file"
+ );
+
+ let plain = config.settings(Some("plain")).unwrap().style;
+ assert_eq!(plain.font_size, 20.0, "naming no style inherits the file's");
+ assert_eq!(plain.canvas_background, Rgb::new(255, 0, 0));
+ }
+
+ #[test]
+ fn profile_padding_overrides_preserve_unnamed_sides() {
+ for (base, overlay, canvas, content) in [
+ (
+ "canvas_padding = 30\ncontent_padding = 20",
+ "[profiles.docs.recording.style.canvas_padding]\nbottom = 48\n\
+ [profiles.docs.recording.style.content_padding]\nbottom = 48",
+ [30, 30, 48, 30],
+ [20, 20, 48, 20],
+ ),
+ (
+ "[recording.style.canvas_padding]\ntop = 30\n\
+ [recording.style.content_padding]\ntop = 20",
+ "[profiles.docs.recording.style.canvas_padding]\nbottom = 48\n\
+ [profiles.docs.recording.style.content_padding]\nbottom = 48",
+ [30, 24, 48, 24],
+ [20, 15, 48, 15],
+ ),
+ (
+ "[recording.style.canvas_padding]\ntop = 30\n\
+ [recording.style.content_padding]\ntop = 20",
+ "[profiles.docs.recording.style]\ncanvas_padding = 0\ncontent_padding = 0",
+ [0; 4],
+ [0; 4],
+ ),
+ ] {
+ let config =
+ ConfigFile::parse(&format!("[recording.style]\n{base}\n{overlay}")).unwrap();
+ let style = config.settings(Some("docs")).unwrap().style;
+ assert_eq!(
+ [
+ style.canvas_top(),
+ style.canvas_right(),
+ style.canvas_bottom(),
+ style.canvas_left(),
+ ],
+ canvas
+ );
+ assert_eq!(
+ [
+ style.content_top(),
+ style.content_right(),
+ style.content_bottom(),
+ style.content_left(),
+ ],
+ content.map(|side| side as f32)
+ );
+ }
+ }
+
+ #[test]
+ fn a_profile_recording_is_validated_and_anchored() {
+ let error = ConfigFile::parse("[profiles.docs.recording]\ndirectory = \"\"\n")
+ .expect_err("an empty directory is rejected wherever it is written");
+ assert!(
+ error.to_string().contains("docs"),
+ "the error names the profile: {error}"
+ );
+
+ let error = ConfigFile::parse("[profiles.docs.recording.style]\nfont_size = 0\n")
+ .expect_err("a style that cannot be drawn is rejected at parse");
+ assert!(
+ error.to_string().contains("font_size"),
+ "the error names the value: {error}"
+ );
+
+ let dir = std::env::temp_dir().join(format!("tui-test-anchor-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join(CONFIG_FILE);
+ std::fs::write(
+ &path,
+ "[profiles.docs.recording]\ndirectory = \"artifacts\"\n\
+ \n[profiles.docs.recording.style.font]\nfiles = [\"fonts/Berkeley.ttf\"]\n",
+ )
+ .unwrap();
+ let config = ConfigFile::load(&path).unwrap();
+ assert_eq!(
+ config.profiles["docs"].recording.directory,
+ Some(dir.join("artifacts")),
+ "a relative directory anchors to the config, not the working directory"
+ );
+ assert_eq!(
+ config.profiles["docs"]
+ .recording
+ .style
+ .as_ref()
+ .unwrap()
+ .font
+ .files,
+ vec![dir.join("fonts").join("Berkeley.ttf")],
+ "and so does a font the repository carries"
+ );
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ /// The defaults written down in `references/cli.md`. Documentation that
+ /// drifts from the code is worse than none, so the numbers in it are
+ /// pinned here rather than trusted.
+ #[test]
+ fn the_documented_defaults_are_the_real_ones() {
+ let style = crate::render::style::Style::default();
+ assert_eq!(style.font_size, 17.0);
+ assert_eq!(style.title_font_size, 13.0);
+ assert_eq!(style.canvas_background, Rgb::new(0x68, 0x67, 0xaa));
+ assert_eq!(style.canvas_top(), 24);
+ assert_eq!(
+ (
+ style.content_top(),
+ style.content_right(),
+ style.content_bottom(),
+ style.content_left()
+ ),
+ (8.0, 15.0, 14.0, 15.0)
+ );
+ assert_eq!(
+ style.font.family,
+ "'Cascadia Code','JetBrains Mono','Fira Code',Menlo,Consolas,'DejaVu Sans Mono',monospace"
+ );
+ assert!(style.window.title_bar);
+ assert!(style.window.traffic_lights);
+ assert_eq!(style.window.background, Rgb::new(0xd9, 0xd9, 0xe8));
+ assert_eq!(style.window.foreground, Rgb::new(0x41, 0x41, 0x45));
+ assert_eq!(style.window.divider, Rgb::new(0, 0, 0));
+ assert_eq!(style.border.width, 0.0);
+ assert_eq!(style.border.color, Rgb::new(0, 0, 0));
+ assert_eq!(style.border.radius, 8.0);
+ assert!(style.shadow.enabled);
+ assert_eq!(style.shadow.color, Rgb::new(0x08, 0x08, 0x12));
+ assert_eq!(style.shadow.offset, 5.0);
+ assert_eq!(style.shadow.spread, 7.0);
+ }
+
+ /// The per-profile example in `references/cli.md`, and the claim the prose
+ /// around it makes about what the profile ends up with.
+ #[test]
+ fn the_documented_per_profile_example_does_what_it_says() {
+ let config = ConfigFile::parse(
+ "[recording]\ndirectory = \"./artifacts\"\n\
+ \n[recording.style]\ncanvas_background = \"#101014\"\ncanvas_padding = 32\n\
+ \n[profiles.docs.recording]\ndirectory = \"./docs/media\"\n\
+ \n[profiles.docs.recording.style]\nfont_size = 24\n",
+ )
+ .expect("the documented example parses");
+
+ let docs = config.settings(Some("docs")).unwrap();
+ assert_eq!(
+ docs.recording.directory,
+ Some(PathBuf::from("./docs/media"))
+ );
+ assert_eq!(docs.style.font_size, 24.0);
+ assert_eq!(docs.style.canvas_background, Rgb::new(0x10, 0x10, 0x14));
+ assert_eq!(docs.style.canvas_top(), 32);
+ }
+
+ /// The per-profile examples in `references/cli.md`. Each profile names a
+ /// different mix of keys, so between them they cover overriding a policy,
+ /// overriding a style key, and naming neither.
+ #[test]
+ fn the_documented_profile_overrides_resolve_as_written() {
+ let config = ConfigFile::parse(include_str!("testdata/profiles.toml"))
+ .expect("the documented example parses");
+ let style = |name| config.settings(Some(name)).unwrap().style;
+ let directory = |name| config.settings(Some(name)).unwrap().recording.directory;
+
+ // docs: overrides the directory and one style key.
+ assert_eq!(directory("docs"), Some(PathBuf::from("./docs/media")));
+ assert_eq!(style("docs").font_size, 24.0);
+ assert_eq!(
+ style("docs").canvas_background,
+ Rgb::new(0x10, 0x10, 0x14),
+ "and keeps the canvas the file set"
+ );
+ assert_eq!(style("docs").canvas_top(), 30);
+
+ // ci: names no directory at all, so it comes from the file.
+ assert_eq!(directory("ci"), Some(PathBuf::from("./artifacts")));
+ assert_eq!(style("ci").canvas_top(), 8, "its own gap");
+ assert!(!style("ci").window.title_bar);
+ assert!(!style("ci").shadow.enabled);
+ assert_eq!(
+ style("ci").window.background,
+ Rgb::new(0x1a, 0x1a, 0x22),
+ "a sibling inside the same sub-table still comes from the file"
+ );
+ assert_eq!(style("ci").font_size, 17.0);
+
+ // demo: a look of its own, still inheriting the file's directory.
+ assert_eq!(directory("demo"), Some(PathBuf::from("./artifacts")));
+ assert_eq!(style("demo").canvas_background, Rgb::new(0xf6, 0xf6, 0xf8));
+ assert_eq!(style("demo").border.width, 2.0);
+ assert_eq!(
+ style("demo").window.divider,
+ Rgb::new(0x2a, 0x2a, 0x36),
+ "the divider it did not name comes from the file"
+ );
+ assert_eq!(style("demo").canvas_top(), 30);
+ }
+
+ #[test]
+ fn naming_the_default_profile_matches_omitting_it() {
+ let config = ConfigFile::parse(
+ "[recording]\ndirectory = \"a\"\n\n[profiles.docs.recording]\ndirectory = \"b\"\n",
+ )
+ .unwrap();
+
+ assert_eq!(
+ config
+ .settings(Some(DEFAULT_PROFILE))
+ .unwrap()
+ .recording
+ .directory,
+ config.settings(None).unwrap().recording.directory,
+ "the flag's own default must not be an error on a config without it"
+ );
+ assert!(
+ config.settings(Some("nope")).is_err(),
+ "any other unknown profile is still an error"
+ );
+ }
+
+ #[test]
+ fn a_profile_that_names_no_style_keeps_the_file_style() {
+ let config = ConfigFile::parse(
+ "[recording]\ndirectory = \"artifacts\"\n\
+ \n[recording.style]\nfont_size = 30\n\
+ \n[profiles.ops.recording]\ndirectory = \"shots\"\n",
+ )
+ .unwrap();
+
+ let ops = config.settings(Some("ops")).unwrap();
+ assert_eq!(
+ ops.style.font_size, 30.0,
+ "a profile adjusting only where recordings land keeps the file's look"
+ );
+ assert_eq!(ops.recording.directory, Some(PathBuf::from("shots")));
+ }
+
+ /// A profile changes the recording settings it names and inherits the
+ /// rest. Replacing the table whole would mean that setting a font size
+ /// silently reset the mode the file had established.
+ #[test]
+ fn a_profile_recording_inherits_field_by_field() {
+ let config = ConfigFile::parse(
+ "[recording]\ndirectory = \"artifacts\"\n\
+ \n[profiles.docs.recording.style]\nfont_size = 24\n\
+ \n[profiles.ci.recording]\ndirectory = \"ci-artifacts\"\n",
+ )
+ .unwrap();
+
+ let docs = config.settings(Some("docs")).unwrap();
+ assert_eq!(docs.style.font_size, 24.0, "the profile's style applies");
+ assert_eq!(
+ docs.recording.directory,
+ Some(PathBuf::from("artifacts")),
+ "and naming only a style leaves the file's directory alone"
+ );
+
+ let ci = config.settings(Some("ci")).unwrap();
+ assert_eq!(ci.recording.directory, Some(PathBuf::from("ci-artifacts")));
+ assert_eq!(
+ ci.style,
+ crate::render::style::Style::default(),
+ "and a profile naming no style gets the default"
+ );
+ }
+
#[test]
fn trace_directory_is_relative_to_config_without_a_recording_directory() {
let root =
diff --git a/crates/tui-test/src/record.rs b/crates/tui-test/src/record.rs
index d9c5345d..a91d18a8 100644
--- a/crates/tui-test/src/record.rs
+++ b/crates/tui-test/src/record.rs
@@ -35,6 +35,9 @@ pub(crate) struct StartRecording {
pub initial_output: String,
#[cfg(feature = "recording-raster")]
pub zoom: f64,
+ /// Frame style; not stored in the asciicast data.
+ #[cfg(feature = "recording-raster")]
+ pub style: crate::render::style::Style,
#[cfg(feature = "recording-raster")]
pub background: Option,
#[cfg(feature = "recording-raster")]
@@ -51,6 +54,8 @@ pub(crate) struct StoppedRecording {
#[cfg(feature = "recording-raster")]
pub zoom: f64,
#[cfg(feature = "recording-raster")]
+ pub style: crate::render::style::Style,
+ #[cfg(feature = "recording-raster")]
pub background: Option,
#[cfg(feature = "recording-raster")]
pub timeline: frames::TimelineOptions,
@@ -402,6 +407,8 @@ mod tests {
#[cfg(feature = "recording-raster")]
zoom: 1.0,
#[cfg(feature = "recording-raster")]
+ style: crate::render::style::Style::default(),
+ #[cfg(feature = "recording-raster")]
background: None,
#[cfg(feature = "recording-raster")]
timeline: frames::TimelineOptions::default(),
@@ -441,6 +448,8 @@ mod tests {
#[cfg(feature = "recording-raster")]
zoom: 1.0,
#[cfg(feature = "recording-raster")]
+ style: crate::render::style::Style::default(),
+ #[cfg(feature = "recording-raster")]
background: None,
#[cfg(feature = "recording-raster")]
timeline: frames::TimelineOptions::default(),
@@ -486,6 +495,8 @@ mod tests {
#[cfg(feature = "recording-raster")]
zoom: 1.0,
#[cfg(feature = "recording-raster")]
+ style: crate::render::style::Style::default(),
+ #[cfg(feature = "recording-raster")]
background: None,
#[cfg(feature = "recording-raster")]
timeline: frames::TimelineOptions::default(),
@@ -656,6 +667,8 @@ mod tests {
env: Vec::new(),
initial_output: String::new(),
#[cfg(feature = "recording-raster")]
+ style: crate::render::style::Style::default(),
+ #[cfg(feature = "recording-raster")]
zoom: 1.0,
#[cfg(feature = "recording-raster")]
background: None,
diff --git a/crates/tui-test/src/record/frames.rs b/crates/tui-test/src/record/frames.rs
index 2f6666bf..d395d823 100644
--- a/crates/tui-test/src/record/frames.rs
+++ b/crates/tui-test/src/record/frames.rs
@@ -481,7 +481,18 @@ mod tests {
let encoded = std::fs::read(&apng_path).unwrap();
assert_eq!(&encoded[..8], b"\x89PNG\r\n\x1a\n");
assert!(encoded.windows(4).any(|window| window == b"acTL"));
- assert_eq!(renderer.pixel_size(), (196, 244));
+ // Derived from the style rather than pinned, so a deliberate change to
+ // a default gap does not read as a broken encoder test.
+ let style = crate::render::style::Style::default();
+ let (panel_width, panel_height) = crate::render::svg::pixel_size(2, 1, &style);
+ assert_eq!(
+ renderer.pixel_size(),
+ // The renderer is built at scale 2.
+ (
+ (panel_width + style.canvas_horizontal().unwrap()) * 2,
+ (panel_height + style.canvas_vertical().unwrap()) * 2
+ )
+ );
std::fs::remove_file(cast_path).unwrap();
std::fs::remove_file(apng_path).unwrap();
diff --git a/crates/tui-test/src/record/worker.rs b/crates/tui-test/src/record/worker.rs
index e99d63ce..40221d88 100644
--- a/crates/tui-test/src/record/worker.rs
+++ b/crates/tui-test/src/record/worker.rs
@@ -144,6 +144,8 @@ pub(super) fn worker_loop(
#[cfg(feature = "recording-raster")]
zoom: request.zoom,
#[cfg(feature = "recording-raster")]
+ style: request.style,
+ #[cfg(feature = "recording-raster")]
background: request.background,
#[cfg(feature = "recording-raster")]
timeline: request.timeline,
diff --git a/crates/tui-test/src/render/encode.rs b/crates/tui-test/src/render/encode.rs
index 9f4461fc..3ef14251 100644
--- a/crates/tui-test/src/render/encode.rs
+++ b/crates/tui-test/src/render/encode.rs
@@ -353,6 +353,7 @@ fn gif_timeline(frames: &[Frame]) -> Vec {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::render::style::Style;
use std::io::BufReader;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -505,16 +506,16 @@ mod tests {
);
let pixel = decode_first_png_pixel(
&path,
- (crate::render::raster::CANVAS_PADDING + 20) * scale,
- (crate::render::raster::CANVAS_PADDING + 48) * scale,
+ (Style::default().canvas_left() + 20) * scale,
+ (Style::default().canvas_top() + 48) * scale,
);
assert_eq!(&pixel[..3], &[200, 10, 20]);
}
RecordingFormat::Gif => {
let decoded = decode_gif(
&path,
- (crate::render::raster::CANVAS_PADDING + 20) * scale,
- (crate::render::raster::CANVAS_PADDING + 48) * scale,
+ (Style::default().canvas_left() + 20) * scale,
+ (Style::default().canvas_top() + 48) * scale,
);
assert_eq!(decoded.frames, 2);
assert_eq!(decoded.dimensions, renderer.pixel_size());
@@ -535,7 +536,8 @@ mod tests {
let path = temp_path("png");
std::fs::write(&path, b"previous output").unwrap();
let frame = frame(Color::Rgb(200, 10, 20), Duration::ZERO);
- let mut renderer = GridRenderer::with_zoom(1, 1, 1.5).unwrap();
+ let style = Style::default();
+ let mut renderer = GridRenderer::with_zoom(1, 1, 1.5, style.clone()).unwrap();
encode_png(&path, &frame, &mut renderer).unwrap();
let bytes = std::fs::read(&path).unwrap();
@@ -544,8 +546,8 @@ mod tests {
assert!(!chunks.iter().any(|(kind, _)| kind == b"acTL"));
let pixel = decode_first_png_pixel(
&path,
- ((crate::render::raster::CANVAS_PADDING + 20) as f64 * 1.5) as u32,
- ((crate::render::raster::CANVAS_PADDING + 48) as f64 * 1.5) as u32,
+ ((style.canvas_left() + 20) as f64 * 1.5) as u32,
+ ((style.canvas_top() + 48) as f64 * 1.5) as u32,
);
assert_eq!(&pixel[..3], &[200, 10, 20]);
diff --git a/crates/tui-test/src/render/font.rs b/crates/tui-test/src/render/font.rs
index 7893511a..b3dcaafd 100644
--- a/crates/tui-test/src/render/font.rs
+++ b/crates/tui-test/src/render/font.rs
@@ -1,6 +1,11 @@
use std::cmp::Ordering;
-use std::collections::HashSet;
-use std::sync::{Arc, OnceLock};
+use std::collections::{HashMap, HashSet};
+use std::fs::File;
+use std::io::Read;
+use std::path::Path;
+use std::sync::{Arc, Mutex, OnceLock};
+
+use super::style::FontFamilies;
pub(crate) const FAMILY: &str = "JetBrains Mono";
@@ -106,41 +111,82 @@ impl Catalog {
}
}
-pub(crate) fn catalog() -> &'static Catalog {
- static CATALOG: OnceLock = OnceLock::new();
- CATALOG.get_or_init(|| {
+/// Scan system fonts once; each style adds its files to a clone.
+fn base_database() -> &'static fontdb::Database {
+ static BASE: OnceLock = OnceLock::new();
+ BASE.get_or_init(|| {
let mut database = fontdb::Database::new();
load_bundled_fonts(&mut database);
database.load_font_data(super::nerd_font::FONT_DATA.to_vec());
database.load_system_fonts();
+ database
+ })
+}
- let preferred = preferred_families();
- let candidates = std::array::from_fn(|index| {
- let bold = index & 1 != 0;
- let italic = index & 2 != 0;
- let mut faces = database.faces().collect::>();
- faces.sort_by(|left, right| {
- face_score(left, &preferred, bold, italic)
- .partial_cmp(&face_score(right, &preferred, bold, italic))
- .unwrap_or(Ordering::Equal)
- });
- faces.into_iter().map(|face| face.id).collect()
+pub(crate) fn catalog_for(font: &FontFamilies) -> Arc {
+ static CATALOGS: OnceLock>>> = OnceLock::new();
+ let mut catalogs = CATALOGS
+ .get_or_init(|| Mutex::new(HashMap::new()))
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if let Some(existing) = catalogs.get(font) {
+ return Arc::clone(existing);
+ }
+ // Hold the lock to avoid duplicate, expensive font ranking.
+ let catalog = Arc::new(build_catalog(font));
+ catalogs.insert(font.clone(), Arc::clone(&catalog));
+ catalog
+}
+
+/// Bound memory use for font paths from untrusted config files.
+const MAX_FONT_FILE_BYTES: u64 = 64 * 1024 * 1024;
+
+/// Unlike fontdb's path loader, read only regular files with a size limit.
+/// Unavailable files use the normal font fallback.
+fn load_named_font(database: &mut fontdb::Database, path: &Path) {
+ if !path.metadata().is_ok_and(|meta| meta.is_file()) {
+ return;
+ }
+ let mut data = Vec::new();
+ let read =
+ File::open(path).and_then(|file| file.take(MAX_FONT_FILE_BYTES).read_to_end(&mut data));
+ if read.is_ok() {
+ database.load_font_data(data);
+ }
+}
+
+fn build_catalog(font: &FontFamilies) -> Catalog {
+ let mut database = base_database().clone();
+ for path in &font.files {
+ load_named_font(&mut database, path);
+ }
+
+ let candidates = std::array::from_fn(|index| {
+ let bold = index & 1 != 0;
+ let italic = index & 2 != 0;
+ let preferred = preferred_families(font, bold, italic);
+ let mut faces = database.faces().collect::>();
+ faces.sort_by(|left, right| {
+ face_score(left, &preferred, bold, italic)
+ .partial_cmp(&face_score(right, &preferred, bold, italic))
+ .unwrap_or(Ordering::Equal)
});
- let nerd_faces = database
- .faces()
- .filter(|face| {
- face.families
- .iter()
- .any(|(family, _)| family.contains("Nerd Font"))
- })
- .map(|face| face.id)
- .collect();
- Catalog {
- database: Arc::new(database),
- candidates,
- nerd_faces,
- }
- })
+ faces.into_iter().map(|face| face.id).collect()
+ });
+ let nerd_faces = database
+ .faces()
+ .filter(|face| {
+ face.families
+ .iter()
+ .any(|(family, _)| family.contains("Nerd Font"))
+ })
+ .map(|face| face.id)
+ .collect();
+ Catalog {
+ database: Arc::new(database),
+ candidates,
+ nerd_faces,
+ }
}
fn load_bundled_fonts(database: &mut fontdb::Database) {
@@ -165,7 +211,58 @@ fn style_index(bold: bool, italic: bool) -> usize {
usize::from(bold) | (usize::from(italic) << 1)
}
-fn preferred_families() -> Vec {
+/// Split a CSS font stack without splitting commas inside quoted names.
+fn split_font_stack(stack: &str) -> Vec {
+ let mut families = Vec::new();
+ let mut current = String::new();
+ let mut quote: Option = None;
+ for character in stack.chars() {
+ match character {
+ '\'' | '"' if quote == Some(character) => quote = None,
+ '\'' | '"' if quote.is_none() => quote = Some(character),
+ ',' if quote.is_none() => families.push(std::mem::take(&mut current)),
+ _ => current.push(character),
+ }
+ }
+ families.push(current);
+ families
+ .into_iter()
+ .map(|family| family.trim().to_string())
+ .filter(|family| !family.is_empty() && !is_css_generic(family))
+ .collect()
+}
+
+fn is_css_generic(family: &str) -> bool {
+ matches!(
+ family.to_ascii_lowercase().as_str(),
+ "serif"
+ | "sans-serif"
+ | "monospace"
+ | "cursive"
+ | "fantasy"
+ | "system-ui"
+ | "ui-serif"
+ | "ui-sans-serif"
+ | "ui-monospace"
+ | "ui-rounded"
+ | "math"
+ | "emoji"
+ | "fangsong"
+ )
+}
+
+fn preferred_families(font: &FontFamilies, bold: bool, italic: bool) -> Vec {
+ // Explicit style fonts take priority over the environment variable.
+ let named = if font == &FontFamilies::default() {
+ Vec::new()
+ } else {
+ let resolved = font.resolve(bold, italic);
+ let mut stacks = vec![resolved];
+ if resolved != font.family {
+ stacks.push(font.family.as_str());
+ }
+ stacks.into_iter().flat_map(split_font_stack).collect()
+ };
let configured = std::env::var("TUI_TEST_RECORDING_FONT_FAMILIES")
.ok()
.into_iter()
@@ -177,7 +274,9 @@ fn preferred_families() -> Vec {
.map(str::to_string)
.collect::>()
});
- configured
+ named
+ .into_iter()
+ .chain(configured)
.chain(
[
FAMILY,
@@ -266,7 +365,7 @@ mod tests {
#[test]
fn catalog_contains_the_bundled_nerd_face() {
- let catalog = catalog();
+ let catalog = catalog_for(&FontFamilies::default());
assert!(!catalog.nerd_faces.is_empty());
}
@@ -359,4 +458,110 @@ mod tests {
.map(|face| face.post_script_name.clone())
.collect()
}
+
+ /// The raster path used to pick faces with no reference to the style, so a
+ /// screenshot and a recording of the same session disagreed about the font.
+ #[test]
+ fn a_configured_family_outranks_the_environment_and_the_defaults() {
+ let font = FontFamilies {
+ family: "Berkeley Mono".into(),
+ bold: Some("Berkeley Mono Bold".into()),
+ ..FontFamilies::default()
+ };
+
+ let plain = preferred_families(&font, false, false);
+ assert_eq!(plain.first().map(String::as_str), Some("Berkeley Mono"));
+
+ // A family is a CSS font stack, so face selection has to read it the
+ // same way the SVG does rather than as one unmatchable string.
+ let stack = preferred_families(
+ &FontFamilies {
+ family: "'Berkeley Mono', Menlo, monospace".into(),
+ ..FontFamilies::default()
+ },
+ false,
+ false,
+ );
+ assert_eq!(
+ &stack[..2],
+ &["Berkeley Mono".to_string(), "Menlo".to_string()],
+ "quotes and spacing are stripped and the generic name dropped"
+ );
+
+ let bold = preferred_families(&font, true, false);
+ assert_eq!(
+ bold.first().map(String::as_str),
+ Some("Berkeley Mono Bold"),
+ "a bold run prefers the family named for it"
+ );
+ assert_eq!(
+ bold.get(1).map(String::as_str),
+ Some("Berkeley Mono"),
+ "and falls back to the base family before anything built in"
+ );
+
+ // A style naming nothing must leave the existing order alone, which is
+ // what keeps TUI_TEST_RECORDING_FONT_FAMILIES authoritative.
+ let default = preferred_families(&FontFamilies::default(), false, false);
+ assert_eq!(default.first().map(String::as_str), Some(FAMILY));
+ }
+
+ #[test]
+ fn catalogs_are_cached_per_style_and_differ_between_them() {
+ let one = catalog_for(&FontFamilies::default());
+ let again = catalog_for(&FontFamilies::default());
+ assert!(
+ Arc::ptr_eq(&one, &again),
+ "the same fonts reuse the catalog rather than rescanning"
+ );
+
+ let other = catalog_for(&FontFamilies {
+ family: "Berkeley Mono".into(),
+ ..FontFamilies::default()
+ });
+ assert!(
+ !Arc::ptr_eq(&one, &other),
+ "and different fonts get their own"
+ );
+ }
+
+ /// A config file is found in the working directory, so checking out an
+ /// untrusted repository must not let it name a path that never ends.
+ #[test]
+ fn a_font_file_that_is_not_a_regular_file_is_not_read() {
+ let mut database = fontdb::Database::new();
+ let before = database.len();
+
+ // Reading either of these to the end never terminates. fontdb would.
+ for path in ["/dev/zero", "/dev/urandom"] {
+ let path = Path::new(path);
+ if path.exists() {
+ load_named_font(&mut database, path);
+ }
+ }
+ load_named_font(&mut database, Path::new("/definitely/not/here.ttf"));
+ load_named_font(&mut database, Path::new("/"));
+
+ assert_eq!(
+ database.len(),
+ before,
+ "a device, a directory and a missing path all load nothing"
+ );
+ }
+
+ #[test]
+ fn a_font_file_that_is_a_real_file_is_read() {
+ let dir = std::env::temp_dir().join(format!("tui-test-font-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("bundled.ttf");
+ // A real face, so this proves the bounded read still produces a font
+ // rather than only proving that nothing loads.
+ std::fs::write(&path, crate::render::nerd_font::FONT_DATA).unwrap();
+
+ let mut database = fontdb::Database::new();
+ load_named_font(&mut database, &path);
+ assert!(!database.is_empty(), "a regular font file still loads");
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
}
diff --git a/crates/tui-test/src/render/mod.rs b/crates/tui-test/src/render/mod.rs
index bd507d39..acdb2b2a 100644
--- a/crates/tui-test/src/render/mod.rs
+++ b/crates/tui-test/src/render/mod.rs
@@ -1,4 +1,5 @@
mod nerd_font;
+pub mod style;
pub mod svg;
#[cfg(feature = "recording-raster")]
diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs
index 63fb9a39..2d35a119 100644
--- a/crates/tui-test/src/render/raster.rs
+++ b/crates/tui-test/src/render/raster.rs
@@ -17,11 +17,14 @@ mod font;
use draw::{
draw_glyph, fill_antialiased_rect, fill_circle, fill_pixel_rect, fill_rounded_rect,
fill_rounded_rect_alpha, fill_top_rounded_rect, format_glyph_sequence, is_default_ignorable,
- unpremultiply, unsupported_grapheme,
+ stroke_rounded_rect, unpremultiply, unsupported_grapheme,
};
use font::{FontSystem, GlyphKey};
-pub(crate) use svg::{CANVAS_BACKGROUND, CANVAS_PADDING};
+use crate::render::style::Style;
+
+/// Limit the RGBA buffer to 400 MB.
+const MAX_PIXELS: u64 = 100_000_000;
#[derive(Debug)]
pub struct RgbaFrame {
@@ -58,67 +61,87 @@ pub struct GridRenderer {
height: u32,
pixmap: Pixmap,
fonts: FontSystem,
+ style: Style,
+ /// Overrides the style's canvas background for one capture.
background: Option,
}
impl GridRenderer {
+ pub fn dimensions(&self) -> (u32, u32) {
+ (self.width, self.height)
+ }
+
pub fn new(cols: u16, rows: usize) -> Self {
Self::with_scale(cols, rows, 1)
}
pub fn with_scale(cols: u16, rows: usize, scale: u32) -> Self {
- Self::with_zoom(cols, rows, f64::from(scale))
+ Self::with_zoom(cols, rows, f64::from(scale), Style::default())
.expect("recording raster scale must fit output dimensions")
}
- pub fn with_zoom(cols: u16, rows: usize, zoom: f64) -> anyhow::Result {
- Self::with_zoom_background_and_size(cols, rows, zoom, None, false)
+ pub fn with_zoom(cols: u16, rows: usize, zoom: f64, style: Style) -> anyhow::Result {
+ Self::with_zoom_background_and_size(cols, rows, zoom, style, None, false)
}
pub fn with_zoom_and_background(
cols: u16,
rows: usize,
zoom: f64,
+ style: Style,
background: Option,
) -> anyhow::Result {
- Self::with_zoom_background_and_size(cols, rows, zoom, background, false)
+ Self::with_zoom_background_and_size(cols, rows, zoom, style, background, false)
}
pub(crate) fn for_screenshot(
cols: u16,
rows: usize,
zoom: f64,
+ style: Style,
background: Option,
) -> anyhow::Result {
- Self::with_zoom_background_and_size(cols, rows, zoom, background, true)
+ Self::with_zoom_background_and_size(cols, rows, zoom, style, background, true)
}
+ #[allow(clippy::too_many_arguments)]
fn with_zoom_background_and_size(
cols: u16,
rows: usize,
zoom: f64,
+ style: Style,
background: Option,
exact_size: bool,
) -> anyhow::Result {
if !zoom.is_finite() || zoom <= 0.0 || zoom > f64::from(f32::MAX) {
anyhow::bail!("recording zoom must be finite and greater than zero");
}
+ style.validate().map_err(|error| anyhow::anyhow!(error))?;
let (base_width, base_height) = if exact_size {
- svg::exact_pixel_size(cols, rows)
+ svg::exact_pixel_size(cols, rows, &style)
} else {
- svg::pixel_size(cols, rows)
+ svg::pixel_size(cols, rows, &style)
};
- let padding = CANVAS_PADDING
- .checked_mul(2)
- .expect("recording canvas padding must fit in u32");
+ let horizontal = style
+ .canvas_horizontal()
+ .ok_or_else(|| anyhow::anyhow!("recording canvas padding must fit in u32"))?;
+ let vertical = style
+ .canvas_vertical()
+ .ok_or_else(|| anyhow::anyhow!("recording canvas padding must fit in u32"))?;
let width = base_width
- .checked_add(padding)
+ .checked_add(horizontal)
.ok_or_else(|| anyhow::anyhow!("recording width must fit in u32"))?;
let height = base_height
- .checked_add(padding)
+ .checked_add(vertical)
.ok_or_else(|| anyhow::anyhow!("recording height must fit in u32"))?;
let width = scaled_dimension(width, zoom, "width")?;
let height = scaled_dimension(height, zoom, "height")?;
+ let pixels = u64::from(width) * u64::from(height);
+ if pixels > MAX_PIXELS {
+ anyhow::bail!(
+ "recording is {width}x{height}, which is {pixels} pixels; the limit is {MAX_PIXELS}"
+ );
+ }
Ok(Self {
max_cols: cols,
max_rows: rows,
@@ -129,7 +152,8 @@ impl GridRenderer {
pixmap: Pixmap::new(width, height).ok_or_else(|| {
anyhow::anyhow!("terminal recording dimensions must fit a pixmap")
})?,
- fonts: FontSystem::new(),
+ fonts: FontSystem::new(&style.font),
+ style,
background,
})
}
@@ -152,15 +176,23 @@ impl FrameRenderer for GridRenderer {
let scale = self.scale;
let colors = &frame.render_state;
+ let style = &self.style;
let (base_width, base_height) = if self.exact_size {
- svg::exact_pixel_size(cols, rows)
+ svg::exact_pixel_size(cols, rows, style)
} else {
- svg::pixel_size(cols, rows)
+ svg::pixel_size(cols, rows, style)
};
let panel_width = scaled_dimension(base_width, f64::from(self.scale), "frame width")?;
let panel_height = scaled_dimension(base_height, f64::from(self.scale), "frame height")?;
- let origin_x = (self.width - panel_width) as f32 / 2.0;
- let origin_y = (self.height - panel_height) as f32 / 2.0;
+ // Center smaller frames inside the configured padding after a resize.
+ let pad_left = style.canvas_left() as f32 * scale;
+ let pad_top = style.canvas_top() as f32 * scale;
+ let pad_right = style.canvas_right() as f32 * scale;
+ let pad_bottom = style.canvas_bottom() as f32 * scale;
+ let content_width = (self.width as f32 - pad_left - pad_right).max(0.0);
+ let content_height = (self.height as f32 - pad_top - pad_bottom).max(0.0);
+ let origin_x = pad_left + (content_width - panel_width as f32).max(0.0) / 2.0;
+ let origin_y = pad_top + (content_height - panel_height as f32).max(0.0) / 2.0;
match self.background {
Some(CaptureBackground::Transparent) => {
self.pixmap.fill(tiny_skia::Color::TRANSPARENT);
@@ -171,9 +203,9 @@ impl FrameRenderer for GridRenderer {
}
None => {
self.pixmap.fill(tiny_skia::Color::from_rgba8(
- CANVAS_BACKGROUND.r,
- CANVAS_BACKGROUND.g,
- CANVAS_BACKGROUND.b,
+ style.canvas_background.r,
+ style.canvas_background.g,
+ style.canvas_background.b,
255,
));
}
@@ -185,6 +217,7 @@ impl FrameRenderer for GridRenderer {
panel_width as f32,
panel_height as f32,
scale,
+ style,
);
fill_rounded_rect(
&mut self.pixmap,
@@ -192,52 +225,57 @@ impl FrameRenderer for GridRenderer {
origin_y,
panel_width as f32,
panel_height as f32,
- svg::WINDOW_RADIUS * scale,
+ style.border.radius * scale,
colors.resolve(None, false),
);
- fill_top_rounded_rect(
- &mut self.pixmap,
- origin_x,
- origin_y,
- panel_width as f32,
- (svg::HEADER_H - svg::TITLE_DIVIDER_H) * scale,
- svg::WINDOW_RADIUS * scale,
- svg::TITLE_BG,
- );
- fill_antialiased_rect(
- &mut self.pixmap,
- origin_x,
- origin_y + (svg::HEADER_H - svg::TITLE_DIVIDER_H) * scale,
- panel_width as f32,
- svg::TITLE_DIVIDER_H * scale,
- svg::TITLE_DIVIDER,
- );
- for (index, color) in svg::TRAFFIC_LIGHTS.iter().copied().enumerate() {
- let cx = origin_x + (svg::MARGIN_X + 5.0 + index as f32 * 20.0) * scale;
- let cy = origin_y + svg::HEADER_H / 2.0 * scale;
- fill_circle(&mut self.pixmap, cx, cy, svg::DOT_R * scale, color);
- }
- fill_circle(
- &mut self.pixmap,
- origin_x + (svg::MARGIN_X + 5.0) * scale,
- origin_y + svg::HEADER_H / 2.0 * scale,
- svg::RED_DOT_R * scale,
- svg::RED_DOT_COLOR,
- );
-
let mut missing = BTreeSet::new();
- draw_title(
- &mut self.pixmap,
- &mut self.fonts,
- frame.title.as_deref(),
- cols,
- rows,
- base_width as f32,
- origin_x,
- origin_y,
- scale,
- &mut missing,
- );
+ if style.window.title_bar {
+ fill_top_rounded_rect(
+ &mut self.pixmap,
+ origin_x,
+ origin_y,
+ panel_width as f32,
+ (style.header_height() - style.divider_height()) * scale,
+ style.border.radius * scale,
+ style.window.background,
+ );
+ fill_antialiased_rect(
+ &mut self.pixmap,
+ origin_x,
+ origin_y + (style.header_height() - style.divider_height()) * scale,
+ panel_width as f32,
+ style.divider_height() * scale,
+ style.window.divider,
+ );
+ let lights = style.window.traffic_lights();
+ for (index, color) in lights.iter().copied().enumerate() {
+ let cx = origin_x + (style.content_left() + 5.0 + index as f32 * 20.0) * scale;
+ let cy = origin_y + style.header_height() / 2.0 * scale;
+ fill_circle(&mut self.pixmap, cx, cy, svg::DOT_R * scale, color);
+ }
+ if !lights.is_empty() {
+ fill_circle(
+ &mut self.pixmap,
+ origin_x + (style.content_left() + 5.0) * scale,
+ origin_y + style.header_height() / 2.0 * scale,
+ svg::RED_DOT_R * scale,
+ svg::RED_DOT_COLOR,
+ );
+ }
+ draw_title(
+ &mut self.pixmap,
+ &mut self.fonts,
+ frame.title.as_deref(),
+ cols,
+ rows,
+ base_width as f32,
+ origin_x,
+ origin_y,
+ scale,
+ &mut missing,
+ style,
+ );
+ }
let blank = EmuCell::blank();
for (y, row) in grid.iter().enumerate() {
@@ -252,10 +290,10 @@ impl FrameRenderer for GridRenderer {
run += 1;
}
if background != colors.resolve(None, false) {
- let left = grid_x(origin_x, x, scale);
- let right = grid_x(origin_x, x + run, scale);
- let top = grid_y(origin_y, y, scale);
- let bottom = grid_y(origin_y, y + 1, scale);
+ let left = grid_x(origin_x, x, scale, style);
+ let right = grid_x(origin_x, x + run, scale, style);
+ let top = grid_y(origin_y, y, scale, style);
+ let bottom = grid_y(origin_y, y + 1, scale, style);
fill_pixel_rect(&mut self.pixmap, left, top, right, bottom, background);
}
x += run;
@@ -269,8 +307,8 @@ impl FrameRenderer for GridRenderer {
if cell.ch.as_str() == CONTINUATION {
continue;
}
- let style = svg::style_of(cell, colors);
- if style.invisible {
+ let paint = svg::cell_paint(cell, colors);
+ if paint.invisible {
continue;
}
let span = if row
@@ -281,16 +319,20 @@ impl FrameRenderer for GridRenderer {
} else {
1
};
- let cell_origin_x = origin_x + (svg::MARGIN_X + x as f32 * svg::CELL_W) * scale;
+ let cell_origin_x =
+ origin_x + (style.content_left() + x as f32 * style.cell_width()) * scale;
let cell_origin_y = origin_y
- + (svg::HEADER_H + svg::CONTENT_PADDING_TOP + y as f32 * svg::CELL_H) * scale;
- let cell_width = svg::CELL_W * span as f32 * scale;
- let cell_height = svg::CELL_H * scale;
+ + (style.header_height()
+ + style.content_top()
+ + y as f32 * style.cell_height())
+ * scale;
+ let cell_width = style.cell_width() * span as f32 * scale;
+ let cell_height = style.cell_height() * scale;
let baseline = origin_y
- + (svg::HEADER_H
- + svg::CONTENT_PADDING_TOP
- + y as f32 * svg::CELL_H
- + svg::FONT_BASELINE)
+ + (style.header_height()
+ + style.content_top()
+ + y as f32 * style.cell_height()
+ + style.baseline())
* scale;
if unsupported_grapheme(cell.ch.as_str()) {
@@ -303,8 +345,8 @@ impl FrameRenderer for GridRenderer {
}
let key = GlyphKey {
character,
- bold: style.bold,
- italic: style.italic,
+ bold: paint.bold,
+ italic: paint.italic,
};
match fonts.resolve(key) {
Some(glyph) => draw_glyph(
@@ -315,8 +357,8 @@ impl FrameRenderer for GridRenderer {
cell_width,
cell_height,
baseline,
- style.fg,
- svg::FONT_SIZE,
+ paint.fg,
+ style.font_size,
scale,
),
None => {
@@ -325,24 +367,24 @@ impl FrameRenderer for GridRenderer {
}
}
- if style.underline {
+ if paint.underline {
fill_antialiased_rect(
pixmap,
cell_origin_x,
cell_origin_y + cell_height - 3.0 * scale,
cell_width,
scale.max(1.0),
- style.fg,
+ paint.fg,
);
}
- if style.strike {
+ if paint.strike {
fill_antialiased_rect(
pixmap,
cell_origin_x,
- baseline - svg::FONT_SIZE * 0.32 * scale,
+ baseline - style.font_size * 0.32 * scale,
cell_width,
scale.max(1.0),
- style.fg,
+ paint.fg,
);
}
}
@@ -359,6 +401,21 @@ impl FrameRenderer for GridRenderer {
origin_y,
scale,
&mut missing,
+ style,
+ );
+ }
+
+ // Last, so the content it frames cannot paint over it.
+ if style.border.width > 0.0 {
+ stroke_rounded_rect(
+ &mut self.pixmap,
+ origin_x,
+ origin_y,
+ panel_width as f32,
+ panel_height as f32,
+ style.border.radius * scale,
+ style.border.color,
+ style.border.width * scale,
);
}
@@ -386,12 +443,13 @@ impl FrameRenderer for GridRenderer {
}
}
-fn grid_x(origin_x: f32, column: usize, scale: f32) -> u32 {
- (origin_x + (svg::MARGIN_X + column as f32 * svg::CELL_W) * scale).round() as u32
+fn grid_x(origin_x: f32, column: usize, scale: f32, style: &Style) -> u32 {
+ (origin_x + (style.content_left() + column as f32 * style.cell_width()) * scale).round() as u32
}
-fn grid_y(origin_y: f32, row: usize, scale: f32) -> u32 {
- (origin_y + (svg::HEADER_H + svg::CONTENT_PADDING_TOP + row as f32 * svg::CELL_H) * scale)
+fn grid_y(origin_y: f32, row: usize, scale: f32, style: &Style) -> u32 {
+ (origin_y
+ + (style.header_height() + style.content_top() + row as f32 * style.cell_height()) * scale)
.round() as u32
}
@@ -406,6 +464,7 @@ fn draw_cursor(
panel_origin_y: f32,
scale: f32,
missing: &mut BTreeSet,
+ style: &Style,
) {
let Some(row) = grid.get(cy) else {
return;
@@ -422,15 +481,16 @@ fn draw_cursor(
1
};
let column = usize::from(cx);
- let origin_x = panel_origin_x + (svg::MARGIN_X + f32::from(cx) * svg::CELL_W) * scale;
+ let origin_x =
+ panel_origin_x + (style.content_left() + f32::from(cx) * style.cell_width()) * scale;
let origin_y = panel_origin_y
- + (svg::HEADER_H + svg::CONTENT_PADDING_TOP + cy as f32 * svg::CELL_H) * scale;
- let cell_width = svg::CELL_W * span as f32 * scale;
- let cell_height = svg::CELL_H * scale;
- let left = grid_x(panel_origin_x, column, scale);
- let right = grid_x(panel_origin_x, column + span, scale);
- let top = grid_y(panel_origin_y, cy, scale);
- let bottom = grid_y(panel_origin_y, cy + 1, scale);
+ + (style.header_height() + style.content_top() + cy as f32 * style.cell_height()) * scale;
+ let cell_width = style.cell_width() * span as f32 * scale;
+ let cell_height = style.cell_height() * scale;
+ let left = grid_x(panel_origin_x, column, scale, style);
+ let right = grid_x(panel_origin_x, column + span, scale, style);
+ let top = grid_y(panel_origin_y, cy, scale, style);
+ let bottom = grid_y(panel_origin_y, cy + 1, scale, style);
let thickness = (2.0 * scale).round().max(1.0) as u32;
let color = colors.color(ColorSlot::Cursor);
match colors.cursor_shape() {
@@ -464,8 +524,8 @@ fn draw_cursor(
if cell.ch.as_str() == CONTINUATION || cell.ch.chars().all(char::is_whitespace) {
return;
}
- let style = svg::style_of(cell, colors);
- if style.invisible {
+ let paint = svg::cell_paint(cell, colors);
+ if paint.invisible {
return;
}
if unsupported_grapheme(cell.ch.as_str()) {
@@ -473,7 +533,10 @@ fn draw_cursor(
return;
}
let baseline = panel_origin_y
- + (svg::HEADER_H + svg::CONTENT_PADDING_TOP + cy as f32 * svg::CELL_H + svg::FONT_BASELINE)
+ + (style.header_height()
+ + style.content_top()
+ + cy as f32 * style.cell_height()
+ + style.baseline())
* scale;
for character in cell.ch.chars() {
if is_default_ignorable(character) {
@@ -481,8 +544,8 @@ fn draw_cursor(
}
let key = GlyphKey {
character,
- bold: style.bold,
- italic: style.italic,
+ bold: paint.bold,
+ italic: paint.italic,
};
match fonts.resolve(key) {
Some(glyph) => draw_glyph(
@@ -494,7 +557,7 @@ fn draw_cursor(
cell_height,
baseline,
svg::bg_of(cell, colors),
- svg::FONT_SIZE,
+ style.font_size,
scale,
),
None => {
@@ -504,8 +567,16 @@ fn draw_cursor(
}
}
-fn draw_shadow(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, scale: f32) {
- for (spread, offset_y, alpha) in svg::SHADOW_LAYERS {
+fn draw_shadow(
+ pixmap: &mut Pixmap,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ scale: f32,
+ style: &Style,
+) {
+ for (spread, offset_y, alpha) in style.shadow_layers() {
let spread = spread * scale;
fill_rounded_rect_alpha(
pixmap,
@@ -513,8 +584,8 @@ fn draw_shadow(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, sca
y - spread + offset_y * scale,
width + spread * 2.0,
height + spread * 2.0,
- svg::WINDOW_RADIUS * scale + spread,
- svg::SHADOW_COLOR,
+ style.border.radius * scale + spread,
+ style.shadow.color,
alpha,
);
}
@@ -532,14 +603,15 @@ fn draw_title(
origin_y: f32,
scale: f32,
missing: &mut BTreeSet,
+ style: &Style,
) {
- let Some(title) = svg::visible_title(title, cols, rows, panel_width) else {
+ let Some(title) = svg::visible_title(title, cols, rows, panel_width, style) else {
return;
};
- let advance = svg::title_advance();
+ let advance = svg::title_advance(style);
let title_width = crate::terminal::cell::display_width(&title) as f32 * advance * scale;
let mut x = origin_x + (panel_width * scale - title_width) / 2.0;
- let baseline = origin_y + (svg::HEADER_H / 2.0 + svg::TITLE_FONT_SIZE * 0.35) * scale;
+ let baseline = origin_y + (style.header_height() / 2.0 + style.title_font_size * 0.35) * scale;
for character in title.chars() {
let columns = crate::terminal::cell::display_width(&character.to_string()).max(1);
@@ -557,10 +629,10 @@ fn draw_title(
x,
origin_y,
width,
- svg::HEADER_H * scale,
+ style.header_height() * scale,
baseline,
- svg::TITLE_FG,
- svg::TITLE_FONT_SIZE,
+ style.window.foreground,
+ style.title_font_size,
scale,
),
None => {
diff --git a/crates/tui-test/src/render/raster/draw.rs b/crates/tui-test/src/render/raster/draw.rs
index c4c2bceb..f9de29da 100644
--- a/crates/tui-test/src/render/raster/draw.rs
+++ b/crates/tui-test/src/render/raster/draw.rs
@@ -1,4 +1,4 @@
-use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Transform};
+use tiny_skia::{FillRule, Paint, Path, PathBuilder, Pixmap, Rect, Stroke, Transform};
use super::font::GlyphOutline;
use crate::profile::Rgb;
@@ -115,16 +115,8 @@ pub(super) fn fill_circle(pixmap: &mut Pixmap, x: f32, y: f32, radius: f32, colo
);
}
-#[allow(clippy::too_many_arguments)]
-pub(super) fn fill_rounded_rect(
- pixmap: &mut Pixmap,
- x: f32,
- y: f32,
- width: f32,
- height: f32,
- radius: f32,
- color: Rgb,
-) {
+/// The one rounded rectangle every panel, shadow layer and border is cut from.
+fn rounded_rect_path(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option {
let right = x + width;
let bottom = y + height;
let radius = radius.min(width / 2.0).min(height / 2.0);
@@ -139,7 +131,53 @@ pub(super) fn fill_rounded_rect(
path.line_to(x, y + radius);
path.quad_to(x, y, x + radius, y);
path.close();
- let Some(path) = path.finish() else {
+ path.finish()
+}
+
+/// Draw the panel's border. Inset by half the stroke because tiny-skia, like
+/// SVG, centers a stroke on its path, and a border straddling the panel edge
+/// would bleed into the padding.
+#[allow(clippy::too_many_arguments)]
+pub(super) fn stroke_rounded_rect(
+ pixmap: &mut Pixmap,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ radius: f32,
+ color: Rgb,
+ stroke_width: f32,
+) {
+ let inset = stroke_width / 2.0;
+ let Some(path) = rounded_rect_path(
+ x + inset,
+ y + inset,
+ (width - stroke_width).max(0.0),
+ (height - stroke_width).max(0.0),
+ (radius - inset).max(0.0),
+ ) else {
+ return;
+ };
+ let mut paint = Paint::default();
+ paint.set_color_rgba8(color.r, color.g, color.b, 255);
+ let stroke = Stroke {
+ width: stroke_width,
+ ..Stroke::default()
+ };
+ pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(super) fn fill_rounded_rect(
+ pixmap: &mut Pixmap,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ radius: f32,
+ color: Rgb,
+) {
+ let Some(path) = rounded_rect_path(x, y, width, height, radius) else {
return;
};
let mut paint = Paint::default();
@@ -164,21 +202,7 @@ pub(super) fn fill_rounded_rect_alpha(
color: Rgb,
alpha: u8,
) {
- let right = x + width;
- let bottom = y + height;
- let radius = radius.min(width / 2.0).min(height / 2.0);
- let mut path = PathBuilder::new();
- path.move_to(x + radius, y);
- path.line_to(right - radius, y);
- path.quad_to(right, y, right, y + radius);
- path.line_to(right, bottom - radius);
- path.quad_to(right, bottom, right - radius, bottom);
- path.line_to(x + radius, bottom);
- path.quad_to(x, bottom, x, bottom - radius);
- path.line_to(x, y + radius);
- path.quad_to(x, y, x + radius, y);
- path.close();
- let Some(path) = path.finish() else {
+ let Some(path) = rounded_rect_path(x, y, width, height, radius) else {
return;
};
let mut paint = Paint::default();
diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs
index a9b94203..3e385266 100644
--- a/crates/tui-test/src/render/raster/font.rs
+++ b/crates/tui-test/src/render/raster/font.rs
@@ -23,14 +23,16 @@ pub(super) struct GlyphOutline {
}
pub(super) struct FontSystem {
- catalog: &'static catalog::Catalog,
+ catalog: std::sync::Arc,
glyphs: HashMap>,
}
impl FontSystem {
- pub fn new() -> Self {
+ /// Built from the style's fonts, so a recording draws with the family the
+ /// config named rather than whatever the catalog ranked first.
+ pub fn new(font: &crate::render::style::FontFamilies) -> Self {
Self {
- catalog: catalog::catalog(),
+ catalog: catalog::catalog_for(font),
glyphs: HashMap::new(),
}
}
diff --git a/crates/tui-test/src/render/raster/tests.rs b/crates/tui-test/src/render/raster/tests.rs
index 512c8238..ce3f4f71 100644
--- a/crates/tui-test/src/render/raster/tests.rs
+++ b/crates/tui-test/src/render/raster/tests.rs
@@ -1,9 +1,10 @@
#[cfg(feature = "recording-font-jetbrains-mono-styles")]
use super::font::{FontSystem, GlyphKey};
-use super::{FrameRenderer, GridRenderer, RgbaFrame, CANVAS_BACKGROUND, CANVAS_PADDING};
+use super::{FrameRenderer, GridRenderer, RgbaFrame};
use crate::api::CaptureBackground;
use crate::profile::Profile;
use crate::record::frames::Frame;
+use crate::render::style::{Padding, Style};
use crate::render::svg::{RenderColors, RenderState};
use crate::terminal::alacritty::AlacrittyEmu;
use crate::terminal::cell::{Attrs, Color, EmuCell, CONTINUATION};
@@ -60,11 +61,14 @@ fn scaled_renderers_multiply_output_dimensions() {
#[test]
fn static_screenshots_keep_odd_svg_dimensions_at_zoom() {
+ // Sizes follow the default style; update them deliberately if a default
+ // gap moves. The point is the difference: a recording is rounded up to
+ // even dimensions for the video encoders, a screenshot is not.
for (zoom, recording_size, screenshot_size) in
- [(1.0, (88, 122), (88, 121)), (2.0, (176, 244), (176, 242))]
+ [(1.0, (88, 126), (88, 125)), (2.0, (176, 252), (176, 250))]
{
- let recording = GridRenderer::with_zoom(1, 1, zoom).unwrap();
- let screenshot = GridRenderer::for_screenshot(1, 1, zoom, None).unwrap();
+ let recording = GridRenderer::with_zoom(1, 1, zoom, Style::default()).unwrap();
+ let screenshot = GridRenderer::for_screenshot(1, 1, zoom, Style::default(), None).unwrap();
assert_eq!(recording.pixel_size(), recording_size);
assert_eq!(screenshot.pixel_size(), screenshot_size);
@@ -74,7 +78,7 @@ fn static_screenshots_keep_odd_svg_dimensions_at_zoom() {
#[test]
fn fractional_zoom_shrinks_output_without_changing_grid_dimensions() {
let standard = GridRenderer::new(80, 30);
- let half = GridRenderer::with_zoom(80, 30, 0.5).unwrap();
+ let half = GridRenderer::with_zoom(80, 30, 0.5, Style::default()).unwrap();
assert_eq!(
half.pixel_size(),
(
@@ -95,15 +99,21 @@ fn canvas_background_can_be_custom_or_transparent() {
1,
1,
1.0,
+ Style::default(),
Some(CaptureBackground::Color(crate::profile::Rgb::new(1, 2, 3))),
)
.unwrap();
let custom = custom.render(&frame).unwrap();
assert_eq!(&custom.as_raw()[..4], &[1, 2, 3, 255]);
- let mut transparent =
- GridRenderer::with_zoom_and_background(1, 1, 1.0, Some(CaptureBackground::Transparent))
- .unwrap();
+ let mut transparent = GridRenderer::with_zoom_and_background(
+ 1,
+ 1,
+ 1.0,
+ Style::default(),
+ Some(CaptureBackground::Transparent),
+ )
+ .unwrap();
let transparent = transparent.render(&frame).unwrap();
assert_eq!(&transparent.as_raw()[..4], &[0, 0, 0, 0]);
}
@@ -138,10 +148,12 @@ fn adjacent_background_cells_are_seamless_at_fractional_zoom() {
];
for zoom in [1.02, 1.25] {
- let mut renderer = GridRenderer::with_zoom(backgrounds.len() as u16, rows, zoom).unwrap();
+ let mut renderer =
+ GridRenderer::with_zoom(backgrounds.len() as u16, rows, zoom, Style::default())
+ .unwrap();
let image = renderer.render(&frame(grid.clone())).unwrap();
let (panel_width, panel_height) =
- crate::render::svg::pixel_size(backgrounds.len() as u16, rows);
+ crate::render::svg::pixel_size(backgrounds.len() as u16, rows, &Style::default());
let panel_width = super::scaled_dimension(panel_width, zoom, "test width").unwrap();
let panel_height = super::scaled_dimension(panel_height, zoom, "test height").unwrap();
let origin_x = (image.dimensions().0 - panel_width) as f32 / 2.0;
@@ -186,9 +198,9 @@ fn block_cursor_is_aligned_with_background_cells_at_fractional_zoom() {
let cursor = content
.render_state
.color(crate::profile::ColorSlot::Cursor);
- let mut renderer = GridRenderer::with_zoom(4, 1, zoom).unwrap();
+ let mut renderer = GridRenderer::with_zoom(4, 1, zoom, Style::default()).unwrap();
let image = renderer.render(&content).unwrap();
- let (panel_width, panel_height) = crate::render::svg::pixel_size(4, 1);
+ let (panel_width, panel_height) = crate::render::svg::pixel_size(4, 1, &Style::default());
let panel_width = super::scaled_dimension(panel_width, zoom, "test width").unwrap();
let panel_height = super::scaled_dimension(panel_height, zoom, "test height").unwrap();
let origin_x = (image.dimensions().0 - panel_width) as f32 / 2.0;
@@ -222,7 +234,7 @@ fn block_cursor_is_aligned_with_background_cells_at_fractional_zoom() {
#[test]
fn invalid_zoom_is_rejected() {
for zoom in [0.0, -1.0, f64::INFINITY, f64::NAN] {
- assert!(GridRenderer::with_zoom(1, 1, zoom).is_err());
+ assert!(GridRenderer::with_zoom(1, 1, zoom, Style::default()).is_err());
}
}
@@ -235,17 +247,20 @@ fn smaller_terminal_is_centered_on_the_recording_canvas() {
let expected_background = content.render_state.resolve(None, false);
let image = renderer.render(&content).unwrap();
let (width, height) = image.dimensions();
- let (panel_width, panel_height) = crate::render::svg::pixel_size(2, 1);
+ let (panel_width, panel_height) = crate::render::svg::pixel_size(2, 1, &Style::default());
let origin_x = (width - panel_width) / 2;
let origin_y = (height - panel_height) / 2;
- assert_eq!(CANVAS_BACKGROUND, crate::profile::Rgb::new(104, 103, 170));
+ assert_eq!(
+ Style::default().canvas_background,
+ crate::profile::Rgb::new(104, 103, 170)
+ );
assert_eq!(
pixel_at(&image, 0, 0),
[
- CANVAS_BACKGROUND.r,
- CANVAS_BACKGROUND.g,
- CANVAS_BACKGROUND.b,
+ Style::default().canvas_background.r,
+ Style::default().canvas_background.g,
+ Style::default().canvas_background.b,
255
]
);
@@ -257,15 +272,15 @@ fn smaller_terminal_is_centered_on_the_recording_canvas() {
pixel_at(
&image,
origin_x + panel_width / 2,
- origin_y + crate::render::svg::HEADER_H as u32 - 1
+ origin_y + Style::default().header_height() as u32 - 1
),
[0, 0, 0, 255]
);
assert_eq!(
pixel_at(
&image,
- origin_x + (crate::render::svg::MARGIN_X + 5.0) as u32,
- origin_y + (crate::render::svg::HEADER_H / 2.0) as u32
+ origin_x + (Style::default().content_left() + 5.0) as u32,
+ origin_y + (Style::default().header_height() / 2.0) as u32
),
[105, 17, 10, 255]
);
@@ -273,7 +288,7 @@ fn smaller_terminal_is_centered_on_the_recording_canvas() {
pixel_at(
&image,
origin_x + panel_width / 2,
- origin_y + crate::render::svg::HEADER_H as u32 + 1
+ origin_y + Style::default().header_height() as u32 + 1
),
[
expected_background.r,
@@ -287,7 +302,7 @@ fn smaller_terminal_is_centered_on_the_recording_canvas() {
&image,
origin_x + panel_width / 2,
origin_y
- + (crate::render::svg::HEADER_H + crate::render::svg::CONTENT_PADDING_TOP) as u32
+ + (Style::default().header_height() + Style::default().content_top()) as u32
+ 1
),
[1, 2, 3, 255]
@@ -326,7 +341,7 @@ fn bold_and_italic_change_the_rasterized_glyph() {
#[cfg(feature = "recording-font-jetbrains-mono-styles")]
#[test]
fn bundled_styles_do_not_need_synthetic_bold_or_italic() {
- let mut fonts = FontSystem::new();
+ let mut fonts = FontSystem::new(&Style::default().font);
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let glyph = fonts
.resolve(GlyphKey {
@@ -406,10 +421,10 @@ fn frame_palette_and_cursor_state_change_the_pixels() {
assert_ne!(first_pixels, second_pixels);
let width = renderer.pixel_size().0 as usize;
- let x = (CANVAS_PADDING + super::super::svg::MARGIN_X as u32) as usize;
- let y = (CANVAS_PADDING
- + super::super::svg::HEADER_H as u32
- + super::super::svg::CONTENT_PADDING_TOP as u32) as usize;
+ let x = (Style::default().canvas_left() + Style::default().content_left() as u32) as usize;
+ let y = (Style::default().canvas_top()
+ + Style::default().header_height() as u32
+ + Style::default().content_top() as u32) as usize;
let cursor = (y * width + x) * 4;
assert_eq!(&first_pixels[cursor..cursor + 3], &[255, 0, 255]);
}
@@ -453,15 +468,259 @@ fn color_to_pixel(color: Color) -> [u8; 4] {
}
fn grid_x(origin_x: f32, column: usize, scale: f32) -> u32 {
- (origin_x + (super::super::svg::MARGIN_X + column as f32 * super::super::svg::CELL_W) * scale)
+ (origin_x
+ + (Style::default().content_left() + column as f32 * Style::default().cell_width()) * scale)
.round() as u32
}
fn grid_y(origin_y: f32, row: usize, scale: f32) -> u32 {
(origin_y
- + (super::super::svg::HEADER_H
- + super::super::svg::CONTENT_PADDING_TOP
- + row as f32 * super::super::svg::CELL_H)
+ + (Style::default().header_height()
+ + Style::default().content_top()
+ + row as f32 * Style::default().cell_height())
* scale)
.round() as u32
}
+
+/// Each axis can pass its own bound while the area is enormous. A grid this
+/// size at the largest allowed font and padding is under u32::MAX on both
+/// axes and still tens of gigabytes of pixmap, which the process would
+/// otherwise only discover by touching the pages.
+#[test]
+fn an_enormous_canvas_is_refused_before_it_is_allocated() {
+ let huge = Style {
+ font_size: 999.0,
+ canvas_padding: Padding::Uniform(10_000),
+ ..Style::default()
+ };
+ let Err(error) = GridRenderer::with_zoom(500, 200, 1.0, huge) else {
+ panic!("a canvas that large must not be allocated");
+ };
+ assert!(
+ error.to_string().contains("pixels"),
+ "the error says how big it was: {error}"
+ );
+
+ GridRenderer::with_zoom(
+ 120,
+ 40,
+ 2.0,
+ Style {
+ font_size: 40.0,
+ ..Style::default()
+ },
+ )
+ .map(|_| ())
+ .expect("a genuinely large recording still renders");
+}
+
+/// A screenshot and a recording of the same terminal under the same config
+/// have to describe the same canvas. They are not byte-identical in size:
+/// `pixel_size` rounds each axis up to an even number because video encoders
+/// demand it, and an SVG has no such constraint. That rounding is the only
+/// licensed difference, so this pins it — the two renderers compute their
+/// geometry separately, and nothing else would catch them drifting apart.
+#[test]
+fn both_renderers_agree_on_size_for_the_same_style() {
+ use crate::render::style::WindowStyle;
+
+ let cases = [
+ ("default", Style::default()),
+ (
+ "no chrome",
+ Style {
+ window: WindowStyle {
+ title_bar: false,
+ ..WindowStyle::default()
+ },
+ ..Style::default()
+ },
+ ),
+ (
+ "padded",
+ Style {
+ canvas_padding: Padding::Uniform(40),
+ ..Style::default()
+ },
+ ),
+ (
+ "large font",
+ Style {
+ font_size: 30.0,
+ ..Style::default()
+ },
+ ),
+ ];
+
+ for (name, style) in cases {
+ let rows = vec![vec![EmuCell::blank(); 8]; 3];
+ let svg = crate::render::svg::render_svg(
+ &rows,
+ 8,
+ &Profile::default(),
+ None,
+ Some("t"),
+ &style,
+ 1.0,
+ None,
+ );
+ // The root dimensions can be fractional; the raster canvas is whole
+ // pixels, so it takes the ceiling before rounding up to even.
+ let attr = |key: &str| -> f64 {
+ let at = svg.find(&format!("{key}=\"")).expect("dimension attribute");
+ let rest = &svg[at + key.len() + 2..];
+ rest[..rest.find('"').unwrap()].parse().expect("a number")
+ };
+
+ let renderer =
+ GridRenderer::with_zoom(8, 3, 1.0, style).expect("the raster canvas is buildable");
+ let even = |value: f64| {
+ let whole = value.ceil() as u32;
+ whole + whole % 2
+ };
+ assert_eq!(
+ (even(attr("width")), even(attr("height"))),
+ renderer.dimensions(),
+ "{name}: the recording is the screenshot's canvas rounded up to even"
+ );
+ }
+}
+
+/// Every other raster test builds its renderer with `Style::default()`, so a
+/// `GridRenderer` that ignored its style entirely would keep them all green.
+#[test]
+fn the_raster_canvas_is_drawn_from_its_style() {
+ let style = Style {
+ font_size: 34.0,
+ canvas_padding: Padding::Uniform(40),
+ canvas_background: crate::profile::Rgb::new(1, 2, 3),
+ ..Style::default()
+ };
+ let mut renderer = GridRenderer::with_zoom(4, 2, 1.0, style.clone()).unwrap();
+ let plain = GridRenderer::new(4, 2);
+
+ let (panel_width, panel_height) = crate::render::svg::pixel_size(4, 2, &style);
+ assert_eq!(
+ renderer.dimensions(),
+ (
+ panel_width + style.canvas_horizontal().unwrap(),
+ panel_height + style.canvas_vertical().unwrap()
+ ),
+ "the canvas is the styled panel plus the styled padding on every side"
+ );
+ assert_ne!(
+ renderer.dimensions(),
+ plain.dimensions(),
+ "a larger font and padding grow the canvas"
+ );
+
+ let image = renderer
+ .render(&frame(vec![vec![EmuCell::blank(); 4]; 2]))
+ .unwrap();
+ assert_eq!(
+ pixel_at(&image, 1, 1),
+ color_to_pixel(Color::Rgb(1, 2, 3)),
+ "the configured background is painted into the padding"
+ );
+}
+
+/// The border has to reach real pixels, not just the SVG text.
+#[test]
+fn a_border_is_stroked_onto_the_raster_canvas() {
+ use crate::render::style::BorderStyle;
+
+ let border = BorderStyle {
+ width: 4.0,
+ color: crate::profile::Rgb::new(255, 0, 0),
+ radius: 0.0,
+ };
+ let style = Style {
+ border,
+ canvas_padding: Padding::Uniform(10),
+ ..Style::default()
+ };
+ let mut renderer = GridRenderer::with_zoom(6, 2, 1.0, style.clone()).unwrap();
+ let image = renderer
+ .render(&frame(vec![vec![EmuCell::blank(); 6]; 2]))
+ .unwrap();
+
+ let (panel_width, _) = crate::render::svg::pixel_size(6, 2, &style);
+ // The panel is centered, so its left edge sits one padding in. Two pixels
+ // further is the middle of a four-wide stroke.
+ let middle_of_stroke = (image.dimensions().0 - panel_width) / 2 + 2;
+ assert_eq!(
+ pixel_at(&image, middle_of_stroke, image.dimensions().1 / 2),
+ color_to_pixel(Color::Rgb(255, 0, 0)),
+ "the configured border color is painted along the panel edge"
+ );
+
+ let mut plain = GridRenderer::with_zoom(
+ 6,
+ 2,
+ 1.0,
+ Style {
+ canvas_padding: Padding::Uniform(10),
+ ..Style::default()
+ },
+ )
+ .unwrap();
+ let unbordered = plain
+ .render(&frame(vec![vec![EmuCell::blank(); 6]; 2]))
+ .unwrap();
+ assert_ne!(
+ pixel_at(&unbordered, middle_of_stroke, unbordered.dimensions().1 / 2),
+ color_to_pixel(Color::Rgb(255, 0, 0)),
+ "and asking for no border leaves that edge alone"
+ );
+}
+
+/// The raster path centres the window on its canvas, which is the same thing
+/// as "at the gap" only while every gap is equal.
+#[test]
+fn the_raster_window_sits_at_its_own_gaps() {
+ use crate::render::style::PaddingSides;
+
+ let style = Style {
+ canvas_padding: Padding::Sides(PaddingSides {
+ top: Some(10),
+ right: Some(20),
+ bottom: Some(60),
+ left: Some(30),
+ }),
+ canvas_background: crate::profile::Rgb::new(1, 2, 3),
+ // Off, so a tinted pixel means the panel rather than its shadow.
+ shadow: crate::render::style::ShadowStyle {
+ enabled: false,
+ ..crate::render::style::ShadowStyle::default()
+ },
+ ..Style::default()
+ };
+ let mut renderer = GridRenderer::with_zoom(6, 2, 1.0, style.clone()).unwrap();
+ let (panel_width, panel_height) = crate::render::svg::pixel_size(6, 2, &style);
+
+ assert_eq!(
+ renderer.dimensions(),
+ (panel_width + 30 + 20, panel_height + 10 + 60),
+ "each axis grows by its own two gaps"
+ );
+
+ let image = renderer
+ .render(&frame(vec![vec![EmuCell::blank(); 6]; 2]))
+ .unwrap();
+ let canvas = color_to_pixel(Color::Rgb(1, 2, 3));
+
+ // One pixel inside the left gap is canvas; one pixel past it is the panel.
+ assert_eq!(pixel_at(&image, 29, panel_height / 2 + 10), canvas);
+ assert_ne!(
+ pixel_at(&image, 31, panel_height / 2 + 10),
+ canvas,
+ "the window starts at the left gap, not at the midpoint of the canvas"
+ );
+ // The bottom gap is wider than the top, so the row below the panel is
+ // still canvas while the matching row above it is too.
+ assert_eq!(pixel_at(&image, panel_width / 2 + 30, 9), canvas);
+ assert_eq!(
+ pixel_at(&image, panel_width / 2 + 30, image.dimensions().1 - 2),
+ canvas
+ );
+}
diff --git a/crates/tui-test/src/render/snapshot_tests.rs b/crates/tui-test/src/render/snapshot_tests.rs
index dcdafb5d..d6e81a46 100644
--- a/crates/tui-test/src/render/snapshot_tests.rs
+++ b/crates/tui-test/src/render/snapshot_tests.rs
@@ -34,7 +34,13 @@ fn single_frame_gif_and_png_renders_match_snapshots() {
)),
cursor: case.cursor,
};
- let mut renderer = GridRenderer::with_zoom(COLS, ROWS, case.zoom).unwrap();
+ let mut renderer = GridRenderer::with_zoom(
+ COLS,
+ ROWS,
+ case.zoom,
+ crate::render::style::Style::default(),
+ )
+ .unwrap();
encode::encode(&output, format, &[frame], &mut renderer, 30, None).unwrap();
let actual = std::fs::read(&output).unwrap();
std::fs::remove_file(output).unwrap();
diff --git a/crates/tui-test/src/render/style.rs b/crates/tui-test/src/render/style.rs
new file mode 100644
index 00000000..40e5023c
--- /dev/null
+++ b/crates/tui-test/src/render/style.rs
@@ -0,0 +1,655 @@
+//! How a screenshot or recording is drawn.
+//!
+//! `[recording.style]` resolves through [`crate::profile::Settings`] into the
+//! session. SVG and raster output share the same style.
+//! Regenerate the default golden with `TUI_TEST_UPDATE_GOLDEN=1` after intentional changes.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use crate::profile::Rgb;
+
+/// The default CSS font stack for SVG and raster output.
+pub const DEFAULT_FONT_FAMILY: &str =
+ "'Cascadia Code','JetBrains Mono','Fira Code',Menlo,Consolas,'DejaVu Sans Mono',monospace";
+
+const DEFAULT_FONT_SIZE: f32 = 17.0;
+const MAX_FONT_SIZE: f32 = 1_000.0;
+const MAX_LENGTH: f32 = 10_000.0;
+const MAX_PADDING: u32 = 10_000;
+
+const CELL_W_RATIO: f32 = 10.0 / DEFAULT_FONT_SIZE;
+const CELL_H_RATIO: f32 = 21.0 / DEFAULT_FONT_SIZE;
+
+/// Font families, with optional overrides for bold and italic text.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct FontFamilies {
+ /// The family for unstyled text, and the fallback for the other three.
+ pub family: String,
+ pub bold: Option,
+ pub italic: Option,
+ pub bold_italic: Option,
+ /// Extra font files or directories, relative to the config file.
+ /// Unavailable fonts fall back to bundled and system fonts.
+ pub files: Vec,
+}
+
+impl Default for FontFamilies {
+ fn default() -> Self {
+ Self {
+ family: DEFAULT_FONT_FAMILY.to_string(),
+ bold: None,
+ italic: None,
+ bold_italic: None,
+ files: Vec::new(),
+ }
+ }
+}
+
+impl FontFamilies {
+ /// Bold italic falls back through bold, italic, then the base family.
+ pub fn resolve(&self, bold: bool, italic: bool) -> &str {
+ let pick = match (bold, italic) {
+ (true, true) => self
+ .bold_italic
+ .as_deref()
+ .or(self.bold.as_deref())
+ .or(self.italic.as_deref()),
+ (true, false) => self.bold.as_deref(),
+ (false, true) => self.italic.as_deref(),
+ (false, false) => None,
+ };
+ pick.unwrap_or(&self.family)
+ }
+
+ pub fn named(&self) -> impl Iterator- {
+ std::iter::once(self.family.as_str()).chain(
+ [
+ self.bold.as_deref(),
+ self.italic.as_deref(),
+ self.bold_italic.as_deref(),
+ ]
+ .into_iter()
+ .flatten(),
+ )
+ }
+
+ pub fn resolve_paths(&mut self, config_dir: &Path) {
+ for file in &mut self.files {
+ if file.is_relative() {
+ *file = config_dir.join(&*file);
+ }
+ }
+ }
+}
+
+/// The window chrome drawn around the grid.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct WindowStyle {
+ /// Draw the title bar, divider, and controls above the grid.
+ pub title_bar: bool,
+ /// Draw the three controls when the title bar is visible.
+ pub traffic_lights: bool,
+ pub background: Rgb,
+ pub foreground: Rgb,
+ pub divider: Rgb,
+}
+
+impl WindowStyle {
+ pub fn traffic_lights(&self) -> &[Rgb] {
+ const LIGHTS: [Rgb; 3] = [
+ Rgb::new(236, 106, 94),
+ Rgb::new(244, 191, 79),
+ Rgb::new(97, 197, 84),
+ ];
+ if self.title_bar && self.traffic_lights {
+ &LIGHTS
+ } else {
+ &[]
+ }
+ }
+}
+
+impl Default for WindowStyle {
+ fn default() -> Self {
+ Self {
+ title_bar: true,
+ traffic_lights: true,
+ background: Rgb::new(217, 217, 232),
+ foreground: Rgb::new(65, 65, 69),
+ divider: Rgb::new(0, 0, 0),
+ }
+ }
+}
+
+/// A panel border. A zero width disables it.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct BorderStyle {
+ pub width: f32,
+ pub color: Rgb,
+ /// Corner radius of the panel, which the border follows.
+ pub radius: f32,
+}
+
+impl Default for BorderStyle {
+ fn default() -> Self {
+ Self {
+ width: 0.0,
+ color: Rgb::new(0, 0, 0),
+ radius: 8.0,
+ }
+ }
+}
+
+/// The drop shadow under the panel.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct ShadowStyle {
+ pub enabled: bool,
+ pub color: Rgb,
+ /// How far below the panel the shadow sits.
+ pub offset: f32,
+ /// How far beyond the panel's edges it reaches.
+ pub spread: f32,
+}
+
+impl Default for ShadowStyle {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ color: Rgb::new(8, 8, 18),
+ offset: 5.0,
+ spread: 7.0,
+ }
+ }
+}
+
+impl ShadowStyle {
+ /// `(spread, offset, alpha)` layers, largest first, shared by both renderers.
+ fn layers(&self) -> [(f32, f32, u8); 4] {
+ let spread_step = self.spread * 2.0 / 7.0;
+ let offset_step = self.offset / 5.0;
+ std::array::from_fn(|index| {
+ let step = index as f32;
+ (
+ self.spread - step * spread_step,
+ self.offset - step * offset_step,
+ 18 + index as u8 * 2,
+ )
+ })
+ }
+}
+
+/// One number covers every side. A table sets individual sides.
+/// Omitted sides inherit file settings, then the canvas or content defaults.
+///
+/// ```toml
+/// canvas_padding = 24
+///
+/// # or
+/// [recording.style.canvas_padding]
+/// bottom = 48
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Padding {
+ Uniform(u32),
+ Sides(PaddingSides),
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct PaddingSides {
+ pub top: Option,
+ pub right: Option,
+ pub bottom: Option,
+ pub left: Option,
+}
+
+impl Padding {
+ fn side(self, pick: fn(PaddingSides) -> Option, fallback: u32) -> u32 {
+ match self {
+ Self::Uniform(value) => value,
+ Self::Sides(sides) => pick(sides).unwrap_or(fallback),
+ }
+ }
+
+ fn each(self, fallback: Sides) -> [(&'static str, u32); 4] {
+ [
+ ("top", self.side(|s| s.top, fallback.top)),
+ ("right", self.side(|s| s.right, fallback.right)),
+ ("bottom", self.side(|s| s.bottom, fallback.bottom)),
+ ("left", self.side(|s| s.left, fallback.left)),
+ ]
+ }
+}
+
+/// The default gap on each side, for a `Padding` that names none.
+#[derive(Debug, Clone, Copy)]
+struct Sides {
+ top: u32,
+ right: u32,
+ bottom: u32,
+ left: u32,
+}
+
+/// Around the window.
+const CANVAS_SIDES: Sides = Sides {
+ top: 24,
+ right: 24,
+ bottom: 24,
+ left: 24,
+};
+
+/// Between the window and the grid inside it. Wider at the sides than at the
+/// top and bottom because a character sits tight in its cell horizontally and
+/// the rows already carry their own leading.
+const CONTENT_SIDES: Sides = Sides {
+ top: 8,
+ right: 15,
+ bottom: 14,
+ left: 15,
+};
+
+/// Everything about how output is drawn.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Style {
+ pub font: FontFamilies,
+ /// Grid font size in pixels. Cell width and height follow it.
+ pub font_size: f32,
+ /// Title bar font size in pixels.
+ pub title_font_size: f32,
+ /// Background outside the panel, separate from the title bar and terminal.
+ pub canvas_background: Rgb,
+ /// The width of that area on every side of the panel.
+ pub canvas_padding: Padding,
+ /// The gap between the window and the grid drawn inside it.
+ pub content_padding: Padding,
+ pub window: WindowStyle,
+ pub border: BorderStyle,
+ pub shadow: ShadowStyle,
+}
+
+impl Default for Style {
+ fn default() -> Self {
+ Self {
+ font: FontFamilies::default(),
+ font_size: DEFAULT_FONT_SIZE,
+ title_font_size: 13.0,
+ canvas_background: Rgb::new(104, 103, 170),
+ canvas_padding: Padding::Uniform(CANVAS_SIDES.top),
+ content_padding: Padding::Sides(PaddingSides::default()),
+ window: WindowStyle::default(),
+ border: BorderStyle::default(),
+ shadow: ShadowStyle::default(),
+ }
+ }
+}
+
+impl Style {
+ /// Reject non-finite values, invalid sizes, and excessive dimensions.
+ pub fn validate(&self) -> Result<(), String> {
+ for (name, value) in [
+ ("font_size", self.font_size),
+ ("title_font_size", self.title_font_size),
+ ] {
+ if !value.is_finite() || value <= 0.0 {
+ return Err(format!("{name} must be finite and greater than zero"));
+ }
+ if value > MAX_FONT_SIZE {
+ return Err(format!("{name} must not exceed {MAX_FONT_SIZE}"));
+ }
+ }
+ for (name, value) in [
+ ("border.width", self.border.width),
+ ("border.radius", self.border.radius),
+ ("shadow.offset", self.shadow.offset),
+ ("shadow.spread", self.shadow.spread),
+ ] {
+ if !value.is_finite() || value < 0.0 {
+ return Err(format!("{name} must be finite and not negative"));
+ }
+ if value > MAX_LENGTH {
+ return Err(format!("{name} must not exceed {MAX_LENGTH}"));
+ }
+ }
+ for (name, padding, fallback) in [
+ ("canvas_padding", self.canvas_padding, CANVAS_SIDES),
+ ("content_padding", self.content_padding, CONTENT_SIDES),
+ ] {
+ for (side, value) in padding.each(fallback) {
+ if value > MAX_PADDING {
+ return Err(format!("{name} {side} must not exceed {MAX_PADDING}"));
+ }
+ }
+ }
+ // A positive font size can still be too small to draw with: a
+ // subnormal one leaves cells that round to nothing.
+ if self.cell_width() < 1.0 || self.cell_height() < 1.0 {
+ return Err("font_size is too small to draw a cell".to_string());
+ }
+ if self.font.family.trim().is_empty() {
+ return Err("font.family must name a font".to_string());
+ }
+ for (name, family) in [
+ ("font.bold", &self.font.bold),
+ ("font.italic", &self.font.italic),
+ ("font.bold_italic", &self.font.bold_italic),
+ ] {
+ if family.as_deref().is_some_and(|f| f.trim().is_empty()) {
+ return Err(format!("{name} must name a font when it is set"));
+ }
+ }
+ Ok(())
+ }
+
+ pub fn cell_width(&self) -> f32 {
+ self.font_size * CELL_W_RATIO
+ }
+
+ pub fn cell_height(&self) -> f32 {
+ self.font_size * CELL_H_RATIO
+ }
+
+ /// Where a glyph sits inside its cell, measured from the cell's top.
+ pub fn baseline(&self) -> f32 {
+ (self.cell_height() - self.font_size) / 2.0 + self.font_size * 0.78
+ }
+
+ /// The gap around the window, resolved per side.
+ pub fn canvas_top(&self) -> u32 {
+ self.canvas_padding.side(|s| s.top, CANVAS_SIDES.top)
+ }
+
+ pub fn canvas_right(&self) -> u32 {
+ self.canvas_padding.side(|s| s.right, CANVAS_SIDES.right)
+ }
+
+ pub fn canvas_bottom(&self) -> u32 {
+ self.canvas_padding.side(|s| s.bottom, CANVAS_SIDES.bottom)
+ }
+
+ pub fn canvas_left(&self) -> u32 {
+ self.canvas_padding.side(|s| s.left, CANVAS_SIDES.left)
+ }
+
+ /// What the canvas grows by to hold the window, or `None` past `u32`.
+ pub fn canvas_horizontal(&self) -> Option {
+ self.canvas_left().checked_add(self.canvas_right())
+ }
+
+ pub fn canvas_vertical(&self) -> Option {
+ self.canvas_top().checked_add(self.canvas_bottom())
+ }
+
+ pub fn content_top(&self) -> f32 {
+ self.content_padding.side(|s| s.top, CONTENT_SIDES.top) as f32
+ }
+
+ pub fn content_right(&self) -> f32 {
+ self.content_padding.side(|s| s.right, CONTENT_SIDES.right) as f32
+ }
+
+ pub fn content_bottom(&self) -> f32 {
+ self.content_padding
+ .side(|s| s.bottom, CONTENT_SIDES.bottom) as f32
+ }
+
+ pub fn content_left(&self) -> f32 {
+ self.content_padding.side(|s| s.left, CONTENT_SIDES.left) as f32
+ }
+
+ /// The height the title bar occupies, zero when it is not drawn.
+ pub fn header_height(&self) -> f32 {
+ if self.window.title_bar {
+ 34.0
+ } else {
+ 0.0
+ }
+ }
+
+ /// The height of the line under the title bar.
+ pub fn divider_height(&self) -> f32 {
+ if self.window.title_bar {
+ 1.0
+ } else {
+ 0.0
+ }
+ }
+
+ /// The shadow's stacked rectangles, empty when it is turned off.
+ pub fn shadow_layers(&self) -> Vec<(f32, f32, u8)> {
+ if self.shadow.enabled {
+ self.shadow.layers().to_vec()
+ } else {
+ Vec::new()
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The default has to reproduce the constants the renderer used to hold,
+ /// or every existing screenshot and recording shifts.
+ #[test]
+ fn the_default_reproduces_the_original_geometry() {
+ let style = Style::default();
+ assert_eq!(style.cell_width(), 10.0);
+ assert_eq!(style.cell_height(), 21.0);
+ assert_eq!(style.font_size, 17.0);
+ assert_eq!(style.baseline(), (21.0 - 17.0) / 2.0 + 17.0 * 0.78);
+ assert_eq!(style.header_height(), 34.0);
+ assert_eq!(style.divider_height(), 1.0);
+ assert_eq!(style.canvas_top(), 24);
+ assert_eq!(style.border.radius, 8.0);
+ assert_eq!(style.border.width, 0.0, "no border was drawn before");
+ assert_eq!(style.shadow_layers().len(), 4);
+ }
+
+ /// One knob moves the grid: the ratios are what keep a larger font from
+ /// overflowing cells sized for the old one.
+ #[test]
+ fn cell_geometry_follows_the_font_size() {
+ let style = Style {
+ font_size: 34.0,
+ ..Style::default()
+ };
+ assert_eq!(style.cell_width(), 20.0);
+ assert_eq!(style.cell_height(), 42.0);
+ }
+
+ #[test]
+ fn a_variant_font_falls_back_to_the_family() {
+ let plain = FontFamilies::default();
+ assert_eq!(plain.resolve(true, true), DEFAULT_FONT_FAMILY);
+
+ let mixed = FontFamilies {
+ family: "Regular".into(),
+ bold: Some("Bold".into()),
+ ..FontFamilies::default()
+ };
+ assert_eq!(mixed.resolve(false, false), "Regular");
+ assert_eq!(mixed.resolve(true, false), "Bold");
+ assert_eq!(mixed.resolve(false, true), "Regular");
+ assert_eq!(
+ mixed.resolve(true, true),
+ "Bold",
+ "bold italic falls through to bold before giving up on the variant"
+ );
+ }
+
+ #[test]
+ fn turning_the_title_bar_off_removes_its_height() {
+ let style = Style {
+ window: WindowStyle {
+ title_bar: false,
+ ..WindowStyle::default()
+ },
+ ..Style::default()
+ };
+ assert_eq!(style.header_height(), 0.0);
+ assert_eq!(style.divider_height(), 0.0);
+ }
+
+ #[test]
+ fn a_disabled_shadow_draws_no_layers() {
+ let style = Style {
+ shadow: ShadowStyle {
+ enabled: false,
+ ..ShadowStyle::default()
+ },
+ ..Style::default()
+ };
+ assert!(style.shadow_layers().is_empty());
+ }
+
+ #[test]
+ fn font_files_resolve_against_the_config_that_named_them() {
+ // Built by joining rather than written out: a literal "/opt/x" is
+ // relative on Windows, which has no drive letter for it, and a
+ // literal expectation would compare separators byte for byte.
+ let config_dir = std::env::temp_dir().join("config");
+ let absolute = std::env::temp_dir().join("Absolute.ttf");
+ assert!(absolute.is_absolute(), "the fixture is absolute everywhere");
+
+ let mut font = FontFamilies {
+ files: vec![
+ PathBuf::from("fonts").join("Berkeley.ttf"),
+ absolute.clone(),
+ ],
+ ..FontFamilies::default()
+ };
+ font.resolve_paths(&config_dir);
+ assert_eq!(
+ font.files[0],
+ config_dir.join("fonts").join("Berkeley.ttf"),
+ "a repository carrying its own font renders the same wherever it sits"
+ );
+ assert_eq!(font.files[1], absolute, "an absolute path is left alone");
+ }
+
+ #[test]
+ fn every_named_family_is_reported_for_loading() {
+ let font = FontFamilies {
+ family: "Regular".into(),
+ bold: Some("Bold".into()),
+ bold_italic: Some("BoldItalic".into()),
+ ..FontFamilies::default()
+ };
+ let named: Vec<&str> = font.named().collect();
+ assert_eq!(named, ["Regular", "Bold", "BoldItalic"]);
+ }
+
+ #[test]
+ fn the_config_spelling_matches_the_rest_of_the_file() {
+ // Colors spells bright_black, so Style spells font_size. One config
+ // file should not carry two naming conventions.
+ let style: Style = toml::from_str(
+ "font_size = 20\ncanvas_padding = 8\n\
+ [font]\nfamily = \"Berkeley Mono\"\nbold_italic = \"Berkeley Mono Oblique\"\n\
+ [window]\ntitle_bar = false\n\
+ [border]\nwidth = 2\ncolor = \"#ff0000\"\n\
+ [shadow]\nenabled = false\noffset = 2\n",
+ )
+ .unwrap();
+ assert_eq!(style.font_size, 20.0);
+ assert_eq!(style.font.resolve(true, true), "Berkeley Mono Oblique");
+ assert!(!style.window.title_bar);
+ assert_eq!(style.border.width, 2.0);
+ assert!(!style.shadow.enabled);
+ assert_eq!(style.shadow.offset, 2.0);
+ assert_eq!(
+ style.canvas_background,
+ Style::default().canvas_background,
+ "anything unnamed keeps its default"
+ );
+ }
+
+ #[test]
+ fn a_kebab_case_key_is_rejected() {
+ let error = toml::from_str::