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" +``` + +

+ tui-test drawn with a dark canvas, chrome and palette +

+ +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::