Skip to main content

gitu/
term.rs

1use crate::style::{Color, Modifier, Style};
2use crate::{Res, config::Config, error::Error};
3use crossterm::{
4    QueueableCommand,
5    cursor::{self, MoveTo},
6    event::{DisableMouseCapture, EnableMouseCapture, Event},
7    style::{Attribute, Colors, Print, ResetColor, SetAttribute, SetColors},
8    terminal::{
9        Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
10        enable_raw_mode,
11    },
12};
13use std::io::{self, Stdout, Write, stdout};
14use std::time::Duration;
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18pub type Term = TermBackend;
19
20pub fn backend() -> TermBackend {
21    TermBackend::Crossterm(stdout())
22}
23
24pub enum TermBackend {
25    Crossterm(Stdout),
26    #[allow(dead_code)]
27    Test {
28        buffer: TestBuffer,
29        events: Vec<Event>,
30    },
31}
32
33#[derive(Clone, PartialEq)]
34pub struct TestCell {
35    pub symbol: String,
36    /// The OSC-8 link the cell was printed inside, if any.
37    pub link: Option<String>,
38    pub fg: Color,
39    pub bg: Color,
40    pub modifier: Modifier,
41}
42
43impl Default for TestCell {
44    fn default() -> Self {
45        TestCell {
46            symbol: " ".to_string(),
47            link: None,
48            fg: Color::Reset,
49            bg: Color::Reset,
50            modifier: Modifier::empty(),
51        }
52    }
53}
54
55impl TestCell {
56    fn set(&mut self, symbol: &str, style: &Style) {
57        self.symbol.clear();
58        self.symbol.push_str(symbol);
59
60        if let Some(fg) = style.fg {
61            self.fg = fg;
62        }
63        if let Some(bg) = style.bg {
64            self.bg = bg;
65        }
66        self.modifier.insert(style.add_modifier);
67        self.modifier.remove(style.sub_modifier);
68    }
69}
70
71pub struct TestBuffer {
72    pub cells: Vec<TestCell>,
73    pub width: u16,
74    pub height: u16,
75    cursor: (u16, u16),
76    /// The link printing is currently inside, if any.
77    link: Option<String>,
78}
79
80impl TestBuffer {
81    pub fn new(width: u16, height: u16) -> Self {
82        TestBuffer {
83            cells: vec![TestCell::default(); width as usize * height as usize],
84            width,
85            height,
86            cursor: (0, 0),
87            link: None,
88        }
89    }
90
91    fn clear(&mut self) {
92        self.cells.fill(TestCell::default());
93    }
94
95    fn cell_mut(&mut self, x: u16, y: u16) -> Option<&mut TestCell> {
96        if x >= self.width || y >= self.height {
97            return None;
98        }
99
100        self.cells
101            .get_mut(y as usize * self.width as usize + x as usize)
102    }
103
104    fn print(&mut self, text: &str, style: &Style) {
105        let (mut x, y) = self.cursor;
106
107        for grapheme in text.graphemes(true) {
108            let grapheme_width = grapheme.width() as u16;
109            if grapheme_width == 0 || x >= self.width {
110                continue;
111            }
112
113            let link = self.link.clone();
114            if let Some(cell) = self.cell_mut(x, y) {
115                *cell = TestCell::default();
116                cell.set(grapheme, style);
117                cell.link = link;
118            }
119            x += 1;
120
121            for _ in 1..grapheme_width {
122                if x >= self.width {
123                    break;
124                }
125
126                if let Some(cell) = self.cell_mut(x, y) {
127                    *cell = TestCell::default();
128                }
129                x += 1;
130            }
131        }
132
133        self.cursor = (x, y);
134    }
135}
136
137impl TermBackend {
138    pub(crate) fn queue_move_cursor(&mut self, x: u16, y: u16) -> Res<()> {
139        match self {
140            TermBackend::Crossterm(t) => crossterm::queue!(t, MoveTo(x, y)).map_err(Error::Term),
141            TermBackend::Test { buffer, .. } => {
142                buffer.cursor = (x, y);
143                Ok(())
144            }
145        }
146    }
147
148    /// Open or close an OSC-8 hyperlink around what is printed next. The
149    /// sequence takes up no columns, so the test terminal records only that the
150    /// link is open.
151    pub(crate) fn queue_link(&mut self, uri: Option<&str>) -> Res<()> {
152        match self {
153            TermBackend::Crossterm(t) => {
154                let uri = uri.unwrap_or("");
155                crossterm::queue!(t, Print(format!("\x1b]8;;{uri}\x1b\\"))).map_err(Error::Term)
156            }
157            TermBackend::Test { buffer, .. } => {
158                buffer.link = uri.map(str::to_owned);
159                Ok(())
160            }
161        }
162    }
163
164    pub(crate) fn queue_newline(&mut self) -> Res<()> {
165        match self {
166            TermBackend::Crossterm(t) => {
167                crossterm::queue!(t, ResetColor, SetAttribute(Attribute::Reset), Print("\n"))
168                    .map_err(Error::Term)
169            }
170            TermBackend::Test { buffer, .. } => {
171                buffer.cursor = (0, buffer.cursor.1 + 1);
172                Ok(())
173            }
174        }
175    }
176
177    pub fn queue_print(&mut self, text: &str, style: &Style) -> Res<()> {
178        match self {
179            TermBackend::Crossterm(t) => {
180                print_crossterm_span(text, style, t).map_err(Error::Term)?;
181
182                Ok(())
183            }
184            TermBackend::Test { buffer, .. } => {
185                buffer.print(text, style);
186                Ok(())
187            }
188        }
189    }
190}
191
192const ATTRS: &[(Modifier, Attribute)] = &[
193    (Modifier::BOLD, Attribute::Bold),
194    (Modifier::DIM, Attribute::Dim),
195    (Modifier::ITALIC, Attribute::Italic),
196    (Modifier::UNDERLINED, Attribute::Underlined),
197    (Modifier::SLOW_BLINK, Attribute::SlowBlink),
198    (Modifier::RAPID_BLINK, Attribute::RapidBlink),
199    (Modifier::REVERSED, Attribute::Reverse),
200    (Modifier::HIDDEN, Attribute::Hidden),
201    (Modifier::CROSSED_OUT, Attribute::CrossedOut),
202];
203
204fn print_crossterm_span(text: &str, style: &Style, t: &mut Stdout) -> Result<(), io::Error> {
205    let fg = style.fg.unwrap_or(Color::Reset);
206    let bg = style.bg.unwrap_or(Color::Reset);
207
208    crossterm::queue!(t, SetAttribute(Attribute::Reset))?;
209
210    for (modifier, attribute) in ATTRS {
211        if style.add_modifier.contains(*modifier) {
212            crossterm::queue!(t, SetAttribute(*attribute))?;
213        }
214    }
215
216    crossterm::queue!(t, SetColors(Colors::new(fg.into(), bg.into())))?;
217    crossterm::queue!(t, Print(text))?;
218    Ok(())
219}
220
221impl TermBackend {
222    pub(crate) fn size(&self) -> io::Result<(u16, u16)> {
223        match self {
224            TermBackend::Crossterm(_) => crossterm::terminal::size(),
225            TermBackend::Test { buffer, .. } => Ok((buffer.width, buffer.height)),
226        }
227    }
228
229    pub(crate) fn clear(&mut self) -> io::Result<()> {
230        match self {
231            TermBackend::Crossterm(t) => crossterm::queue!(t, Clear(ClearType::All)),
232            TermBackend::Test { buffer, .. } => {
233                buffer.clear();
234                Ok(())
235            }
236        }
237    }
238
239    pub(crate) fn flush(&mut self) -> io::Result<()> {
240        match self {
241            TermBackend::Crossterm(t) => t.flush(),
242            TermBackend::Test { .. } => Ok(()),
243        }
244    }
245}
246
247impl TermBackend {
248    pub fn setup_term(&mut self, config: &Config) -> io::Result<()> {
249        match self {
250            TermBackend::Crossterm(crossterm_backend) => {
251                enable_raw_mode()?;
252                crossterm_backend.queue(EnterAlternateScreen)?;
253                crossterm_backend.queue(cursor::Hide)?;
254                if config.general.mouse_support {
255                    crossterm_backend.queue(EnableMouseCapture)?;
256                }
257            }
258            TermBackend::Test { .. } => {}
259        }
260
261        self.flush()
262    }
263
264    pub fn reset_term(&mut self, config: &Config) -> io::Result<()> {
265        match self {
266            TermBackend::Crossterm(crossterm_backend) => {
267                if config.general.mouse_support {
268                    crossterm_backend.queue(DisableMouseCapture)?;
269                }
270                crossterm_backend.queue(cursor::Show)?;
271                crossterm_backend.queue(LeaveAlternateScreen)?;
272                disable_raw_mode()?;
273            }
274            TermBackend::Test { .. } => {}
275        }
276
277        self.flush()
278    }
279
280    /// Stop gitu as job control stops a program on ctrl+z, giving the
281    /// terminal back until the shell continues it, and then take it again. The
282    /// test terminal has no job control, and stopping would stop the tests.
283    pub(crate) fn suspend(&mut self, config: &Config) -> io::Result<()> {
284        if let TermBackend::Test { .. } = self {
285            return Ok(());
286        }
287        self.reset_term(config)?;
288        #[cfg(unix)]
289        // SAFETY: raising a signal touches no memory of this process.
290        unsafe {
291            libc::raise(libc::SIGTSTP);
292        }
293        self.setup_term(config)?;
294        self.clear()
295    }
296
297    pub(crate) fn reset_term_stay_on_alt_screeen(
298        &mut self,
299        config: &Config,
300    ) -> Result<(), io::Error> {
301        match self {
302            TermBackend::Crossterm(crossterm_backend) => {
303                if config.general.mouse_support {
304                    crossterm_backend.queue(DisableMouseCapture)?;
305                }
306                crossterm_backend.queue(cursor::Show)?;
307                disable_raw_mode()?;
308            }
309            TermBackend::Test { .. } => {}
310        }
311
312        self.flush()
313    }
314
315    pub fn poll_event(&self, timeout: Duration) -> Res<bool> {
316        match self {
317            TermBackend::Crossterm(_) => crossterm::event::poll(timeout).map_err(Error::Term),
318            TermBackend::Test { events, .. } => {
319                if events.is_empty() {
320                    Err(Error::NoMoreEvents)
321                } else {
322                    Ok(true)
323                }
324            }
325        }
326    }
327
328    /// Whether an event is waiting to be read, without waiting for one.
329    pub fn has_pending_event(&self) -> Res<bool> {
330        match self {
331            TermBackend::Crossterm(_) => {
332                crossterm::event::poll(Duration::ZERO).map_err(Error::Term)
333            }
334            TermBackend::Test { events, .. } => Ok(!events.is_empty()),
335        }
336    }
337
338    pub fn hung_up(&self) -> bool {
339        match self {
340            #[cfg(unix)]
341            TermBackend::Crossterm(t) => hung_up(std::os::fd::AsFd::as_fd(t)),
342            #[cfg(not(unix))]
343            TermBackend::Crossterm(_) => false,
344            TermBackend::Test { .. } => false,
345        }
346    }
347
348    pub fn read_event(&mut self) -> Res<Event> {
349        match self {
350            TermBackend::Crossterm(_) => crossterm::event::read().map_err(Error::Term),
351            TermBackend::Test { events, .. } => events.pop().ok_or(Error::NoMoreEvents),
352        }
353    }
354}
355
356/// Whether the terminal behind `fd` has gone, as when its window is closed.
357/// Reading it then gives end of file, which crossterm reports as no input, so
358/// gitu must ask. Without this, gitu as git's pager in a closed tmux pane spins
359/// forever: git, the pane's session leader, takes the SIGHUP and waits for its
360/// pager rather than passing it on.
361#[cfg(unix)]
362fn hung_up(fd: std::os::fd::BorrowedFd) -> bool {
363    use std::os::fd::AsRawFd;
364    let mut pollfd = libc::pollfd {
365        fd: fd.as_raw_fd(),
366        events: libc::POLLOUT,
367        revents: 0,
368    };
369    let ready = unsafe { libc::poll(&mut pollfd, 1, 0) };
370    ready == 1 && pollfd.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0
371}
372
373#[cfg(all(test, unix))]
374mod tests {
375    use super::hung_up;
376    use std::os::fd::{AsFd, FromRawFd, OwnedFd};
377
378    #[test]
379    fn a_terminal_has_hung_up_once_its_other_end_is_closed() {
380        let (mut master, mut slave) = (0, 0);
381        let opened = unsafe {
382            libc::openpty(
383                &mut master,
384                &mut slave,
385                std::ptr::null_mut(),
386                std::ptr::null_mut(),
387                std::ptr::null_mut(),
388            )
389        };
390        assert_eq!(opened, 0);
391        let (master, slave) =
392            unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };
393
394        assert!(!hung_up(slave.as_fd()));
395        drop(master);
396        assert!(hung_up(slave.as_fd()));
397    }
398}