1use std::fmt;
18use std::io::{self, Write};
19use std::path::Path;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use crossterm::terminal::SetTitle;
23use crossterm::Command;
24
25static PUSHED: AtomicBool = AtomicBool::new(false);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TitleMode {
34 Off,
36 Dir,
38 Path,
40}
41
42impl TitleMode {
43 pub fn parse(value: &str) -> Option<TitleMode> {
46 match value.trim().to_ascii_lowercase().as_str() {
47 "off" => Some(TitleMode::Off),
48 "dir" => Some(TitleMode::Dir),
49 "path" => Some(TitleMode::Path),
50 _ => None,
51 }
52 }
53}
54
55pub fn title_for(mode: TitleMode, cwd: &Path) -> Option<String> {
62 let place = match mode {
63 TitleMode::Off => return None,
64 TitleMode::Dir => cwd
65 .file_name()
66 .map(|name| name.to_string_lossy().into_owned())
67 .unwrap_or_else(|| crate::pathfmt::display(cwd)),
68 TitleMode::Path => crate::pathfmt::display(cwd),
69 };
70 let place: String = place.chars().filter(|c| !c.is_control()).collect();
71 Some(format!("trail {place}"))
72}
73
74#[derive(Debug, Default)]
77pub struct WindowTitle {
78 shown: Option<String>,
81}
82
83impl WindowTitle {
84 pub fn update(&mut self, out: &mut impl Write, mode: TitleMode, cwd: &Path) -> io::Result<()> {
91 let Some(title) = title_for(mode, cwd) else {
92 return self.restore(out);
93 };
94 if self.shown.as_ref() == Some(&title) {
95 return Ok(());
96 }
97 if !PUSHED.swap(true, Ordering::SeqCst) {
98 write_command(out, PushTitle)?;
99 }
100 write_command(out, SetTitle(&title))?;
101 out.flush()?;
102 self.shown = Some(title);
103 Ok(())
104 }
105
106 pub fn forget(&mut self) {
109 self.shown = None;
110 }
111
112 pub fn restore(&mut self, out: &mut impl Write) -> io::Result<()> {
118 self.shown = None;
119 restore_to(out)
120 }
121}
122
123pub fn restore_to(out: &mut impl Write) -> io::Result<()> {
129 if PUSHED.swap(false, Ordering::SeqCst) {
130 write_command(out, SetTitle(""))?;
131 write_command(out, PopTitle)?;
132 out.flush()?;
133 }
134 Ok(())
135}
136
137fn write_command(out: &mut impl Write, command: impl Command) -> io::Result<()> {
143 let mut ansi = String::new();
144 command
145 .write_ansi(&mut ansi)
146 .map_err(|_| io::Error::other("formatting a terminal command failed"))?;
147 out.write_all(ansi.as_bytes())
148}
149
150struct PushTitle;
152
153struct PopTitle;
155
156impl Command for PushTitle {
157 fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
158 f.write_str("\x1b[22;0t")
159 }
160
161 #[cfg(windows)]
164 fn execute_winapi(&self) -> io::Result<()> {
165 Ok(())
166 }
167}
168
169impl Command for PopTitle {
170 fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
171 f.write_str("\x1b[23;0t")
172 }
173
174 #[cfg(windows)]
175 fn execute_winapi(&self) -> io::Result<()> {
176 Ok(())
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::path::PathBuf;
184
185 #[test]
186 fn modes_parse_from_what_the_config_can_say() {
187 assert_eq!(TitleMode::parse("off"), Some(TitleMode::Off));
188 assert_eq!(TitleMode::parse(" Dir "), Some(TitleMode::Dir));
189 assert_eq!(TitleMode::parse("PATH"), Some(TitleMode::Path));
190 assert_eq!(TitleMode::parse("name"), None);
191 }
192
193 #[test]
194 fn the_title_names_the_directory_or_its_path() {
195 let cwd = PathBuf::from("projects").join("trail");
196 assert_eq!(
197 title_for(TitleMode::Dir, &cwd).as_deref(),
198 Some("trail trail")
199 );
200 assert_eq!(
201 title_for(TitleMode::Path, &cwd),
202 Some(format!("trail {}", cwd.display()))
203 );
204 assert_eq!(title_for(TitleMode::Off, &cwd), None);
205 }
206
207 #[test]
208 fn a_root_is_named_by_its_path() {
209 let root = PathBuf::from(if cfg!(windows) { "C:\\" } else { "/" });
210 assert_eq!(
211 title_for(TitleMode::Dir, &root),
212 Some(format!("trail {}", root.display()))
213 );
214 }
215
216 #[test]
217 fn control_characters_never_reach_the_terminal() {
218 let cwd = PathBuf::from("evil\x1b]0;pwned\x07name");
219 assert_eq!(
220 title_for(TitleMode::Dir, &cwd).as_deref(),
221 Some("trail evil]0;pwnedname")
222 );
223 }
224
225 #[test]
226 fn the_title_is_written_once_per_change_and_restored() {
227 let mut title = WindowTitle::default();
228 let mut out = Vec::new();
229 let here = PathBuf::from("here");
230
231 title.update(&mut out, TitleMode::Dir, &here).unwrap();
232 let first = String::from_utf8(out.clone()).unwrap();
233 assert!(
234 first.starts_with("\x1b[22;0t"),
235 "saves the shell's title: {first:?}"
236 );
237 assert!(first.contains("trail here"), "{first:?}");
238
239 out.clear();
240 title.update(&mut out, TitleMode::Dir, &here).unwrap();
241 assert!(out.is_empty(), "an unchanged title is not rewritten");
242
243 title.forget();
244 title.update(&mut out, TitleMode::Dir, &here).unwrap();
245 let again = String::from_utf8(out.clone()).unwrap();
246 assert!(
247 again.contains("trail here") && !again.contains("\x1b[22"),
248 "{again:?}"
249 );
250
251 out.clear();
252 title.update(&mut out, TitleMode::Off, &here).unwrap();
253 assert_eq!(
255 String::from_utf8(out.clone()).unwrap(),
256 "\x1b]0;\x07\x1b[23;0t"
257 );
258
259 out.clear();
260 title.update(&mut out, TitleMode::Off, &here).unwrap();
261 assert!(
262 out.is_empty(),
263 "off with nothing to give back writes nothing"
264 );
265 }
266}