1use crate::config::TerminalConfig;
2use anyhow::Result;
3use cba::bait::ResultExt;
4use crossterm::{
5 event::{
6 DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
7 PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
8 },
9 execute,
10 terminal::{ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode},
11};
12use log::{debug, error};
13use ratatui::{Terminal, TerminalOptions, Viewport, layout::Rect, prelude::CrosstermBackend};
14use serde::{Deserialize, Serialize};
15use std::{
16 io::{self, Write},
17 thread::sleep,
18 time::Duration,
19};
20pub struct Tui<W>
21where
22 W: Write,
23{
24 pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
25 pub area: Rect,
26 pub config: TerminalConfig,
27 pub cursor_y_offset: Option<u16>,
28 pub fullscreen: bool, }
30
31impl<W> Tui<W>
32where
33 W: Write,
34{
35 pub fn new_with_writer(writer: W, mut config: TerminalConfig) -> Result<Self> {
38 let mut backend = CrosstermBackend::new(writer);
39 let mut options = TerminalOptions::default();
40 if config.sleep_ms.is_zero() {
41 config.sleep_ms = Duration::from_millis(100)
42 };
43
44 crossterm::terminal::enable_raw_mode()?;
46
47 let (width, height) = Self::full_size().unwrap_or_default();
48 let area = if let Some(ref layout) = config.layout {
49 let request = layout
50 .percentage
51 .compute_clamped(height, layout.min, layout.max);
52
53 let cursor_y = Self::get_cursor_y(config.sleep_ms).unwrap_or_else(|e| {
54 error!("Failed to read cursor: {e}");
55 height - 1 });
57
58 let initial_height = height.saturating_sub(cursor_y);
59
60 let scroll = request.saturating_sub(initial_height);
61 debug!("TUI dimensions: {width}, {height}. Cursor_y: {cursor_y}.",);
62
63 let cursor_y = match Self::scroll_up(&mut backend, scroll) {
65 Ok(_) => {
66 cursor_y.saturating_sub(scroll) }
68 Err(_) => cursor_y,
69 };
70 let available_height = height.saturating_sub(cursor_y);
71
72 debug!(
73 "TUI quantities: min: {}, initial_available: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}",
74 layout.min
75 );
76
77 if available_height < layout.min {
78 error!("Failed to allocate minimum height, falling back to fullscreen");
79 Rect::new(0, 0, width, height)
80 } else {
81 let area = Rect::new(
82 0,
83 cursor_y,
84 width,
85 available_height.min(request).max(layout.min),
86 );
87
88 options.viewport = Viewport::Fixed(area);
90
91 area
92 }
93 } else {
94 Rect::new(0, 0, width, height)
95 };
96
97 debug!("TUI area: {area}");
98
99 let terminal = Terminal::with_options(backend, options)?;
100 Ok(Self {
101 terminal,
102 fullscreen: config.layout.is_none(),
103 cursor_y_offset: None,
104 config,
105 area,
106 })
107 }
108
109 pub fn enter(&mut self) -> Result<()> {
110 let fullscreen = self.is_fullscreen();
111
112 crossterm::terminal::enable_raw_mode()?;
113 if fullscreen {
114 self.enter_alternate_screen()?;
115 }
116
117 let backend = self.terminal.backend_mut();
118 execute!(backend, EnableMouseCapture)._elog();
119 #[cfg(feature = "bracketed-paste")]
120 {
121 execute!(backend, crossterm::event::EnableBracketedPaste)._elog();
122 }
123
124 if self.config.extended_keys {
125 execute!(
126 backend,
127 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
128 )
129 ._elog();
130 log::trace!("keyboard enhancement set");
131 }
132
133 Ok(())
134 }
135
136 pub fn enter_alternate_screen(&mut self) -> Result<()> {
138 let backend = self.terminal.backend_mut();
139 execute!(backend, EnterAlternateScreen)?;
140 execute!(backend, crossterm::terminal::Clear(ClearType::All))?;
141 self.terminal.clear()?;
142 debug!("Entered alternate screen");
143 Ok(())
144 }
145
146 pub fn enter_execute(&mut self) {
147 self.exit();
148 sleep(self.config.sleep_ms); debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
150
151 }
153
154 pub fn return_execute(&mut self) -> Result<()> {
155 self.config.layout = None; self.enter()?;
157
158 sleep(self.config.sleep_ms);
159 log::trace!("During return, slept {}", self.config.sleep_ms.as_millis());
160
161 execute!(
162 self.terminal.backend_mut(),
163 crossterm::terminal::Clear(ClearType::All)
164 )
165 ._wlog();
166
167 if self.is_fullscreen() || self.config.restore_fullscreen {
169 if let Some((width, height)) = Self::full_size() {
170 self.resize(Rect::new(0, 0, width, height));
171 } else {
172 error!("Failed to get terminal size");
173 self.resize(self.area);
174 }
175 } else {
176 self.resize(self.area);
177 }
178
179 Ok(())
180 }
181
182 pub fn exit(&mut self) {
183 let backend = self.terminal.backend_mut();
184
185 execute!(backend, LeaveAlternateScreen, DisableMouseCapture)._wlog();
186
187 if self.config.extended_keys {
188 execute!(backend, PopKeyboardEnhancementFlags)._elog();
189 }
190
191 if self.config.move_up_on_exit {
192 let move_up = self.cursor_y_offset.unwrap_or(1);
193 log::debug!("Moving up by: {move_up}");
194 execute!(backend, crossterm::cursor::MoveUp(move_up))._elog();
195 }
196
197 if self.config.clear_on_exit && !cfg!(debug_assertions) {
198 execute!(
199 backend,
200 crossterm::cursor::MoveToColumn(0),
201 crossterm::terminal::Clear(ClearType::FromCursorDown)
202 )
203 ._elog();
204 }
205
206 self.terminal.show_cursor()._wlog();
207
208 disable_raw_mode()._wlog();
209
210 debug!("Terminal exited");
211 }
212
213 pub fn resize(&mut self, area: Rect) {
214 self.terminal.resize(area)._elog();
215 self.area = area
216 }
217
218 pub fn redraw(&mut self) {
219 self.terminal.resize(self.area)._elog();
220 }
221
222 pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
224 Ok(if !atty::is(atty::Stream::Stdout) {
228 utils::query_cursor_position(timeout)
229 .map_err(io::Error::other)?
230 .1
231 } else {
232 crossterm::cursor::position()?.1
233 })
234 }
235
236 pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
237 execute!(backend, crossterm::terminal::ScrollUp(lines))?;
238 Ok(0) }
241 pub fn size() -> io::Result<(u16, u16)> {
242 crossterm::terminal::size()
243 }
244 pub fn full_size() -> Option<(u16, u16)> {
245 if let Ok((width, height)) = Self::size() {
246 Some((width, height))
247 } else {
248 error!("Failed to read terminal size");
249 None
250 }
251 }
252 pub fn is_fullscreen(&self) -> bool {
253 self.config.layout.is_none()
254 }
255 pub fn set_fullscreen(&mut self) {
256 self.config.layout = None;
257 }
258}
259
260impl Tui<Box<dyn Write + Send>> {
261 pub fn new(config: TerminalConfig) -> Result<Self> {
262 let writer = config.stream.to_stream();
263 let tui = Self::new_with_writer(writer, config)?;
264 Ok(tui)
265 }
266}
267
268impl<W> Drop for Tui<W>
269where
270 W: Write,
271{
272 fn drop(&mut self) {
273 self.exit();
274 }
275}
276
277#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
280pub enum IoStream {
281 Stdout,
282 #[default]
283 BufferedStderr,
284}
285
286impl IoStream {
287 pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
288 match self {
289 IoStream::Stdout => Box::new(io::stdout()),
290 IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
291 }
292 }
293}
294
295#[cfg(unix)]
298mod utils {
299 use anyhow::{Context, Result, bail};
300 use std::{
301 fs::OpenOptions,
302 io::{Read, Write},
303 time::Duration,
304 };
305
306 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
310 use nix::sys::{
311 select::{FdSet, select},
312 time::{TimeVal, TimeValLike},
313 };
314 use std::os::fd::AsFd;
315
316 let mut tty = OpenOptions::new()
317 .read(true)
318 .write(true)
319 .open("/dev/tty")
320 .context("Failed to open /dev/tty")?;
321
322 tty.write_all(b"\x1b[6n")?;
324 tty.flush()?;
325
326 let fd = tty.as_fd();
328 let mut fds = FdSet::new();
329 fds.insert(fd);
330
331 let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
332
333 let ready =
334 select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
335
336 if ready == 0 {
337 bail!("Timed out waiting for cursor position response: {timeout:?}");
338 }
339
340 let mut buf = [0u8; 64];
342 let n = tty.read(&mut buf)?;
343 let s = String::from_utf8_lossy(&buf[..n]);
344
345 parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
346 }
347
348 fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
351 let coords = s
352 .strip_prefix("\x1b[")
353 .context("Missing ESC]")?
354 .strip_suffix('R')
355 .context("Missing R")?;
356
357 let mut parts = coords.split(';');
358
359 let row: u16 = parts.next().context("Missing row")?.parse()?;
360
361 let col: u16 = parts.next().context("Missing column")?.parse()?;
362
363 Ok((col - 1, row - 1)) }
365}
366
367#[cfg(windows)]
368mod utils {
369 use anyhow::Result;
370 use std::time::Duration;
371 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
372 let ret = crossterm::cursor::position()?;
373 Ok(ret)
374 }
375}