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