use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::Result;
use crate::core::component::Component;
use crate::core::element::Element;
use crate::mockup::Mockup;
use crate::style::Rect;
use crate::test_backend::TestBackend;
#[cfg(feature = "ui-snapshot-png")]
use super::baseline::{self, BaselineComparison};
use super::options::UiSnapshotOptions;
const DEFAULT_VIEWPORT: (u16, u16) = (80, 24);
const DEFAULT_FIT_MARGIN: (u16, u16) = (20, 8);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SketchViewport {
Fixed { w: u16, h: u16 },
Fit { margin_w: u16, margin_h: u16 },
}
impl SketchViewport {
fn slug(&self) -> String {
match self {
Self::Fixed { w, h } => format!("{w}x{h}"),
Self::Fit { .. } => "fit".to_owned(),
}
}
}
pub struct Sketch<C: Component> {
name: String,
component: C,
viewports: Vec<SketchViewport>,
dir: Option<PathBuf>,
markdown: bool,
png: bool,
json: bool,
quiet: bool,
focus_steps: usize,
options: UiSnapshotOptions,
key_script: Option<String>,
advance: Duration,
#[cfg(feature = "ui-snapshot-png")]
baseline_dir: Option<PathBuf>,
#[cfg(feature = "ui-snapshot-png")]
tolerance: f64,
}
impl<F> Sketch<Mockup<F>>
where
F: Fn() -> Element + 'static,
{
pub fn view(name: impl Into<String>, view: F) -> Self {
Self::component(name, Mockup::new(view))
}
}
impl<C: Component> Sketch<C>
where
C::Properties: Default,
{
pub fn component(name: impl Into<String>, component: C) -> Self {
Self {
name: name.into(),
component,
viewports: Vec::new(),
dir: None,
markdown: true,
png: true,
json: false,
quiet: false,
focus_steps: 0,
options: UiSnapshotOptions::default(),
key_script: None,
advance: Duration::ZERO,
#[cfg(feature = "ui-snapshot-png")]
baseline_dir: None,
#[cfg(feature = "ui-snapshot-png")]
tolerance: 0.0,
}
}
#[must_use]
pub fn viewport(mut self, w: u16, h: u16) -> Self {
self.viewports.push(SketchViewport::Fixed { w, h });
self
}
#[must_use]
pub fn fit(mut self, margin_w: u16, margin_h: u16) -> Self {
self.viewports
.push(SketchViewport::Fit { margin_w, margin_h });
self
}
#[must_use]
pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dir = Some(dir.into());
self
}
#[must_use]
pub fn focus_next(mut self, steps: usize) -> Self {
self.focus_steps = steps;
self
}
#[must_use]
pub fn options(mut self, options: UiSnapshotOptions) -> Self {
self.options = options;
self
}
#[must_use]
pub fn markdown(mut self, enabled: bool) -> Self {
self.markdown = enabled;
self
}
#[must_use]
pub fn png(mut self, enabled: bool) -> Self {
self.png = enabled;
self
}
#[must_use]
pub fn json(mut self, enabled: bool) -> Self {
self.json = enabled;
self
}
#[must_use]
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
#[must_use]
pub fn keys(mut self, script: impl AsRef<str>) -> Self {
self.key_script = Some(script.as_ref().to_owned());
self
}
#[must_use]
pub fn advance(mut self, dt: Duration) -> Self {
self.advance = dt;
self
}
#[cfg(feature = "ui-snapshot-png")]
#[must_use]
pub fn baseline(mut self, dir: impl Into<PathBuf>) -> Self {
self.baseline_dir = Some(dir.into());
self
}
#[cfg(feature = "ui-snapshot-png")]
#[must_use]
pub fn tolerance(mut self, ratio: f64) -> Self {
self.tolerance = ratio.clamp(0.0, 1.0);
self
}
pub fn write(self) -> Result<Vec<PathBuf>> {
Ok(self.run()?.written)
}
#[cfg(feature = "ui-snapshot-png")]
pub fn check(self) -> Result<Vec<BaselineComparison>> {
Ok(self.run()?.comparisons)
}
#[cfg(feature = "ui-snapshot-png")]
pub fn assert_baseline(self) -> Result<()> {
let comparisons = self.check()?;
let regressions: Vec<String> = comparisons
.iter()
.filter(|comparison| comparison.outcome.is_regression())
.map(|comparison| comparison.outcome.summary(&comparison.name))
.collect();
if regressions.is_empty() {
return Ok(());
}
Err(std::io::Error::other(format!(
"{} visual baseline regression(s):\n {}\n\nRe-run with `{}=1` to accept them.",
regressions.len(),
regressions.join("\n "),
baseline::UPDATE_ENV,
))
.into())
}
fn run(self) -> Result<SketchRun> {
let Self {
name,
component,
viewports,
dir,
markdown,
png,
json,
quiet,
focus_steps,
options,
key_script,
advance,
#[cfg(feature = "ui-snapshot-png")]
baseline_dir,
#[cfg(feature = "ui-snapshot-png")]
tolerance,
} = self;
let formats = SketchFormats {
markdown,
png,
json,
};
let keys = match key_script.as_deref() {
Some(script) => super::keys::parse_key_script(script)?,
None => Vec::new(),
};
let dir = dir.unwrap_or_else(default_sketch_dir);
std::fs::create_dir_all(&dir)?;
let viewports = if viewports.is_empty() {
vec![
SketchViewport::Fixed {
w: DEFAULT_VIEWPORT.0,
h: DEFAULT_VIEWPORT.1,
},
SketchViewport::Fit {
margin_w: DEFAULT_FIT_MARGIN.0,
margin_h: DEFAULT_FIT_MARGIN.1,
},
]
} else {
viewports
};
let stem = slugify(&name);
let mut backend = TestBackend::new(component);
let mut run = SketchRun::default();
for viewport in viewports {
backend.set_viewport(Rect {
x: 0,
y: 0,
w: DEFAULT_VIEWPORT.0,
h: DEFAULT_VIEWPORT.1,
});
if let SketchViewport::Fixed { w, h } = viewport {
backend.set_viewport(Rect {
x: 0,
y: 0,
w: w.max(1),
h: h.max(1),
});
}
backend.render();
for _ in 0..focus_steps {
backend.focus_next();
}
if focus_steps > 0 {
backend.render();
}
for key in &keys {
backend.send_key(*key)?;
}
if !advance.is_zero() {
backend.advance(advance);
}
let snapshot = match viewport {
SketchViewport::Fixed { .. } => backend.capture_ui_snapshot_with_options(&options),
SketchViewport::Fit { margin_w, margin_h } => {
backend.capture_ui_snapshot_with_margin(margin_w, margin_h, &options)
}
};
let base = format!("{stem}-{}", viewport.slug());
run.written
.extend(write_sketch_artifacts(&formats, &dir, &base, &snapshot)?);
#[cfg(feature = "ui-snapshot-png")]
if let Some(baseline_dir) = baseline_dir.as_ref() {
let baseline_path = baseline_dir.join(format!("{base}.png"));
let deterministic = crate::capture::PngOptions {
text_renderer: crate::capture::PngTextRenderer::Bitmap,
..crate::capture::PngOptions::default()
};
let current = snapshot.to_png(&deterministic)?;
run.comparisons.push(baseline::compare_or_create(
&base,
&baseline_path,
¤t,
tolerance,
)?);
}
}
if !quiet {
for path in &run.written {
println!("wrote {}", path.display());
}
#[cfg(feature = "ui-snapshot-png")]
for comparison in &run.comparisons {
println!("{}", comparison.outcome.summary(&comparison.name));
}
#[cfg(not(feature = "ui-snapshot-png"))]
if formats.png {
println!(
"note: PNG skipped - rerun with `--features ui-snapshot-png` (or `cargo snap`)"
);
}
}
Ok(run)
}
}
#[derive(Default)]
struct SketchRun {
written: Vec<PathBuf>,
#[cfg(feature = "ui-snapshot-png")]
comparisons: Vec<BaselineComparison>,
}
#[derive(Clone, Copy, Debug)]
struct SketchFormats {
markdown: bool,
png: bool,
#[allow(dead_code)] json: bool,
}
fn write_sketch_artifacts(
formats: &SketchFormats,
dir: &Path,
base: &str,
snapshot: &super::UiSnapshot,
) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
if formats.markdown {
let path = dir.join(format!("{base}.md"));
std::fs::write(&path, snapshot.to_markdown())?;
written.push(path);
}
#[cfg(feature = "ui-snapshot-json")]
if formats.json {
let path = dir.join(format!("{base}.json"));
std::fs::write(&path, snapshot.to_json_pretty())?;
written.push(path);
}
#[cfg(feature = "ui-snapshot-png")]
if formats.png {
let path = dir.join(format!("{base}.png"));
std::fs::write(&path, snapshot.to_png_default()?)?;
written.push(path);
}
Ok(written)
}
fn default_sketch_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("TUI_LIPAN_SKETCH_DIR") {
return PathBuf::from(dir);
}
let base = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("CARGO_MANIFEST_DIR").map(|dir| PathBuf::from(dir).join("target"))
})
.unwrap_or_else(|| PathBuf::from("target"));
base.join("ui-sketches")
}
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut pending_dash = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
if pending_dash && !out.is_empty() {
out.push('-');
}
pending_dash = false;
out.push(ch.to_ascii_lowercase());
} else {
pending_dash = true;
}
}
if out.is_empty() {
out.push_str("sketch");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_replaces_runs_of_separators_with_single_dash() {
assert_eq!(slugify("Login Screen"), "login-screen");
assert_eq!(slugify(" weird // name "), "weird-name");
assert_eq!(slugify("!!!"), "sketch");
}
#[test]
fn viewport_slug_distinguishes_fixed_from_fit() {
assert_eq!(SketchViewport::Fixed { w: 80, h: 24 }.slug(), "80x24");
assert_eq!(
SketchViewport::Fit {
margin_w: 20,
margin_h: 8
}
.slug(),
"fit"
);
}
}