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