1use crate::{Result, config::TerminalConfig};
2use crossterm::{
3 event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}
4};
5use log::{debug, error, warn};
6use ratatui::{Terminal, TerminalOptions, Viewport, layout::Rect, prelude::CrosstermBackend};
7use serde::{Deserialize, Serialize};
8use std::{io::{self, Write}, thread::sleep, time::Duration};
9
10pub struct Tui<W>
11where
12W: Write,
13{
14 pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
15 pub area: Rect,
16 pub config: TerminalConfig
17}
18
19impl<W> Tui<W>
20where
21W: Write,
22{
23 pub fn new_with_writer(writer: W, mut config: TerminalConfig) -> Result<Self> {
26 let mut backend = CrosstermBackend::new(writer);
27 let mut options = TerminalOptions::default();
28
29 let (width, height) = Self::full_size().unwrap_or_default();
30 let area = if let Some(ref layout) = config.layout {
31 let request = layout.percentage.compute_with_clamp(height, layout.max).min(height);
32
33 let cursor_y= Self::get_cursor_y().unwrap_or_else(|e| {
34 warn!("Failed to read cursor: {e}");
35 height });
37
38 let initial_height = height
39 .saturating_sub(cursor_y);
40
41 let scroll = request.saturating_sub(initial_height);
42 debug!("TUI dimensions: {width}, {height}. Cursor: {cursor_y}.", );
43
44 let cursor_y = match Self::scroll_up(&mut backend, scroll) {
46 Ok(_) => {
47 cursor_y.saturating_sub(scroll) }
49 Err(_) => {
50 cursor_y
51 }
52 };
53 let available_height = height
54 .saturating_sub(cursor_y);
55
56 debug!("TUI quantities: min: {}, initial: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}", layout.min);
57
58 if available_height < layout.min {
59 error!("Failed to allocate minimum height, falling back to fullscreen");
60 Rect::new(0, 0, width, height)
61 } else {
62 let area = Rect::new(
63 0,
64 cursor_y,
65 width,
66 available_height.min(request),
67 );
68
69 options.viewport = Viewport::Fixed(area);
71
72 area
73 }
74 } else {
75 Rect::new(0, 0, width, height)
76 };
77
78 debug!("TUI area: {area}");
79
80 let terminal = Terminal::with_options(backend, options)?;
81 if config.sleep == 0 { config.sleep = 100 };
82 Ok(Self {
83 terminal,
84 config,
85 area
86 })
87 }
88
89 pub fn enter(&mut self) -> Result<()> {
90 let fullscreen = self.is_fullscreen();
91 let backend = self.terminal.backend_mut();
92 enable_raw_mode()?;
93 execute!(backend, EnableMouseCapture)?;
94
95 if fullscreen {
96 self.enter_alternate()?;
97 }
98 Ok(())
99 }
100
101 pub fn enter_alternate(&mut self) -> Result<()> {
102 let backend = self.terminal.backend_mut();
103 execute!(backend, EnterAlternateScreen)?;
104 execute!(
105 backend,
106 crossterm::terminal::Clear(ClearType::All)
107 )?;
108 self.terminal.clear()?;
109 debug!("Entered alternate screen");
110 Ok(())
111 }
112
113 pub fn enter_execute(&mut self) {
114 self.exit();
115 sleep(Duration::from_millis(self.config.sleep)); debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
117
118 }
120
121 pub fn resize(&mut self, area: Rect) {
122 let _ = self
123 .terminal
124 .resize(area)
125 .map_err(|e| error!("{e}"));
126 self.area = area
127 }
128
129 pub fn redraw(&mut self) {
130 let _ = self
131 .terminal
132 .resize(self.area)
133 .map_err(|e| error!("{e}"));
134 }
135
136 pub fn return_execute(&mut self) -> Result<()> {
137 self.enter()?;
138 if !self.is_fullscreen() {
139 let _ = self.enter_alternate();
141 }
142
143 sleep(Duration::from_millis(self.config.sleep));
144
145 let _ = execute!(
146 self.terminal.backend_mut(),
147 crossterm::terminal::Clear(ClearType::All)
148 )
149 .map_err(|e| warn!("{e}"));
150
151 if self.is_fullscreen() || self.config.restore_fullscreen {
152 if let Some((width, height)) = Self::full_size() {
153 self.resize(Rect::new(0, 0, width, height));
154 } else {
155 error!("Failed to get terminal size");
156 self.resize(self.area);
157 }
158 } else {
159 self.resize(self.area);
160 }
161
162 Ok(())
163 }
164
165 pub fn exit(&mut self) {
166 let backend = self.terminal.backend_mut();
167
168 let _ = execute!(
170 backend,
171 crossterm::cursor::MoveTo(0, self.area.y),
172 crossterm::terminal::Clear(ClearType::FromCursorDown)
173 )
174 .map_err(|e| warn!("{e}"));
175 let _ = execute!(backend, LeaveAlternateScreen, DisableMouseCapture)
182 .map_err(|e| warn!("{e}"));
183
184 let _ = self
185 .terminal
186 .show_cursor()
187 .map_err(|e| warn!("{e}"));
188
189 let _ = disable_raw_mode()
190 .map_err(|e| warn!("{e}"));
191
192
193 debug!("Terminal exited");
194 }
195
196 pub fn get_cursor_y() -> io::Result<u16> {
198 {
202 if !atty::is(atty::Stream::Stdout) {
203 return Err(io::Error::new(
204 io::ErrorKind::NotConnected,
205 "stdout is not a TTY",
206 ));
207 }
208 }
209
210 crossterm::cursor::position().map(|u| u.1)
211 }
212
213 pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
214 execute!(backend, crossterm::terminal::ScrollUp(lines))?;
215 Self::get_cursor_y() }
217 pub fn size() -> io::Result<(u16, u16)> {
218 crossterm::terminal::size()
219 }
220 pub fn full_size() -> Option<(u16, u16)> {
221 if let Ok((width, height)) = Self::size() {
222 Some((width, height))
223 } else {
224 error!("Failed to read terminal size");
225 None
226 }
227 }
228 pub fn is_fullscreen(&self) -> bool {
229 self.config.layout.is_none()
230 }
231 pub fn set_fullscreen(&mut self) {
232 self.config.layout = None;
233 }
234}
235
236impl Tui<Box<dyn Write + Send>> {
237 pub fn new(config: TerminalConfig) -> Result<Self> {
238 let writer = config.stream.to_stream();
239 let tui = Self::new_with_writer(writer, config)?;
240 Ok(tui)
241 }
242}
243
244impl<W> Drop for Tui<W>
245where
246W: Write,
247{
248 fn drop(&mut self) {
249 self.exit();
250 }
251}
252
253#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
256pub enum IoStream {
257 Stdout,
258 #[default]
259 BufferedStderr,
260}
261
262impl IoStream {
263 pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
264 match self {
265 IoStream::Stdout => Box::new(io::stdout()),
266 IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
267 }
268 }
269}