Skip to main content

trail/
window_title.rs

1//! The terminal window or tab title, so the tab Trail is running in can be
2//! told apart from the others.
3//!
4//! Set with OSC 0 (crossterm's [`SetTitle`]), which terminals show as the tab
5//! or window title, and kept as `trail <directory>` while Trail runs, in the
6//! form `[general] terminal_title` asks for. The title the shell had is saved
7//! on the terminal's title stack (XTWINOPS 22/23) before the first change and
8//! restored from it on exit, or when `terminal_title` is set to `off`.
9//!
10//! Before that restore Trail blanks the title, because a terminal without a
11//! title stack ignores the restore — ConPTY, which every Windows terminal
12//! reaches the console through, is one. There the tab is left without a title
13//! rather than frozen on a directory Trail is no longer reporting, until the
14//! shell's prompt sets its own; the PowerShell wrapper restores the shell's
15//! title itself as soon as Trail exits.
16
17use 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
25/// Whether this process has pushed a title it still owes the terminal.
26///
27/// Process-wide so the panic hook, which cannot reach the event loop's
28/// [`WindowTitle`], can restore the title too.
29static PUSHED: AtomicBool = AtomicBool::new(false);
30
31/// What the title names, from `[general] terminal_title`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TitleMode {
34    /// Leave the title alone.
35    Off,
36    /// `trail <directory name>`.
37    Dir,
38    /// `trail <full path>`.
39    Path,
40}
41
42impl TitleMode {
43    /// Parses a `[general] terminal_title` value: `off`, `dir` or `path`,
44    /// ignoring case and surrounding space. `None` for anything else.
45    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
55/// The title for `cwd` in `mode`, or `None` when the title is left alone.
56///
57/// A directory with no name of its own — a drive or filesystem root — is
58/// named by its full path in either mode. Control characters are dropped: a
59/// directory name carrying an escape sequence must not be able to write to
60/// the terminal.
61pub 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/// Keeps the terminal title in step with Trail's directory, writing only
75/// when the title changes.
76#[derive(Debug, Default)]
77pub struct WindowTitle {
78    /// The title last written, `None` when none is shown or it must be
79    /// written again.
80    shown: Option<String>,
81}
82
83impl WindowTitle {
84    /// Writes the title for `cwd` in `mode` to `out` if it differs from the
85    /// one shown, saving the shell's title first. Switching to
86    /// [`TitleMode::Off`] gives the shell's title back.
87    ///
88    /// # Errors
89    /// Any error writing to `out`.
90    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    /// Marks the title as needing to be written again — after an editor or
107    /// shell command has had the terminal and may have set its own.
108    pub fn forget(&mut self) {
109        self.shown = None;
110    }
111
112    /// Gives the title back, if Trail changed it: blanks it, then restores
113    /// the shell's title where the terminal kept it (see the module docs).
114    ///
115    /// # Errors
116    /// Any error writing to `out`.
117    pub fn restore(&mut self, out: &mut impl Write) -> io::Result<()> {
118        self.shown = None;
119        restore_to(out)
120    }
121}
122
123/// Gives the title back as [`WindowTitle::restore`] does, for the panic hook.
124/// Does nothing if Trail never changed it.
125///
126/// # Errors
127/// Any error writing to `out`.
128pub 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
137/// Writes `command`'s escape sequence to `out`.
138///
139/// Always the ANSI form, never crossterm's Windows-console fallback: every
140/// console Trail draws in already takes ANSI, since that is how ratatui draws,
141/// and the fallback would write nothing a test could see.
142fn 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
150/// XTWINOPS 22: save the window and icon titles on the terminal's stack.
151struct PushTitle;
152
153/// XTWINOPS 23: restore the titles saved by [`PushTitle`].
154struct 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    // A legacy Windows console has no title stack; the PowerShell wrapper
162    // restores the title there instead.
163    #[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        // Blanked first, for a terminal that ignores the restore after it.
254        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}