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 let backend = self.terminal.backend_mut();
106 crossterm::terminal::enable_raw_mode()?; execute!(backend, EnableMouseCapture)._elog();
108 #[cfg(feature = "bracketed-paste")]
109 execute!(backend, crossterm::event::EnableBracketedPaste)._elog();
110 if self.config.extended_keys {
111 execute!(
112 backend,
113 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
114 )
115 ._elog();
116 }
117
118 if fullscreen {
119 self.alternate_screen()?;
120 }
121 Ok(())
122 }
123
124 pub fn alternate_screen(&mut self) -> Result<()> {
125 let backend = self.terminal.backend_mut();
126 execute!(backend, EnterAlternateScreen)?;
127 execute!(backend, crossterm::terminal::Clear(ClearType::All))?;
128 self.terminal.clear()?;
129 debug!("Entered alternate screen");
130 Ok(())
131 }
132
133 pub fn enter_execute(&mut self) {
134 self.exit();
135 sleep(self.config.sleep_ms); debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
137
138 }
140
141 pub fn resize(&mut self, area: Rect) {
142 self.terminal.resize(area)._elog();
143 self.area = area
144 }
145
146 pub fn redraw(&mut self) {
147 self.terminal.resize(self.area)._elog();
148 }
149
150 pub fn return_execute(&mut self) -> Result<()> {
151 self.enter()?;
152 if !self.is_fullscreen() {
153 self.alternate_screen()._elog();
155 }
156 sleep(self.config.sleep_ms);
157 log::debug!("During return, slept {}", self.config.sleep_ms.as_millis());
158
159 execute!(
160 self.terminal.backend_mut(),
161 crossterm::terminal::Clear(ClearType::All)
162 )
163 ._wlog();
164
165 if self.is_fullscreen() || self.config.restore_fullscreen {
166 if let Some((width, height)) = Self::full_size() {
167 self.resize(Rect::new(0, 0, width, height));
168 } else {
169 error!("Failed to get terminal size");
170 self.resize(self.area);
171 }
172 } else {
173 self.resize(self.area);
174 }
175
176 Ok(())
177 }
178
179 pub fn exit(&mut self) {
180 let backend = self.terminal.backend_mut();
181
182 if self.config.clear_on_exit && !cfg!(debug_assertions) {
184 execute!(
185 backend,
186 crossterm::cursor::MoveTo(0, self.area.y),
187 crossterm::terminal::Clear(ClearType::FromCursorDown)
188 )
189 ._elog();
190 }
191
192 if self.config.extended_keys {
193 execute!(backend, PopKeyboardEnhancementFlags)._elog();
194 }
195 execute!(backend, LeaveAlternateScreen, DisableMouseCapture)._wlog();
202
203 self.terminal.show_cursor()._wlog();
204
205 disable_raw_mode()._wlog();
206
207 debug!("Terminal exited");
208 }
209
210 pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
212 Ok(if !atty::is(atty::Stream::Stdout) {
216 utils::query_cursor_position(timeout)
217 .map_err(io::Error::other)?
218 .1
219 } else {
220 crossterm::cursor::position()?.1
221 })
222 }
223
224 pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
225 execute!(backend, crossterm::terminal::ScrollUp(lines))?;
226 Ok(0) }
229 pub fn size() -> io::Result<(u16, u16)> {
230 crossterm::terminal::size()
231 }
232 pub fn full_size() -> Option<(u16, u16)> {
233 if let Ok((width, height)) = Self::size() {
234 Some((width, height))
235 } else {
236 error!("Failed to read terminal size");
237 None
238 }
239 }
240 pub fn is_fullscreen(&self) -> bool {
241 self.config.layout.is_none()
242 }
243 pub fn set_fullscreen(&mut self) {
244 self.config.layout = None;
245 }
246}
247
248impl Tui<Box<dyn Write + Send>> {
249 pub fn new(config: TerminalConfig) -> Result<Self> {
250 let writer = config.stream.to_stream();
251 let tui = Self::new_with_writer(writer, config)?;
252 Ok(tui)
253 }
254}
255
256impl<W> Drop for Tui<W>
257where
258 W: Write,
259{
260 fn drop(&mut self) {
261 self.exit();
262 }
263}
264
265#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
268pub enum IoStream {
269 Stdout,
270 #[default]
271 BufferedStderr,
272}
273
274impl IoStream {
275 pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
276 match self {
277 IoStream::Stdout => Box::new(io::stdout()),
278 IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
279 }
280 }
281}
282
283#[cfg(unix)]
286mod utils {
287 use anyhow::{Context, Result, bail};
288 use std::{
289 fs::OpenOptions,
290 io::{Read, Write},
291 time::Duration,
292 };
293
294 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
298 use nix::sys::{
299 select::{FdSet, select},
300 time::{TimeVal, TimeValLike},
301 };
302 use std::os::fd::AsFd;
303
304 let mut tty = OpenOptions::new()
305 .read(true)
306 .write(true)
307 .open("/dev/tty")
308 .context("Failed to open /dev/tty")?;
309
310 tty.write_all(b"\x1b[6n")?;
312 tty.flush()?;
313
314 let fd = tty.as_fd();
316 let mut fds = FdSet::new();
317 fds.insert(fd);
318
319 let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
320
321 let ready =
322 select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
323
324 if ready == 0 {
325 bail!("Timed out waiting for cursor position response: {timeout:?}");
326 }
327
328 let mut buf = [0u8; 64];
330 let n = tty.read(&mut buf)?;
331 let s = String::from_utf8_lossy(&buf[..n]);
332
333 parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
334 }
335
336 fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
339 let coords = s
340 .strip_prefix("\x1b[")
341 .context("Missing ESC]")?
342 .strip_suffix('R')
343 .context("Missing R")?;
344
345 let mut parts = coords.split(';');
346
347 let row: u16 = parts.next().context("Missing row")?.parse()?;
348
349 let col: u16 = parts.next().context("Missing column")?.parse()?;
350
351 Ok((col - 1, row - 1)) }
353}
354
355#[cfg(windows)]
356mod utils {
357 use anyhow::Result;
358 use std::time::Duration;
359 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
360 let ret = crossterm::cursor::position()?;
361 Ok(ret)
362 }
363}