use std::io::{self, IsTerminal, Write};
use std::time::Duration;
use ratatui::crossterm::event::{DisableFocusChange, EnableFocusChange};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, SetTitle};
use super::state::{View, percent};
use crate::size::human;
const NOTIFY_AFTER: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Title {
push: &'static str,
pop: &'static str,
}
pub const XTERM_STACK: Title = Title {
push: "\x1b[22;2t",
pop: "\x1b[23;2t",
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Decor {
pub sync: bool,
pub title: Option<Title>,
pub progress: bool,
pub notify: Option<Notify>,
pub graphics: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Notify {
Osc9,
Osc777,
}
struct Known {
program: &'static str,
term: &'static str,
decor: Decor,
}
impl Known {
fn names(&self, program: &str, term: &str) -> bool {
(!self.program.is_empty() && self.program == program)
|| (!self.term.is_empty() && self.term == term)
}
}
const KNOWN: &[Known] = &[
Known {
program: "ghostty",
term: "xterm-ghostty",
decor: Decor {
sync: true,
title: Some(XTERM_STACK),
progress: true,
notify: Some(Notify::Osc9),
graphics: true,
},
},
Known {
program: "WezTerm",
term: "wezterm",
decor: Decor {
sync: true,
title: Some(XTERM_STACK),
progress: true,
notify: Some(Notify::Osc9),
graphics: true,
},
},
Known {
program: "iTerm.app",
term: "",
decor: Decor {
sync: true,
title: Some(XTERM_STACK),
progress: false,
notify: Some(Notify::Osc9),
graphics: false,
},
},
Known {
program: "",
term: "xterm-kitty",
decor: Decor {
sync: true,
title: Some(XTERM_STACK),
progress: false,
notify: None,
graphics: true,
},
},
Known {
program: "Apple_Terminal",
term: "",
decor: Decor {
sync: true,
title: None,
progress: false,
notify: None,
graphics: false,
},
},
];
impl Decor {
#[must_use]
pub fn detect() -> Self {
if !io::stdout().is_terminal() {
return Self::default();
}
Self::read(&|key| std::env::var(key).ok())
}
#[must_use]
pub fn silent() -> Self {
Self::default()
}
fn read(env: &dyn Fn(&str) -> Option<String>) -> Self {
let term = env("TERM").unwrap_or_default();
if term.is_empty() || term == "dumb" {
return Self::silent();
}
let anonymous = Self {
sync: true,
..Self::silent()
};
if term.starts_with("screen") || term.starts_with("tmux") {
return anonymous;
}
let program = env("TERM_PROGRAM").unwrap_or_default();
if let Some(known) = KNOWN.iter().find(|known| known.names(&program, &term)) {
return known.decor;
}
if env("WT_SESSION").is_some() || env("ConEmuANSI").is_some() {
return Self {
progress: true,
..anonymous
};
}
anonymous
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Deleting(u8),
Pricing(u8),
Scanning(u64),
Freed(u64),
Idle(u64),
}
impl Status {
#[must_use]
pub fn of(view: &View, freed: u64) -> Self {
let total = view.total();
if let Some(removing) = view.removing() {
return Self::Deleting(removing.percent());
}
if view.is_scanning() {
let priced = total.claims - total.unpriced;
return match (total.unpriced, total.claims) {
(0, _) | (_, 0) => Self::Scanning(total.bytes),
(_, claims) => Self::Pricing(percent(priced, claims)),
};
}
if freed > 0 {
return Self::Freed(freed);
}
Self::Idle(total.bytes)
}
fn title(&self) -> String {
match self {
Self::Deleting(percent) => format!("pristine — deleting {percent}%"),
Self::Pricing(percent) => format!("pristine — pricing {percent}%"),
Self::Scanning(bytes) | Self::Idle(bytes) => format!("pristine — {}", human(*bytes)),
Self::Freed(bytes) => format!("pristine — freed {}", human(*bytes)),
}
}
fn bar(&self) -> Bar {
match self {
Self::Scanning(_) => Bar::Working,
Self::Deleting(percent) | Self::Pricing(percent) => Bar::At(*percent),
Self::Freed(_) | Self::Idle(_) => Bar::Off,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Bar {
Off,
Working,
At(u8),
Failed,
}
impl Bar {
fn code(self) -> (u8, u8) {
match self {
Self::Off => (0, 0),
Self::Working => (3, 0),
Self::At(percent) => (1, percent),
Self::Failed => (2, 100),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Here,
Away,
}
#[derive(Debug)]
pub struct Chrome<W: Write> {
out: W,
decor: Decor,
title: Option<String>,
bar: Option<Bar>,
entered: bool,
framing: bool,
focus: Focus,
failed: bool,
}
impl<W: Write> Chrome<W> {
pub fn new(out: W, decor: Decor) -> Self {
Self {
out,
decor,
title: None,
bar: None,
entered: false,
framing: false,
focus: Focus::Here,
failed: false,
}
}
pub fn enter(&mut self) -> io::Result<()> {
self.entered = true;
if let Some(title) = self.decor.title {
self.put(title.push)?;
}
if self.decor.notify.is_some() {
execute!(self.out, EnableFocusChange)?;
}
Ok(())
}
pub fn begin_frame(&mut self) -> io::Result<()> {
if !self.decor.sync {
return Ok(());
}
self.framing = true;
execute!(self.out, BeginSynchronizedUpdate)
}
pub fn end_frame(&mut self) -> io::Result<()> {
if !std::mem::take(&mut self.framing) {
return Ok(());
}
execute!(self.out, EndSynchronizedUpdate)
}
pub fn show(&mut self, status: Status) -> io::Result<()> {
if self.decor.title.is_some() {
let title = status.title();
if self.title.as_ref() != Some(&title) {
execute!(self.out, SetTitle(text(&title)))?;
self.title = Some(title);
}
}
self.bar(status.bar())
}
fn bar(&mut self, bar: Bar) -> io::Result<()> {
if !self.decor.progress || self.bar == Some(bar) {
return Ok(());
}
let (state, percent) = bar.code();
self.put(&format!("\x1b]9;4;{state};{percent}\x07"))?;
self.bar = Some(bar);
Ok(())
}
pub fn focused(&mut self, here: bool) {
self.focus = if here { Focus::Here } else { Focus::Away };
}
pub fn announce(&mut self, body: &str, took: Duration) -> io::Result<()> {
if self.focus == Focus::Here || took < NOTIFY_AFTER {
return Ok(());
}
match self.decor.notify {
None => Ok(()),
Some(Notify::Osc9) => self.put(&format!("\x1b]9;pristine: {}\x07", text(body))),
Some(Notify::Osc777) => {
self.put(&format!("\x1b]777;notify;pristine;{}\x07", text(body)))
}
}
}
pub fn failed(&mut self) {
self.failed = true;
}
pub fn restore(&mut self) -> io::Result<()> {
let mut first = self.end_frame();
if !std::mem::take(&mut self.entered) {
return first;
}
let bar = if self.failed { Bar::Failed } else { Bar::Off };
first = first.and(self.bar(bar));
if self.decor.notify.is_some() {
first = first.and(execute!(self.out, DisableFocusChange));
}
if let Some(title) = self.decor.title {
first = first.and(self.put(title.pop));
}
first
}
fn put(&mut self, sequence: &str) -> io::Result<()> {
self.out.write_all(sequence.as_bytes())?;
self.out.flush()
}
#[cfg(test)]
pub(crate) fn sink(&self) -> &W {
&self.out
}
}
fn text(said: &str) -> String {
said.chars().filter(|c| !c.is_control()).collect()
}
#[cfg(test)]
mod tests {
use super::{Chrome, Decor, Notify, Status, XTERM_STACK, text};
use crate::fixture::{hit, priced};
use crate::size::Size;
use crate::tree::Tree;
use crate::tui::keymap::{Action, Turn};
use crate::tui::state::{Planned, View};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
fn everything() -> Decor {
Decor {
sync: true,
title: Some(XTERM_STACK),
progress: true,
notify: Some(Notify::Osc9),
graphics: true,
}
}
fn chrome(decor: Decor) -> Chrome<Vec<u8>> {
Chrome::new(Vec::new(), decor)
}
fn written(chrome: &Chrome<Vec<u8>>) -> String {
String::from_utf8(chrome.sink().clone()).unwrap()
}
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |key: &str| map.get(key).cloned()
}
fn view() -> View {
View::new(Tree::new("/scan"))
}
#[test]
fn a_terminal_that_reads_nothing_is_written_nothing() {
let mut chrome = chrome(Decor::silent());
chrome.enter().unwrap();
chrome.begin_frame().unwrap();
chrome.show(Status::Pricing(41)).unwrap();
chrome.end_frame().unwrap();
chrome.focused(false);
chrome.announce("done", Duration::from_secs(60)).unwrap();
chrome.failed();
chrome.restore().unwrap();
assert_eq!(written(&chrome), "", "an escape reached a pipe");
}
#[test]
fn every_frame_is_wrapped_in_a_synchronized_update() {
let mut chrome = chrome(everything());
chrome.begin_frame().unwrap();
chrome.end_frame().unwrap();
assert_eq!(written(&chrome), "\x1b[?2026h\x1b[?2026l");
}
#[test]
fn a_frame_left_open_is_closed_by_the_restore() {
let mut chrome = chrome(everything());
chrome.enter().unwrap();
chrome.begin_frame().unwrap();
chrome.restore().unwrap();
let said = written(&chrome);
assert!(
said.contains("\x1b[?2026l"),
"the frame was left open: {said:?}"
);
assert_eq!(said.matches("\x1b[?2026l").count(), 1);
}
#[test]
fn the_title_is_written_once_per_change() {
let mut chrome = chrome(everything());
chrome.show(Status::Idle(1024)).unwrap();
chrome.show(Status::Idle(1024)).unwrap();
assert_eq!(written(&chrome).matches("\x1b]0;").count(), 1);
chrome.show(Status::Freed(2048)).unwrap();
let said = written(&chrome);
assert_eq!(said.matches("\x1b]0;").count(), 2);
assert!(said.contains("pristine — freed 2.0 KiB"), "{said:?}");
}
#[test]
fn the_title_is_put_back_on_the_way_out_and_only_once() {
let mut chrome = chrome(everything());
chrome.enter().unwrap();
chrome.show(Status::Scanning(0)).unwrap();
chrome.restore().unwrap();
chrome.restore().unwrap();
let said = written(&chrome);
assert_eq!(said.matches("\x1b[22;2t").count(), 1, "{said:?}");
assert_eq!(said.matches("\x1b[23;2t").count(), 1, "{said:?}");
}
#[test]
fn pricing_reports_a_percentage_and_the_end_of_a_run_takes_the_bar_away() {
let mut chrome = chrome(everything());
chrome.enter().unwrap();
chrome.show(Status::Scanning(10)).unwrap();
assert!(
written(&chrome).contains("\x1b]9;4;3;0\x07"),
"indeterminate"
);
chrome.show(Status::Pricing(41)).unwrap();
assert!(written(&chrome).contains("\x1b]9;4;1;41\x07"));
chrome.restore().unwrap();
assert!(written(&chrome).ends_with("\x1b[23;2t"));
assert!(
written(&chrome).contains("\x1b]9;4;0;0\x07"),
"the bar was left up"
);
}
#[test]
fn a_run_that_ends_with_failures_leaves_the_bar_saying_so() {
let mut chrome = chrome(everything());
chrome.enter().unwrap();
chrome.failed();
chrome.restore().unwrap();
assert!(written(&chrome).contains("\x1b]9;4;2;100\x07"));
}
#[test]
fn a_terminal_that_does_not_read_the_bar_is_not_sent_one() {
let mut chrome = chrome(Decor {
progress: false,
..everything()
});
chrome.enter().unwrap();
chrome.show(Status::Pricing(41)).unwrap();
chrome.restore().unwrap();
let said = written(&chrome);
assert!(!said.contains("\x1b]9;4"), "{said:?}");
assert!(said.contains("pristine — pricing 41%"));
}
#[test]
fn a_terminal_that_cannot_hand_a_title_back_is_never_given_one() {
let mut chrome = chrome(Decor {
title: None,
..everything()
});
chrome.enter().unwrap();
chrome.show(Status::Freed(2048)).unwrap();
chrome.restore().unwrap();
let said = written(&chrome);
assert!(!said.contains("\x1b]0;"), "a title was set: {said:?}");
assert!(
!said.contains("22;2t") && !said.contains("23;2t"),
"{said:?}"
);
assert!(said.contains("\x1b]9;4;0;0\x07"));
}
#[test]
fn a_notification_waits_for_a_run_worth_interrupting_somebody_for() {
let mut chrome = chrome(everything());
chrome.focused(false);
chrome
.announce("scanned", Duration::from_millis(200))
.unwrap();
assert_eq!(written(&chrome), "", "a 200 ms scan raised a notification");
chrome.announce("scanned", Duration::from_secs(60)).unwrap();
assert_eq!(written(&chrome), "\x1b]9;pristine: scanned\x07");
}
#[test]
fn a_reader_who_is_watching_is_not_notified() {
let mut chrome = chrome(everything());
chrome.announce("scanned", Duration::from_secs(60)).unwrap();
assert_eq!(written(&chrome), "");
chrome.focused(false);
chrome.announce("scanned", Duration::from_secs(60)).unwrap();
assert!(written(&chrome).contains("\x1b]9;pristine: scanned\x07"));
chrome.focused(true);
let before = written(&chrome).len();
chrome.announce("more", Duration::from_secs(60)).unwrap();
assert_eq!(written(&chrome).len(), before, "notified after coming back");
}
#[test]
fn the_other_spelling_of_a_notification() {
let mut chrome = chrome(Decor {
notify: Some(Notify::Osc777),
..everything()
});
chrome.focused(false);
chrome
.announce("freed 2.0 KiB", Duration::from_secs(60))
.unwrap();
assert_eq!(
written(&chrome),
"\x1b]777;notify;pristine;freed 2.0 KiB\x07"
);
}
#[test]
fn focus_reporting_is_only_asked_for_when_it_would_answer_something() {
let mut asked = chrome(everything());
asked.enter().unwrap();
asked.restore().unwrap();
assert!(written(&asked).contains("\x1b[?1004h"));
assert!(
written(&asked).contains("\x1b[?1004l"),
"left reporting focus"
);
let mut quiet = chrome(Decor {
notify: None,
..everything()
});
quiet.enter().unwrap();
quiet.restore().unwrap();
assert!(!written(&quiet).contains("1004"));
}
#[test]
fn what_the_view_is_doing_decides_what_the_tab_says() {
let mut view = view();
assert_eq!(Status::of(&view, 0), Status::Scanning(0));
view.found(hit("/scan/a/node_modules", Size::Unmeasured, 0));
view.found(priced("/scan/b/target", 2048));
view.sync();
assert_eq!(Status::of(&view, 0), Status::Pricing(50));
view.priced(
std::path::Path::new("/scan/a/node_modules"),
Size::Measured(1024),
);
view.sync();
assert_eq!(Status::of(&view, 0), Status::Scanning(3072));
view.scanned();
assert_eq!(Status::of(&view, 0), Status::Idle(3072));
assert_eq!(Status::of(&view, 4096), Status::Freed(4096));
view.deleting_for_test();
assert_eq!(Status::of(&view, 4096), Status::Deleting(0));
}
#[test]
fn a_removal_reports_where_it_has_got_to_rather_than_only_that_it_is_running() {
let mut view = view();
view.found(priced("/scan/a/node_modules", 1024));
view.found(priced("/scan/b/node_modules", 1024));
view.found(priced("/scan/c/node_modules", 1024));
view.found(priced("/scan/d/node_modules", 1024));
view.scanned();
view.asking(
&["a", "b", "c", "d"]
.iter()
.map(|name| {
Planned::at(
PathBuf::from(format!("/scan/{name}/node_modules")),
Size::Measured(1024),
)
})
.collect::<Vec<_>>(),
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
assert_eq!(Status::of(&view, 0), Status::Deleting(0));
assert_eq!(Status::of(&view, 0).bar().code(), (1, 0));
view.removed(std::path::Path::new("/scan/a/node_modules"), 1024, true);
view.swept(std::path::Path::new("/scan/a/node_modules"));
assert_eq!(Status::of(&view, 0), Status::Deleting(25));
view.removed(std::path::Path::new("/scan/b/node_modules"), 512, false);
view.swept(std::path::Path::new("/scan/b/node_modules"));
view.swept(std::path::Path::new("/scan/c/node_modules"));
assert_eq!(Status::of(&view, 0), Status::Deleting(75));
assert_eq!(Status::of(&view, 0).bar().code(), (1, 75));
view.deleted(crate::tui::state::Notice::standing("freed 1.5 KiB"), 1536);
assert_eq!(Status::of(&view, 1536), Status::Freed(1536));
assert_eq!(Status::of(&view, 1536).bar().code(), (0, 0));
}
#[test]
fn an_unpriced_scan_is_indeterminate_rather_than_stuck_at_zero() {
let mut view = view();
for n in 0..4 {
view.found(hit(
&format!("/scan/p{n}/node_modules"),
Size::Unmeasured,
0,
));
}
view.sync();
assert_eq!(Status::of(&view, 0), Status::Pricing(0));
view.scanned();
assert_eq!(Status::of(&view, 0), Status::Idle(0));
}
#[test]
fn a_dumb_terminal_gets_nothing_and_an_unknown_one_gets_only_what_leaves_nothing_behind() {
assert_eq!(Decor::read(&env(&[("TERM", "dumb")])), Decor::silent());
assert_eq!(Decor::read(&env(&[])), Decor::silent());
assert_eq!(
Decor::read(&env(&[("TERM", "xterm-256color")])),
Decor {
sync: true,
title: None,
progress: false,
notify: None,
graphics: false,
}
);
}
#[test]
fn a_multiplexer_is_not_the_terminal_named_in_the_environment() {
for term in ["screen-256color", "tmux-256color"] {
assert_eq!(
Decor::read(&env(&[("TERM", term), ("TERM_PROGRAM", "ghostty")])),
Decor {
sync: true,
..Decor::silent()
},
"{term} was taken for the terminal that started it"
);
}
}
#[test]
fn every_terminal_offered_a_title_is_offered_the_way_to_put_it_back() {
for known in super::KNOWN {
if let Some(title) = known.decor.title {
assert!(
!title.push.is_empty() && !title.pop.is_empty(),
"{} sets a title it cannot put back",
known.program
);
}
}
}
#[test]
fn the_terminals_that_are_known_get_what_they_are_known_to_read() {
let ghostty = Decor::read(&env(&[
("TERM", "xterm-ghostty"),
("TERM_PROGRAM", "ghostty"),
]));
assert!(ghostty.progress && ghostty.notify == Some(Notify::Osc9));
assert_eq!(ghostty.title, Some(XTERM_STACK));
let kitty = Decor::read(&env(&[("TERM", "xterm-kitty")]));
assert_eq!(kitty.title, Some(XTERM_STACK));
assert!(!kitty.progress);
assert!(kitty.graphics, "the terminal the protocol is named after");
assert!(
!Decor::read(&env(&[
("TERM_PROGRAM", "iTerm.app"),
("TERM", "xterm-256color")
]))
.graphics
);
let iterm = Decor::read(&env(&[
("TERM", "xterm-256color"),
("TERM_PROGRAM", "iTerm.app"),
]));
assert!(
!iterm.progress,
"a progress report would arrive as a pop-up"
);
assert_eq!(iterm.notify, Some(Notify::Osc9));
let wt = Decor::read(&env(&[("TERM", "xterm-256color"), ("WT_SESSION", "…")]));
assert!(wt.progress);
assert_eq!(wt.notify, None);
assert_eq!(wt.title, None);
let apple = Decor::read(&env(&[
("TERM", "xterm-256color"),
("TERM_PROGRAM", "Apple_Terminal"),
]));
assert!(!apple.progress);
assert_eq!(apple.notify, None);
assert_eq!(apple.title, None);
}
#[test]
fn nothing_interpolated_can_end_the_sequence_it_is_inside() {
assert_eq!(text("node_modules\x07;rm -rf /"), "node_modules;rm -rf /");
assert_eq!(text("a\x1b]0;b"), "a]0;b");
}
}