use std::fmt;
use std::io::{self, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use crossterm::terminal::SetTitle;
use crossterm::Command;
static PUSHED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TitleMode {
Off,
Dir,
Path,
}
impl TitleMode {
pub fn parse(value: &str) -> Option<TitleMode> {
match value.trim().to_ascii_lowercase().as_str() {
"off" => Some(TitleMode::Off),
"dir" => Some(TitleMode::Dir),
"path" => Some(TitleMode::Path),
_ => None,
}
}
}
pub fn title_for(mode: TitleMode, cwd: &Path) -> Option<String> {
let place = match mode {
TitleMode::Off => return None,
TitleMode::Dir => cwd
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| crate::pathfmt::display(cwd)),
TitleMode::Path => crate::pathfmt::display(cwd),
};
let place: String = place.chars().filter(|c| !c.is_control()).collect();
Some(format!("trail {place}"))
}
#[derive(Debug, Default)]
pub struct WindowTitle {
shown: Option<String>,
}
impl WindowTitle {
pub fn update(&mut self, out: &mut impl Write, mode: TitleMode, cwd: &Path) -> io::Result<()> {
let Some(title) = title_for(mode, cwd) else {
return self.restore(out);
};
if self.shown.as_ref() == Some(&title) {
return Ok(());
}
if !PUSHED.swap(true, Ordering::SeqCst) {
write_command(out, PushTitle)?;
}
write_command(out, SetTitle(&title))?;
out.flush()?;
self.shown = Some(title);
Ok(())
}
pub fn forget(&mut self) {
self.shown = None;
}
pub fn restore(&mut self, out: &mut impl Write) -> io::Result<()> {
self.shown = None;
restore_to(out)
}
}
pub fn restore_to(out: &mut impl Write) -> io::Result<()> {
if PUSHED.swap(false, Ordering::SeqCst) {
write_command(out, SetTitle(""))?;
write_command(out, PopTitle)?;
out.flush()?;
}
Ok(())
}
fn write_command(out: &mut impl Write, command: impl Command) -> io::Result<()> {
let mut ansi = String::new();
command
.write_ansi(&mut ansi)
.map_err(|_| io::Error::other("formatting a terminal command failed"))?;
out.write_all(ansi.as_bytes())
}
struct PushTitle;
struct PopTitle;
impl Command for PushTitle {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
f.write_str("\x1b[22;0t")
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
}
impl Command for PopTitle {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
f.write_str("\x1b[23;0t")
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn modes_parse_from_what_the_config_can_say() {
assert_eq!(TitleMode::parse("off"), Some(TitleMode::Off));
assert_eq!(TitleMode::parse(" Dir "), Some(TitleMode::Dir));
assert_eq!(TitleMode::parse("PATH"), Some(TitleMode::Path));
assert_eq!(TitleMode::parse("name"), None);
}
#[test]
fn the_title_names_the_directory_or_its_path() {
let cwd = PathBuf::from("projects").join("trail");
assert_eq!(
title_for(TitleMode::Dir, &cwd).as_deref(),
Some("trail trail")
);
assert_eq!(
title_for(TitleMode::Path, &cwd),
Some(format!("trail {}", cwd.display()))
);
assert_eq!(title_for(TitleMode::Off, &cwd), None);
}
#[test]
fn a_root_is_named_by_its_path() {
let root = PathBuf::from(if cfg!(windows) { "C:\\" } else { "/" });
assert_eq!(
title_for(TitleMode::Dir, &root),
Some(format!("trail {}", root.display()))
);
}
#[test]
fn control_characters_never_reach_the_terminal() {
let cwd = PathBuf::from("evil\x1b]0;pwned\x07name");
assert_eq!(
title_for(TitleMode::Dir, &cwd).as_deref(),
Some("trail evil]0;pwnedname")
);
}
#[test]
fn the_title_is_written_once_per_change_and_restored() {
let mut title = WindowTitle::default();
let mut out = Vec::new();
let here = PathBuf::from("here");
title.update(&mut out, TitleMode::Dir, &here).unwrap();
let first = String::from_utf8(out.clone()).unwrap();
assert!(
first.starts_with("\x1b[22;0t"),
"saves the shell's title: {first:?}"
);
assert!(first.contains("trail here"), "{first:?}");
out.clear();
title.update(&mut out, TitleMode::Dir, &here).unwrap();
assert!(out.is_empty(), "an unchanged title is not rewritten");
title.forget();
title.update(&mut out, TitleMode::Dir, &here).unwrap();
let again = String::from_utf8(out.clone()).unwrap();
assert!(
again.contains("trail here") && !again.contains("\x1b[22"),
"{again:?}"
);
out.clear();
title.update(&mut out, TitleMode::Off, &here).unwrap();
assert_eq!(
String::from_utf8(out.clone()).unwrap(),
"\x1b]0;\x07\x1b[23;0t"
);
out.clear();
title.update(&mut out, TitleMode::Off, &here).unwrap();
assert!(
out.is_empty(),
"off with nothing to give back writes nothing"
);
}
}