diff --git a/README.md b/README.md index 44fd602..d56b425 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,13 @@ animfetch # interactive: animfetch owns the prompt scroll, then detaches and paints them while your own shell runs underneath with its history, completion and aliases intact. Undo with `animfetch --unpin`. +A terminal holds one pin. A bare `animfetch --pin`, which is what a shell's +startup file runs, leaves a pin that is already there alone, so nested shells +stay quiet. `--pin` with settings (`animfetch --pin --no-color`) replaces it, +so a line you type takes effect at once. A run with `--save` does not: it +writes the settings, says so, and leaves this terminal as it is; terminals you +open after that start with them. + ```sh animfetch -a cat-tail # different animation, this run only animfetch --style quad # half (default), quad, ramp, or raw @@ -130,7 +137,8 @@ animfetch --width 40 # cap the art width (0 fills the screen) animfetch --height 12 # both caps apply, aspect ratio is kept animfetch --fps 20 animfetch --play -s 1.5 # shorter intro -animfetch --no-color +animfetch --no-color # everything in shades of grey +animfetch --def-color # the default colours, from your theme: undoes --no-color ``` Piping the output, or `NO_COLOR=1`, drops to the static path automatically. @@ -180,6 +188,7 @@ and another along its bottom, drawn from PNG line art: ```sh animfetch --pin --border-left 00 --border-bottom 03 +animfetch --pin --lb 00 --db 03 # the same, shorter: --lb left, --db down animfetch --list-borders # what is built in, and your own animfetch --pin --no-border # for this run only ``` @@ -205,9 +214,10 @@ pictures even where they are not detected. A bottom image is stored upright, hilt at the top, and is turned a quarter clockwise so it lies along the bottom. The art and the info give up the room a border takes, and it is left out on a terminal under 60 columns and when the -output is not a terminal. `--no-color` and `NO_COLOR` do not remove the -pictures, which have no colour to turn off; they do remove the `"cells"` -drawing, which is coloured text. +output is not a terminal. `--no-color` draws the pictures in grey, like the +rest. `NO_COLOR` and `color = false` leave the pictures as they are, which have +no colour to turn off, and remove the `"cells"` drawing, which is coloured +text. Put your own PNGs in `~/.config/animfetch/border/left_border/` and `.../bottom_border/`; a file of yours shadows a built-in of the same name. They diff --git a/assets/border/bottom_border/00.png b/assets/border/down_border/00.png similarity index 100% rename from assets/border/bottom_border/00.png rename to assets/border/down_border/00.png diff --git a/assets/border/bottom_border/01.png b/assets/border/down_border/01.png similarity index 100% rename from assets/border/bottom_border/01.png rename to assets/border/down_border/01.png diff --git a/assets/border/bottom_border/02.png b/assets/border/down_border/02.png similarity index 100% rename from assets/border/bottom_border/02.png rename to assets/border/down_border/02.png diff --git a/assets/border/bottom_border/03.png b/assets/border/down_border/03.png similarity index 100% rename from assets/border/bottom_border/03.png rename to assets/border/down_border/03.png diff --git a/assets/border/bottom_border/04.png b/assets/border/down_border/04.png similarity index 100% rename from assets/border/bottom_border/04.png rename to assets/border/down_border/04.png diff --git a/assets/border/bottom_border/05.png b/assets/border/down_border/05.png similarity index 100% rename from assets/border/bottom_border/05.png rename to assets/border/down_border/05.png diff --git a/assets/border/bottom_border/06.png b/assets/border/down_border/06.png similarity index 100% rename from assets/border/bottom_border/06.png rename to assets/border/down_border/06.png diff --git a/build.rs b/build.rs index 91c1b72..08520b7 100644 --- a/build.rs +++ b/build.rs @@ -54,8 +54,9 @@ fn main() { /// Generates the `BORDERS` table from `assets/border/`. /// /// Loose PNGs are left borders; `left_border/` and `bottom_border/` say so -/// outright, and win on a name clash. Anything that is not a PNG is left out -/// with a warning, since the decoder reads PNGs and nothing else. +/// outright, and win on a name clash. `down_border/` is the same folder as +/// `bottom_border/` under the name the `--db` flag uses. Anything that is not a +/// PNG is left out with a warning, since the decoder reads PNGs and nothing else. fn borders() { use std::collections::BTreeMap; @@ -68,6 +69,7 @@ fn borders() { scan(root, "left", &mut found); scan(&root.join("left_border"), "left", &mut found); scan(&root.join("bottom_border"), "bottom", &mut found); + scan(&root.join("down_border"), "bottom", &mut found); } let mut out = String::from( diff --git a/config.example.toml b/config.example.toml index c754c61..2d9dac7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -241,6 +241,12 @@ gap = 4 # terminal or NO_COLOR is set. color = true +# Draw in shades of grey: colour is sent, with no hue in it, so the light and +# dark of the art and the text stay. The same as --no-color; --def-color puts +# the default colours back. `color = false` is the other way to have no colour: +# no colour codes at all, so the terminal's own. +gray = false + # Rows in the info pane, in order. Leave this out to get the rows that suit # `info_style`; set it and your list wins, whichever style is chosen. # Available: os, host, kernel, uptime, packages, shell, shell_path, wm, diff --git a/src/anim.rs b/src/anim.rs index 5f72268..56c5fa6 100644 --- a/src/anim.rs +++ b/src/anim.rs @@ -307,6 +307,15 @@ pub struct Animation { } impl Animation { + /// Take the colour out of every frame, once, rather than on every paint. + pub fn grayscale(&mut self) { + for frame in &mut self.frames { + for (_, rgb) in &mut frame.art { + *rgb = rgb.map(Rgb::gray); + } + } + } + fn from_texts(texts: impl IntoIterator) -> Option { let frames: Vec = texts.into_iter().map(|t| Frame::parse(&t)).collect(); if frames.is_empty() { @@ -787,4 +796,27 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::NotFound); assert!(err.to_string().contains(missing), "{err}"); } + + #[test] + fn grayscale_takes_the_hue_out_of_every_cell_and_keeps_the_rest() { + let mut anim = Animation::from_texts([ + "\x1b[38;2;255;0;0m#\x1b[0m.\n".to_string(), + "\x1b[38;2;0;200;90m#\x1b[0m.\n".to_string(), + ]) + .unwrap(); + let before: Vec = anim.frames[0].art.iter().map(|(c, _)| *c).collect(); + + anim.grayscale(); + + for frame in &anim.frames { + for (_, rgb) in &frame.art { + if let Some(Rgb(r, g, b)) = rgb { + assert!(r == g && g == b, "{rgb:?} kept its hue"); + } + } + assert!(frame.art.iter().any(|(_, rgb)| rgb.is_some()), "lost the colour cells"); + } + let after: Vec = anim.frames[0].art.iter().map(|(c, _)| *c).collect(); + assert_eq!(before, after); + } } diff --git a/src/border/bitmap.rs b/src/border/bitmap.rs index 7fc08f7..3dff80c 100644 --- a/src/border/bitmap.rs +++ b/src/border/bitmap.rs @@ -87,6 +87,16 @@ impl Bitmap { ] } + /// The same picture with the hue taken out of every pixel. Transparency is + /// left as it was. + pub fn grayscale(mut self) -> Self { + for px in self.rgba.as_chunks_mut::<4>().0 { + let crate::color::Rgb(y, _, _) = crate::color::Rgb(px[0], px[1], px[2]).gray(); + px[..3].fill(y); + } + self + } + /// A quarter turn clockwise: what was the top is now the right. pub fn rotated_cw(&self) -> Self { let mut out = Self::blank(self.h, self.w); @@ -485,4 +495,19 @@ mod tests { assert_eq!(canvas.pixel(1, 1), [0, 0, 0, 0]); assert_eq!(canvas.rgba.len(), 4 * 4 * 4, "nothing grew"); } + + #[test] + fn grayscale_drops_the_hue_and_leaves_transparency_alone() { + let b = Bitmap { + w: 2, + h: 1, + rgba: vec![255, 0, 0, 255, 10, 200, 30, 0], + } + .grayscale(); + + for px in b.rgba.as_chunks::<4>().0 { + assert!(px[0] == px[1] && px[1] == px[2], "{px:?}"); + } + assert_eq!((b.rgba[3], b.rgba[7]), (255, 0)); + } } diff --git a/src/border/mod.rs b/src/border/mod.rs index 5db57a5..fae26c5 100644 --- a/src/border/mod.rs +++ b/src/border/mod.rs @@ -217,9 +217,10 @@ impl Borders { // pictures and was not told to use text. A name that is wrong is still // wrong, so it is still reported. // - // Colour is not asked of a picture: `--no-color` is about the text. + // Colour is not asked of a picture: `color = false` is about the text. // Only the text drawing of a border is a thing to colour, and without - // colour it is not drawn. + // colour it is not drawn. Grey is a colour setting of its own, and the + // pictures are taken to grey with the rest. let mode = if to_terminal { Mode::resolve(cfg.border_render, images && kitty::supported()) .filter(|m| *m == Mode::Image || cfg.color) @@ -242,6 +243,7 @@ impl Borders { let bitmap = Bitmap::decode(&bytes).map_err(|e| format!("border {spec:?}: {e}"))?; bitmap .trimmed() + .map(|b| if cfg.gray { b.grayscale() } else { b }) .map(Some) .ok_or_else(|| format!("border {spec:?} has nothing visible in it")) }; diff --git a/src/cli.rs b/src/cli.rs index d18cd24..5d3846d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -34,6 +34,10 @@ pub struct Args { pub save: bool, pub once: bool, pub no_color: bool, + pub def_color: bool, + /// The command line was `--pin` and nothing else, which is what a shell's + /// startup file runs. + pub bare_pin: bool, pub palette: bool, pub live: bool, } @@ -70,8 +74,11 @@ impl Args { pub fn parse_from(argv: impl IntoIterator) -> Result { let mut args = Args::default(); let mut argv = argv.into_iter(); + // Options, not the words that follow them. + let mut options = 0usize; while let Some(arg) = argv.next() { + options += 1; let argv: Argv<'_> = &mut argv; match arg.as_str() { "-h" | "--help" => return Ok(Parsed::Help), @@ -83,16 +90,17 @@ impl Args { "-l" | "--list" => args.list = true, "--save" => args.save = true, "--no-color" => args.no_color = true, + "--def-color" => args.def_color = true, "--palette" => args.palette = true, "--live" => args.live = true, "--no-border" => args.no_border = true, "--no-art" => args.no_art = true, "--list-borders" => args.list_borders = true, - "--border-left" => { - args.border_left = Some(value(argv, "--border-left", "a name or a path")?); + "--lb" | "--border-left" => { + args.border_left = Some(value(argv, &arg, "a name or a path")?); } - "--border-bottom" => { - args.border_bottom = Some(value(argv, "--border-bottom", "a name or a path")?); + "--db" | "--border-bottom" => { + args.border_bottom = Some(value(argv, &arg, "a name or a path")?); } "--border-bottom-lift" => { args.border_bottom_lift = @@ -131,9 +139,27 @@ impl Args { } } + args.bare_pin = args.pin && options == 1; Ok(Parsed::Run(args)) } + /// What happens to a pin already on this terminal. A bare `--pin` is the + /// startup file's, which is no reason to disturb it. A run that saves is + /// writing for the terminals still to be opened: it leaves this one as it + /// was, and says so. One with settings of its own that does not save is the + /// run to look at, and takes the terminal over. + pub fn existing_pin(&self) -> crate::pin::Existing { + use crate::pin::Existing; + + if self.save { + Existing::KeepAndSay + } else if self.bare_pin { + Existing::Keep + } else { + Existing::Replace + } + } + /// Lay these overrides over the config. pub fn apply(&self, cfg: &mut Config) { if let Some(name) = &self.animation { @@ -160,8 +186,16 @@ impl Args { if let Some(v) = self.seconds { cfg.play_seconds = v; } + // Each of the two decides both settings, so a config left the other + // way cannot turn `--no-color` into colour or `--def-color` into grey. + // Grey is the plainer, so it wins when both are asked for. + if self.def_color { + cfg.color = true; + cfg.gray = false; + } if self.no_color { - cfg.color = false; + cfg.color = true; + cfg.gray = true; } if self.palette { cfg.color_source = ColorSource::Palette; @@ -210,6 +244,15 @@ impl Args { if let Some(v) = self.info_style { out.push(("info_style", format!("{:?}", v.word()))); } + // The plainer of the two, as `apply` takes it. Both keys, so that the + // file never holds a pair that disagrees with the one just chosen. + if self.no_color { + out.push(("color", "true".to_string())); + out.push(("gray", "true".to_string())); + } else if self.def_color { + out.push(("color", "true".to_string())); + out.push(("gray", "false".to_string())); + } if self.no_art { out.push(("art", "false".to_string())); } @@ -279,9 +322,11 @@ Options: (matugen, pywal, wallust) instead of the config --live Keep uptime, memory, swap and disk current while pinned or interactive (interval: live_interval) - --border-left An ornament down the left edge: the name of a built-in + --lb, --border-left + An ornament down the left edge: the name of a built-in or your own image (see --list-borders), or a PNG path - --border-bottom An ornament along the bottom; the image is upright and + --db, --border-bottom + An ornament along the bottom; the image is upright and is turned a quarter clockwise --border-render auto (pictures where the terminal can show them, else no border), image (force pictures), or cells @@ -295,7 +340,8 @@ Options: --no-border Draw no border this run, whatever the config says --no-art Draw no animation: the info is the whole pane --list-borders List the available border images - --no-color Disable colour output + --no-color Draw everything in shades of grey, no colour at all + --def-color The default colours, from your theme: undoes --no-color -h, --help Show this help -V, --version Show the version @@ -388,6 +434,86 @@ mod tests { assert_eq!(err(&["--nope"]), "unknown option: --nope"); } + #[test] + fn only_a_line_of_nothing_but_pin_is_a_bare_pin() { + assert!(run(&["--pin"]).bare_pin); + assert!(!run(&["--pin", "--no-color"]).bare_pin); + assert!(!run(&["--no-color", "--pin"]).bare_pin); + assert!(!run(&["--pin", "-a", "tree"]).bare_pin); + assert!(!run(&["--no-color"]).bare_pin); + assert!(!run(&[]).bare_pin); + } + + #[test] + fn a_pin_already_there_is_kept_for_a_bare_pin_and_for_a_save() { + use crate::pin::Existing; + + assert_eq!(run(&["--pin"]).existing_pin(), Existing::Keep); + // A save says so, since that terminal looks as if nothing happened. + assert_eq!( + run(&["--pin", "--no-color", "--save"]).existing_pin(), + Existing::KeepAndSay + ); + assert_eq!( + run(&["--save", "--pin", "--info", "card"]).existing_pin(), + Existing::KeepAndSay + ); + + // Settings given to look at now take the terminal over. + assert_eq!( + run(&["--pin", "--no-color"]).existing_pin(), + Existing::Replace + ); + assert_eq!( + run(&["--pin", "-a", "tree", "--live"]).existing_pin(), + Existing::Replace + ); + } + + #[test] + fn each_colour_flag_wins_over_what_the_config_had_the_other_way() { + // The states a config is left in by saving one and then the other, and + // by `color = false`, which is no colour codes at all. + for (color, gray) in [(false, false), (true, false)] { + let mut cfg = Config { + color, + gray, + ..Config::default() + }; + run(&["--no-color"]).apply(&mut cfg); + assert!(cfg.color && cfg.gray, "grey came out otherwise: {color} {gray}"); + } + + for (color, gray) in [(false, false), (true, true)] { + let mut cfg = Config { + color, + gray, + ..Config::default() + }; + run(&["--def-color"]).apply(&mut cfg); + assert!(cfg.color && !cfg.gray, "colour came out otherwise: {color} {gray}"); + } + } + + #[test] + fn no_color_is_grey_and_def_color_is_the_default_colours() { + let mut grey = Config::default(); + run(&["--no-color"]).apply(&mut grey); + // Still coloured on the wire, with no hue in it. + assert!(grey.gray && grey.color); + + let mut def = Config::default(); + run(&["--def-color"]).apply(&mut def); + assert!(def.color && !def.gray); + + // Grey is the plainer, so it wins, whichever order they come in. + for args in [["--no-color", "--def-color"], ["--def-color", "--no-color"]] { + let mut both = Config::default(); + run(&args).apply(&mut both); + assert!(both.color && both.gray, "{args:?}"); + } + } + #[test] fn overrides_are_laid_over_the_config_and_only_those() { let mut cfg = Config::default(); @@ -396,7 +522,7 @@ mod tests { run(&["--info", "tree", "--live", "--no-color"]).apply(&mut cfg); assert_eq!(cfg.info_style, InfoStyle::Tree); - assert!(cfg.live && !cfg.color); + assert!(cfg.live && cfg.gray && cfg.color); assert_eq!( (cfg.fps, cfg.width, cfg.style), before, @@ -421,6 +547,19 @@ mod tests { ); } + #[test] + fn the_short_border_flags_are_the_long_ones() { + let short = run(&["--lb", "03", "--db", "~/x.png"]); + let long = run(&["--border-left", "03", "--border-bottom", "~/x.png"]); + assert_eq!(short.border_left, long.border_left); + assert_eq!(short.border_bottom, long.border_bottom); + assert_eq!(short.saved(), long.saved()); + + // The error names the flag the way it was typed. + assert_eq!(err(&["--lb"]), "--lb needs a name or a path"); + assert_eq!(err(&["--db"]), "--db needs a name or a path"); + } + #[test] fn borders_are_named_laid_over_the_config_and_saved() { let a = run(&[ @@ -491,6 +630,30 @@ mod tests { assert!(run(&["--list-borders"]).list_borders); } + #[test] + fn the_colour_choices_can_be_saved_and_read_back() { + let grey = run(&["--no-color"]).saved(); + assert_eq!( + grey, + [("color", "true".to_string()), ("gray", "true".to_string())] + ); + + let def = run(&["--def-color"]).saved(); + assert_eq!( + def, + [("color", "true".to_string()), ("gray", "false".to_string())] + ); + + // Both is the plainer, as it is when the run is applied. + let both = run(&["--no-color", "--def-color"]).saved(); + assert_eq!(both, grey); + + for saved in [grey, def] { + let text: String = saved.iter().map(|(k, v)| format!("{k} = {v}\n")).collect(); + toml::from_str::(&text).unwrap(); + } + } + #[test] fn saving_nothing_is_an_empty_list() { assert!(run(&["--save", "--pin", "--live"]).saved().is_empty()); @@ -535,6 +698,7 @@ mod tests { "--live", "--palette", "--no-color", + "--def-color", "--animation", "--fps", "--width", @@ -544,7 +708,9 @@ mod tests { "--art-color", "--info", "--border-left", + "--lb", "--border-bottom", + "--db", "--border-render", "--border-bottom-lift", "--border-bottom-shift", diff --git a/src/color.rs b/src/color.rs index 57e42d9..7a15eb8 100644 --- a/src/color.rs +++ b/src/color.rs @@ -40,6 +40,13 @@ impl Rgb { (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)) / 255.0 } + /// The grey of the same brightness: no hue, and as light or dark as this + /// colour looks, so shading and contrast survive the loss of colour. + pub fn gray(self) -> Self { + let y = (self.luma() * 255.0).round() as u8; + Rgb(y, y, y) + } + /// Near-black or near-white, whichever can be read on this colour. pub fn contrast(self) -> Self { if self.luma() > 0.5 { @@ -202,6 +209,21 @@ mod tests { } } + #[test] + fn gray_keeps_the_brightness_and_drops_the_hue() { + assert_eq!(Rgb(0, 0, 0).gray(), Rgb(0, 0, 0)); + assert_eq!(Rgb(255, 255, 255).gray(), Rgb(255, 255, 255)); + assert_eq!(Rgb(77, 77, 77).gray(), Rgb(77, 77, 77)); + + for rgb in [Rgb(255, 0, 0), Rgb(0, 200, 90), Rgb(30, 60, 220)] { + let Rgb(r, g, b) = rgb.gray(); + assert!(r == g && g == b, "{rgb:?} came out with a hue"); + } + // Green is the brightest of the three primaries, blue the darkest. + assert!(Rgb(0, 255, 0).gray().0 > Rgb(255, 0, 0).gray().0); + assert!(Rgb(255, 0, 0).gray().0 > Rgb(0, 0, 255).gray().0); + } + #[test] fn parses_both_hex_forms() { assert_eq!(parse_hex("#ff8000"), Some(Rgb(255, 128, 0))); diff --git a/src/config.rs b/src/config.rs index 12b945c..55826ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -444,8 +444,12 @@ pub struct Config { pub live: bool, /// Seconds between refreshes when `live` is on. Clamped to 0.5-60. pub live_interval: f32, - /// Emit colour. Forced off when piped or when `NO_COLOR` is set. + /// Emit colour. Forced off when piped or when `NO_COLOR` is set. Off, the + /// output carries no colour codes at all and so takes the terminal's own. pub color: bool, + /// Draw in shades of grey: colour is still sent, with no hue in it, so the + /// light and dark of the art and the text stay. Set by `--no-color`. + pub gray: bool, /// Rows to show, in order. Unset means the preset for `info_style`; set, /// it wins whatever the style is. pub modules: Option>, @@ -520,6 +524,7 @@ impl Default for Config { live: false, live_interval: 2.0, color: true, + gray: false, modules: None, custom: Vec::new(), overrides: BTreeMap::new(), @@ -617,6 +622,17 @@ impl Config { } } + /// A colour as it will be drawn: itself, or its grey under `gray`. + pub fn tone(&self, color: Rgb) -> Rgb { + if self.gray { color.gray() } else { color } + } + + /// Whether the rows of the terminal's own palette colours are shown. They + /// are nothing but colour, so grey has no use for them. + pub fn swatches(&self) -> bool { + self.color && !self.gray + } + /// How to turn coverage into glyphs, for the configured style. pub fn ink<'a>(&self, ramp: &'a [char]) -> crate::anim::Ink<'a> { match self.style { @@ -1156,4 +1172,20 @@ mod tests { "the example must not change behaviour" ); } + + #[test] + fn gray_is_a_setting_and_it_hides_the_swatches_with_colour_off() { + assert!(!Config::default().gray); + let cfg: Config = toml::from_str("gray = true").unwrap(); + assert!(cfg.gray && cfg.color); + assert!(!cfg.swatches(), "palette colours in a grey fetch"); + + assert!(Config::default().swatches()); + let plain: Config = toml::from_str("color = false").unwrap(); + assert!(!plain.swatches()); + + let c = Rgb(200, 30, 30); + assert_eq!(Config::default().tone(c), c); + assert_eq!(cfg.tone(c), c.gray()); + } } diff --git a/src/layout.rs b/src/layout.rs index 3b4fb9a..a990a69 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -172,9 +172,9 @@ impl<'a> Layout<'a> { } /// The border pictures for a pane painted by a [`Pane`](crate::render::Pane), - /// which has rows to address: send them, and have the pane place them - /// again with each full repaint. Takes the previous layout's pictures away - /// first, since a resize changes their size. + /// which has rows to address: hand them to the pane, which sends and places + /// them with each full repaint and again every so often. Takes the + /// previous layout's pictures away first, since a resize changes their size. pub fn show_borders( &self, out: &mut impl Write, @@ -185,9 +185,11 @@ impl<'a> Layout<'a> { border::clear(&mut escapes); } + // The pictures go out with the pane's next paint, and again after + // that, so that a screen cleared under them does not lose them. let mut overlay = String::new(); if let Some(frame) = self.frame.as_ref().filter(|f| f.has_images()) { - frame.transmit(&mut escapes); + frame.transmit(&mut overlay); frame.place_at(1, &mut overlay); } pane.set_overlay(overlay); diff --git a/src/main.rs b/src/main.rs index f3f8a2a..9bddc58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,7 +142,10 @@ fn dispatch(args: Args, dir: Option, mut cfg: Config) -> io::Result, mut cfg: Config) -> io::Result) -> io::Result<()> { +pub fn start(fetch: &Fetch<'_>, existing: Existing) -> io::Result<()> { // Claiming is one atomic create, not a check followed by a write: two // shells starting on the same terminal at once could otherwise both see no // owner and both go on to animate the same rows. if !claim_terminal()? { - // A nested shell, most likely. Silent because this runs from an rc - // file, where noise on every subshell gets a tool uninstalled. - return Ok(()); + match existing { + Existing::Keep => return Ok(()), + Existing::KeepAndSay => { + println!( + "this terminal keeps the pin it has; new terminals start with the saved \ + settings\n(to change this one now: animfetch --unpin, then animfetch --pin)" + ); + return Ok(()); + } + Existing::Replace => {} + } + + let mut out = io::stdout(); + let Some(old) = end_owner(&mut out)? else { + return Ok(()); + }; + out.flush()?; + wait_for_exit(old); + + if !claim_terminal()? { + return Ok(()); + } } let (cols, rows) = term::size(); @@ -96,27 +129,48 @@ pub fn start(fetch: &Fetch<'_>) -> io::Result<()> { /// Stop the instance pinned to this terminal, if any. pub fn stop() -> io::Result { - let Some(pid) = live_owner()? else { + let mut stdout = io::stdout(); + if end_owner(&mut stdout)?.is_none() { return Ok(false); + } + + write!(stdout, "{}{}", term::CLEAR_SCREEN, term::move_to(1, 1))?; + stdout.flush()?; + Ok(true) +} + +/// Signal the pinned process, free the terminal for the next claim and take its +/// pictures and its scroll region away. Its pid, or `None` if nothing was there. +fn end_owner(out: &mut impl Write) -> io::Result> { + let Some(pid) = live_owner()? else { + return Ok(None); }; // SAFETY: `pid` came from our pid file and was just confirmed alive. unsafe { libc::kill(pid, libc::SIGTERM) }; let _ = std::fs::remove_file(pid_path()?); - let mut stdout = io::stdout(); let mut gone = String::new(); crate::border::clear_if_supported(&mut gone); write!( - stdout, - "{gone}{}{}{}", + out, + "{gone}{}{}", term::RESET_ORIGIN_MODE, - term::RESET_SCROLL_REGION, - term::CLEAR_SCREEN + term::RESET_SCROLL_REGION )?; - write!(stdout, "{}", term::move_to(1, 1))?; - stdout.flush()?; - Ok(true) + Ok(Some(pid)) +} + +/// Until `pid` is gone, so it cannot paint one more frame over its successor. +/// Bounded: a process that will not go is not worth hanging a shell for. +fn wait_for_exit(pid: libc::pid_t) { + for _ in 0..50 { + // SAFETY: signal 0 checks permission without delivering. + if unsafe { libc::kill(pid, 0) } != 0 { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } } fn animate(fetch: &Fetch<'_>, shell_pgid: libc::pid_t, shell_pid: libc::pid_t) -> io::Result<()> { diff --git a/src/prompt.rs b/src/prompt.rs index 7c8b495..222fe92 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -41,7 +41,7 @@ impl Prompt { return text.to_string(); } let mut out = String::new(); - color.fg(&mut out); + cfg.tone(color).fg(&mut out); out.push_str(text); out.push_str(RESET); out diff --git a/src/render/art.rs b/src/render/art.rs index 386e997..68a7859 100644 --- a/src/render/art.rs +++ b/src/render/art.rs @@ -30,7 +30,7 @@ pub fn paint_art_row( let t = ((y + offset) % span) as f32 / span as f32; if let Some(color) = cfg.gradient.sample(t) { - color.fg(out); + cfg.tone(color).fg(out); out.push_str(row); out.push_str(RESET); } else { diff --git a/src/render/info/boxed.rs b/src/render/info/boxed.rs index 65992a1..dd356db 100644 --- a/src/render/info/boxed.rs +++ b/src/render/info/boxed.rs @@ -70,7 +70,7 @@ pub fn rows<'a>(items: &'a [Item], cfg: &Config) -> Vec> { } // Swatches, if asked for, sit under the box like any other raw row. - if cfg.color { + if cfg.swatches() { rows.extend( items .iter() diff --git a/src/render/info/chips.rs b/src/render/info/chips.rs index 7314809..1e78339 100644 --- a/src/render/info/chips.rs +++ b/src/render/info/chips.rs @@ -168,7 +168,7 @@ pub fn rows<'a>(items: &'a [Item], cfg: &Config) -> Vec> { rows.extend(body); } - if cfg.color && items.iter().any(is_swatch) { + if cfg.swatches() && items.iter().any(is_swatch) { if !rows.is_empty() { rows.push(Row::Blank); } diff --git a/src/render/info/classic.rs b/src/render/info/classic.rs index 4e11bec..ca543da 100644 --- a/src/render/info/classic.rs +++ b/src/render/info/classic.rs @@ -30,7 +30,7 @@ pub fn rows<'a>(title: &'a str, items: &'a [Item], cfg: &Config) -> Vec> label: item.name(), value: &item.value, }); - } else if cfg.color { + } else if cfg.swatches() { rows.push(Row::Raw(&item.value)); } } diff --git a/src/render/info/iconic.rs b/src/render/info/iconic.rs index 4b5ca63..151d765 100644 --- a/src/render/info/iconic.rs +++ b/src/render/info/iconic.rs @@ -204,7 +204,7 @@ pub fn rows(title: &str, items: &[Item], cfg: &Config) -> Vec { rows.extend(lines(block)); } - if cfg.color && items.iter().any(is_swatch) { + if cfg.swatches() && items.iter().any(is_swatch) { rows.push(Row::Blank); rows.push(Row::Shapes); } diff --git a/src/render/info/sections.rs b/src/render/info/sections.rs index d3ef283..8ad7451 100644 --- a/src/render/info/sections.rs +++ b/src/render/info/sections.rs @@ -165,7 +165,7 @@ pub fn rows(items: &[Item], cfg: &Config) -> Vec { rows.extend(body); } - if cfg.color && items.iter().any(is_swatch) { + if cfg.swatches() && items.iter().any(is_swatch) { if !rows.is_empty() { rows.push(Row::Blank); } diff --git a/src/render/paint.rs b/src/render/paint.rs index 37528a6..0df00d5 100644 --- a/src/render/paint.rs +++ b/src/render/paint.rs @@ -67,7 +67,7 @@ impl<'a> Painter<'a> { fn fg(&mut self, color: Rgb) { if self.cfg.color { - color.fg(self.out); + self.cfg.tone(color).fg(self.out); } } @@ -132,7 +132,7 @@ impl<'a> Painter<'a> { self.out.push(CAP_LEFT); self.reset(); } - bg.bg(self.out); + self.cfg.tone(bg).bg(self.out); self.fg(fg); self.out.push(' '); self.out.push_str(text); @@ -168,7 +168,7 @@ impl<'a> Painter<'a> { /// A glyph in one of the terminal's own eight colours. Without colour it /// is the plain glyph, so colour being off never leaves an escape behind. pub fn mark(&mut self, code: u8, glyph: char) { - if self.cfg.color { + if self.cfg.swatches() { self.out.push_str(&format!("\x1b[3{code}m")); } self.out.push(glyph); @@ -231,7 +231,7 @@ impl<'a> Painter<'a> { #[cfg(test)] mod tests { use super::*; - use crate::render::test_util::plain_cfg; + use crate::render::test_util::{plain_cfg, strip_ansi}; fn colored() -> Config { Config { @@ -247,6 +247,36 @@ mod tests { } } + #[test] + fn grey_writes_the_same_text_with_no_hue_in_any_colour() { + let cfg = Config { + gray: true, + ..colored() + }; + let mut out = String::new(); + let mut p = Painter::new(&mut out, &cfg); + p.text(Rgb(255, 0, 0), "a"); + p.chip(Rgb(0, 0, 255), Rgb(0, 255, 0), "b", false); + p.mark(1, '●'); + + // Every colour that went out has equal red, green and blue. + let mut colours = 0; + for part in out.split("\x1b[").skip(1) { + let Some(params) = part.split('m').next() else { + continue; + }; + let n: Vec = params.split(';').filter_map(|v| v.parse().ok()).collect(); + if let [38 | 48, 2, r, g, b] = n[..] { + colours += 1; + assert!(r == g && g == b, "{params} has a hue in {out:?}"); + } + // And not one of the terminal's own palette colours. + assert!(!matches!(n[..], [30..=37]), "{out:?}"); + } + assert!(colours >= 3, "{out:?}"); + assert_eq!(strip_ansi(&out), "a b ●"); + } + #[test] fn colour_off_writes_text_and_nothing_else() { let cfg = uncolored(); diff --git a/src/render/pane.rs b/src/render/pane.rs index 0c2a675..fe7250a 100644 --- a/src/render/pane.rs +++ b/src/render/pane.rs @@ -2,6 +2,16 @@ use std::fmt::Write as _; use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +/// How long the pictures are left alone before they are sent again. +/// +/// A program that clears the screen, `clear` being the usual one, takes every +/// picture with it, and the terminal frees their data as well as their +/// placement. Nothing tells a background process that happened, so the +/// pictures are simply sent again now and then. About 55 KB, which a terminal +/// decodes without a visible cost, inside the frame's own synchronised write. +const PICTURE_REFRESH: Duration = Duration::from_secs(1); /// Incremental painter for the pinned pane. Rewrites only changed lines and /// leaves the cursor where it found it, since the user is typing below. @@ -14,10 +24,15 @@ pub struct Pane { /// Origin mode makes rows relative to the scroll region, which would put /// the art inside it. When set, each frame turns it off and back on. origin_mode: bool, - /// Escapes that put the border pictures on screen. Sent whenever the whole - /// pane is repainted, since a repaint may have followed a program that - /// cleared the screen. + /// Escapes that put the border pictures on screen: the pictures + /// themselves, then where they go. Sent whenever the whole pane is + /// repainted, since a repaint may have followed a program that cleared the + /// screen, and again every `refresh` for the same reason. overlay: String, + /// When the overlay last went out. + overlay_sent: Option, + /// How long the overlay is left before it goes out again. + refresh: Duration, } /// Save/restore cursor position and attributes (DECSC/DECRC). @@ -36,6 +51,8 @@ impl Pane { painted_h: 0, origin_mode: false, overlay: String::new(), + overlay_sent: None, + refresh: PICTURE_REFRESH, } } @@ -46,13 +63,20 @@ impl Pane { self } - /// The escapes that place the border pictures, sent with each full - /// repaint. Empty for none. + /// The escapes that send and place the border pictures, sent with each + /// full repaint and then every so often. Empty for none. pub fn set_overlay(&mut self, overlay: String) { self.overlay = overlay; self.needs_clear = true; } + /// Send the pictures again after this long without a full repaint. + #[cfg(test)] + fn with_picture_refresh(mut self, every: Duration) -> Self { + self.refresh = every; + self + } + pub fn has_overlay(&self) -> bool { !self.overlay.is_empty() } @@ -107,8 +131,10 @@ impl Pane { let _ = write!(buf, "\x1b[{};1H\x1b[2K", y + 1); } - if full { + let stale = self.overlay_sent.is_none_or(|at| at.elapsed() >= self.refresh); + if !self.overlay.is_empty() && (full || stale) { buf.push_str(&self.overlay); + self.overlay_sent = Some(Instant::now()); } if self.origin_mode { @@ -153,6 +179,46 @@ mod tests { assert!(String::from_utf8(buf).unwrap().contains("")); } + #[test] + fn the_pictures_are_sent_again_after_a_while_without_repainting_the_rows() { + // `clear` frees them in the terminal and nothing says it happened. + let mut pane = Pane::new().with_picture_refresh(Duration::ZERO); + pane.set_overlay("".to_string()); + let mut buf = Vec::new(); + pane.paint(&mut buf, &["a".into()], 4).unwrap(); + + buf.clear(); + pane.paint(&mut buf, &["a".into()], 4).unwrap(); + let written = String::from_utf8(buf).unwrap(); + assert!(written.contains(""), "{written:?}"); + // Only the pictures: the rows are not wiped and drawn again. + assert!(!written.contains("\x1b[2K"), "{written:?}"); + } + + #[test] + fn nothing_is_sent_again_while_the_refresh_is_not_due() { + let mut pane = Pane::new().with_picture_refresh(Duration::from_secs(3600)); + pane.set_overlay("".to_string()); + let mut buf = Vec::new(); + pane.paint(&mut buf, &["a".into()], 4).unwrap(); + + for _ in 0..3 { + buf.clear(); + pane.paint(&mut buf, &["a".into()], 4).unwrap(); + assert!(!String::from_utf8(buf.clone()).unwrap().contains("")); + } + } + + #[test] + fn a_pane_with_no_pictures_never_sends_any() { + let mut pane = Pane::new().with_picture_refresh(Duration::ZERO); + let mut buf = Vec::new(); + for _ in 0..2 { + pane.paint(&mut buf, &["a".into()], 4).unwrap(); + } + assert!(!String::from_utf8(buf).unwrap().contains("\x1b_G")); + } + #[test] fn the_pictures_go_inside_the_bracket_that_restores_the_cursor() { let mut pane = Pane::new().with_origin_mode();