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, 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 fn queue_print(&mut self, text: &str, style: &Style) -> Res<()> {
165        match self {
166            TermBackend::Crossterm(t) => {
167                print_crossterm_span(text, style, t).map_err(Error::Term)?;
168
169                Ok(())
170            }
171            TermBackend::Test { buffer, .. } => {
172                buffer.print(text, style);
173                Ok(())
174            }
175        }
176    }
177}
178
179const ATTRS: &[(Modifier, Attribute)] = &[
180    (Modifier::BOLD, Attribute::Bold),
181    (Modifier::DIM, Attribute::Dim),
182    (Modifier::ITALIC, Attribute::Italic),
183    (Modifier::UNDERLINED, Attribute::Underlined),
184    (Modifier::SLOW_BLINK, Attribute::SlowBlink),
185    (Modifier::RAPID_BLINK, Attribute::RapidBlink),
186    (Modifier::REVERSED, Attribute::Reverse),
187    (Modifier::HIDDEN, Attribute::Hidden),
188    (Modifier::CROSSED_OUT, Attribute::CrossedOut),
189];
190
191fn print_crossterm_span(text: &str, style: &Style, t: &mut Stdout) -> Result<(), io::Error> {
192    let fg = style.fg.unwrap_or(Color::Reset);
193    let bg = style.bg.unwrap_or(Color::Reset);
194
195    crossterm::queue!(t, SetAttribute(Attribute::Reset))?;
196
197    for (modifier, attribute) in ATTRS {
198        if style.add_modifier.contains(*modifier) {
199            crossterm::queue!(t, SetAttribute(*attribute))?;
200        }
201    }
202
203    crossterm::queue!(t, SetColors(Colors::new(fg.into(), bg.into())))?;
204    crossterm::queue!(t, Print(text))?;
205    Ok(())
206}
207
208impl TermBackend {
209    pub(crate) fn size(&self) -> io::Result<(u16, u16)> {
210        match self {
211            TermBackend::Crossterm(_) => crossterm::terminal::size(),
212            TermBackend::Test { buffer, .. } => Ok((buffer.width, buffer.height)),
213        }
214    }
215
216    pub(crate) fn clear(&mut self) -> io::Result<()> {
217        match self {
218            TermBackend::Crossterm(t) => crossterm::queue!(t, Clear(ClearType::All)),
219            TermBackend::Test { buffer, .. } => {
220                buffer.clear();
221                Ok(())
222            }
223        }
224    }
225
226    pub(crate) fn flush(&mut self) -> io::Result<()> {
227        match self {
228            TermBackend::Crossterm(t) => t.flush(),
229            TermBackend::Test { .. } => Ok(()),
230        }
231    }
232}
233
234impl TermBackend {
235    pub fn setup_term(&mut self, config: &Config) -> io::Result<()> {
236        match self {
237            TermBackend::Crossterm(crossterm_backend) => {
238                enable_raw_mode()?;
239                crossterm_backend.queue(EnterAlternateScreen)?;
240                crossterm_backend.queue(cursor::Hide)?;
241                if config.general.mouse_support {
242                    crossterm_backend.queue(EnableMouseCapture)?;
243                }
244            }
245            TermBackend::Test { .. } => {}
246        }
247
248        self.flush()
249    }
250
251    pub fn reset_term(&mut self, config: &Config) -> io::Result<()> {
252        match self {
253            TermBackend::Crossterm(crossterm_backend) => {
254                if config.general.mouse_support {
255                    crossterm_backend.queue(DisableMouseCapture)?;
256                }
257                crossterm_backend.queue(cursor::Show)?;
258                crossterm_backend.queue(LeaveAlternateScreen)?;
259                disable_raw_mode()?;
260            }
261            TermBackend::Test { .. } => {}
262        }
263
264        self.flush()
265    }
266
267    pub(crate) fn reset_term_stay_on_alt_screeen(
268        &mut self,
269        config: &Config,
270    ) -> Result<(), io::Error> {
271        match self {
272            TermBackend::Crossterm(crossterm_backend) => {
273                if config.general.mouse_support {
274                    crossterm_backend.queue(DisableMouseCapture)?;
275                }
276                crossterm_backend.queue(cursor::Show)?;
277                disable_raw_mode()?;
278            }
279            TermBackend::Test { .. } => {}
280        }
281
282        self.flush()
283    }
284
285    pub fn poll_event(&self, timeout: Duration) -> Res<bool> {
286        match self {
287            TermBackend::Crossterm(_) => crossterm::event::poll(timeout).map_err(Error::Term),
288            TermBackend::Test { events, .. } => {
289                if events.is_empty() {
290                    Err(Error::NoMoreEvents)
291                } else {
292                    Ok(true)
293                }
294            }
295        }
296    }
297
298    pub fn read_event(&mut self) -> Res<Event> {
299        match self {
300            TermBackend::Crossterm(_) => crossterm::event::read().map_err(Error::Term),
301            TermBackend::Test { events, .. } => events.pop().ok_or(Error::NoMoreEvents),
302        }
303    }
304}