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 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 let move_up = self.cursor_y_offset.unwrap_or(1);
192 log::debug!("Moving up by: {move_up}");
193
194 if self.config.clear_on_exit && !cfg!(debug_assertions) {
195 execute!(
196 backend,
197 crossterm::cursor::MoveUp(move_up),
198 crossterm::terminal::Clear(ClearType::FromCursorDown)
199 )
200 ._elog();
201 }
202
203 self.terminal.show_cursor()._wlog();
204
205 disable_raw_mode()._wlog();
206
207 debug!("Terminal exited");
208 }
209
210 pub fn resize(&mut self, area: Rect) {
211 self.terminal.resize(area)._elog();
212 self.area = area
213 }
214
215 pub fn redraw(&mut self) {
216 self.terminal.resize(self.area)._elog();
217 }
218
219 pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
221 Ok(if !atty::is(atty::Stream::Stdout) {
225 utils::query_cursor_position(timeout)
226 .map_err(io::Error::other)?
227 .1
228 } else {
229 crossterm::cursor::position()?.1
230 })
231 }
232
233 pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
234 execute!(backend, crossterm::terminal::ScrollUp(lines))?;
235 Ok(0) }
238 pub fn size() -> io::Result<(u16, u16)> {
239 crossterm::terminal::size()
240 }
241 pub fn full_size() -> Option<(u16, u16)> {
242 if let Ok((width, height)) = Self::size() {
243 Some((width, height))
244 } else {
245 error!("Failed to read terminal size");
246 None
247 }
248 }
249 pub fn is_fullscreen(&self) -> bool {
250 self.config.layout.is_none()
251 }
252 pub fn set_fullscreen(&mut self) {
253 self.config.layout = None;
254 }
255}
256
257impl Tui<Box<dyn Write + Send>> {
258 pub fn new(config: TerminalConfig) -> Result<Self> {
259 let writer = config.stream.to_stream();
260 let tui = Self::new_with_writer(writer, config)?;
261 Ok(tui)
262 }
263}
264
265impl<W> Drop for Tui<W>
266where
267 W: Write,
268{
269 fn drop(&mut self) {
270 self.exit();
271 }
272}
273
274#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
277pub enum IoStream {
278 Stdout,
279 #[default]
280 BufferedStderr,
281}
282
283impl IoStream {
284 pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
285 match self {
286 IoStream::Stdout => Box::new(io::stdout()),
287 IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
288 }
289 }
290}
291
292#[cfg(unix)]
295mod utils {
296 use anyhow::{Context, Result, bail};
297 use std::{
298 fs::OpenOptions,
299 io::{Read, Write},
300 time::Duration,
301 };
302
303 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
307 use nix::sys::{
308 select::{FdSet, select},
309 time::{TimeVal, TimeValLike},
310 };
311 use std::os::fd::AsFd;
312
313 let mut tty = OpenOptions::new()
314 .read(true)
315 .write(true)
316 .open("/dev/tty")
317 .context("Failed to open /dev/tty")?;
318
319 tty.write_all(b"\x1b[6n")?;
321 tty.flush()?;
322
323 let fd = tty.as_fd();
325 let mut fds = FdSet::new();
326 fds.insert(fd);
327
328 let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
329
330 let ready =
331 select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
332
333 if ready == 0 {
334 bail!("Timed out waiting for cursor position response: {timeout:?}");
335 }
336
337 let mut buf = [0u8; 64];
339 let n = tty.read(&mut buf)?;
340 let s = String::from_utf8_lossy(&buf[..n]);
341
342 parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
343 }
344
345 fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
348 let coords = s
349 .strip_prefix("\x1b[")
350 .context("Missing ESC]")?
351 .strip_suffix('R')
352 .context("Missing R")?;
353
354 let mut parts = coords.split(';');
355
356 let row: u16 = parts.next().context("Missing row")?.parse()?;
357
358 let col: u16 = parts.next().context("Missing column")?.parse()?;
359
360 Ok((col - 1, row - 1)) }
362}
363
364#[cfg(windows)]
365mod utils {
366 use anyhow::Result;
367 use std::time::Duration;
368 pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
369 let ret = crossterm::cursor::position()?;
370 Ok(ret)
371 }
372}