1use std::io::{self, Stdout};
2use std::path::Path;
3use std::time::Duration;
4
5use log::{debug, info};
6use ratatui::prelude::*;
7
8use crossterm::event::{self, Event, KeyCode, KeyEventKind};
9use crossterm::execute;
10use crossterm::terminal::{
11 Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
12};
13use ratatui::backend::CrosstermBackend;
14use ratatui::layout::{Constraint, Direction, Layout};
15use ratatui::{Frame, Terminal};
16
17use super::popups::run_script::RunScriptPopup;
18use super::state::{App, AppMode, UiOptions};
19use super::widgets::category_list::render_category_list;
20use super::widgets::header::render_header;
21use super::widgets::script_list::render_script_list;
22use super::widgets::status_bar::render_status_bar;
23use crate::error::Result;
24use crate::ui::popups;
25
26fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
27 let mut popup_w = r.width * percent_x / 100;
28 let mut popup_h = r.height * percent_y / 100;
29 if !(r.width - popup_w).is_multiple_of(2) {
30 popup_w += 1;
31 }
32 if !(r.height - popup_h).is_multiple_of(2) {
33 popup_h += 1;
34 }
35 let offset_x = (r.width - popup_w) / 2;
36 let offset_y = (r.height - popup_h) / 2;
37 Rect { x: r.x + offset_x, y: r.y + offset_y, width: popup_w, height: popup_h }
38}
39
40fn render_normal_ui(f: &mut Frame, app: &mut App) {
41 let area = Layout::default()
42 .direction(Direction::Vertical)
43 .margin(1)
44 .constraints([Constraint::Min(0)])
45 .split(f.area())[0];
46
47 let chunks = Layout::default()
48 .direction(Direction::Vertical)
49 .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
50 .split(area);
51
52 render_header(f, app, chunks[0]);
53
54 let main_chunks = Layout::default()
55 .direction(Direction::Horizontal)
56 .constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
57 .split(chunks[1]);
58
59 app.script_panel_area = main_chunks[1];
60
61 render_category_list(f, app, main_chunks[0]);
62 render_script_list(f, app, main_chunks[1]);
63
64 render_status_bar(f, app, chunks[2]);
65}
66
67fn ui(f: &mut Frame, app: &mut App) {
68 render_normal_ui(f, app);
69
70 match app.mode {
71 AppMode::RunScript => {
72 if let Some(popup) = &mut app.run_script_popup {
73 let area = app.script_panel_area;
74 let popup_area = centered_rect(98, 96, area);
75 f.render_widget(popup, popup_area);
76 }
77 }
78 AppMode::Search => {
79 let area = app.script_panel_area;
80 let popup_width = std::cmp::min(70, area.width.saturating_sub(8));
81 let popup_height = std::cmp::min(16, area.height.saturating_sub(6));
82
83 let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
84 let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);
85
86 let popup_area = centered_rect(percent_x, percent_y, area);
87 popups::search::render_search_popup(f, app, popup_area);
88 }
89 AppMode::Confirm => {
90 let area = app.script_panel_area;
91 let popup_width = std::cmp::min(60, area.width.saturating_sub(8));
92 let popup_height = if app.multi_select.enabled && !app.multi_select.scripts.is_empty() {
93 std::cmp::min(20, area.height.saturating_sub(6))
94 } else {
95 11
96 };
97
98 let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
99 let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);
100
101 let popup_area = centered_rect(percent_x, percent_y, area);
102 popups::confirmation::render_confirmation_popup(f, app, popup_area);
103 }
104 AppMode::Help => {
105 let area = app.script_panel_area;
106 let popup_area = centered_rect(98, 96, area);
107 let max_scroll = popups::help::render_help_popup(f, app, popup_area);
108 app.help.max_scroll = max_scroll;
109 }
110 AppMode::Preview => {
111 let area = app.script_panel_area;
112 let popup_area = centered_rect(98, 96, area);
113 popups::preview::render_preview_popup(f, app, popup_area);
114 }
115 AppMode::Description => {
116 let area = app.script_panel_area;
117 let popup_area = centered_rect(98, 96, area);
118 popups::description::render_description_popup(f, app, popup_area);
119 }
120 AppMode::Normal => {}
121 AppMode::RootWarning => {
122 let area = app.script_panel_area;
123 let popup_area = centered_rect(98, 96, area);
124 popups::root_warning::render_root_warning_popup(f, app, popup_area);
125 }
126 AppMode::TermuxWarning => {
127 let area = app.script_panel_area;
128 let popup_area = centered_rect(98, 96, area);
129 popups::termux_warning::render_termux_warning_popup(f, app, popup_area);
130 }
131 }
132}
133
134fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
135 enable_raw_mode()?;
136 let mut stdout = io::stdout();
137 execute!(stdout, EnterAlternateScreen, Clear(ClearType::All))?;
138 let backend = CrosstermBackend::new(stdout);
139 Terminal::new(backend).map_err(Into::into)
140}
141
142fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
143 disable_raw_mode()?;
144 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
145 terminal.show_cursor()?;
146 Ok(())
147}
148
149pub fn run_ui_with_options(modules_dir: &Path, options: &UiOptions) -> Result<()> {
150 if options.log_mode {
151 info!("UI initialization started");
152 }
153
154 let mut terminal = setup_terminal()?;
155 install_panic_hook();
156
157 let result = run_ui_loop(modules_dir, options, &mut terminal);
158
159 cleanup_terminal(&mut terminal)?;
160
161 if options.log_mode {
162 match &result {
163 Ok(()) => info!("UI terminated normally"),
164 Err(e) => log::error!("UI terminated with error: {e}"),
165 }
166 }
167
168 result
169}
170
171fn run_ui_loop(
172 modules_dir: &Path,
173 options: &UiOptions,
174 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
175) -> Result<()> {
176 let mut app = App::new(options);
177 app.modules_dir = modules_dir.to_path_buf();
178
179 if options.log_mode {
180 info!("Loading scripts from modules directory");
181 }
182
183 app.load_scripts(modules_dir)?;
184
185 if options.log_mode {
186 info!(
187 "Loaded {} scripts in {} categories",
188 app.all_scripts.values().map(Vec::len).sum::<usize>(),
189 app.categories.items.len()
190 );
191 }
192
193 while !app.quit {
194 let popup_has_new_data =
195 app.run_script_popup.as_mut().is_some_and(RunScriptPopup::has_new_data);
196
197 if app.needs_redraw || popup_has_new_data {
198 if app.last_size == Rect::default() {
199 terminal.autoresize()?;
200 }
201
202 terminal.draw(|f| ui(f, &mut app))?;
203 app.last_size = terminal.get_frame().area();
204 app.needs_redraw = false;
205
206 if let Some(popup) = app.run_script_popup.as_mut() {
207 popup.acknowledge_data();
208 }
209 }
210
211 let poll_duration = if app.mode == AppMode::RunScript {
212 Duration::from_millis(16)
213 } else {
214 Duration::from_millis(100)
215 };
216
217 if event::poll(poll_duration)?
218 && let Ok(event) = event::read()
219 {
220 app.needs_redraw = true;
221 handle_event(&mut app, event, options)?;
222 }
223 }
224
225 Ok(())
226}
227
228fn install_panic_hook() {
229 use std::sync::Once;
230 static ONCE: Once = Once::new();
231 ONCE.call_once(|| {
232 let original = std::panic::take_hook();
233 std::panic::set_hook(Box::new(move |info| {
234 let _ = disable_raw_mode();
235 let _ = execute!(io::stdout(), LeaveAlternateScreen);
236 original(info);
237 }));
238 });
239}
240
241fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
242 match event {
243 Event::Key(key) => {
244 if matches!(key.kind, KeyEventKind::Release | KeyEventKind::Repeat) {
245 return Ok(());
246 }
247
248 if options.log_mode {
249 let key_name = match key.code {
250 KeyCode::Char(c) => format!("Char('{c}')"),
251 _ => format!("{:?}", key.code),
252 };
253 debug!("Key pressed: {} in mode: {:?}", key_name, app.mode);
254 }
255
256 if app.mode == AppMode::RunScript {
257 if let Some(popup) = &mut app.run_script_popup {
258 match popup.handle_key_event(key) {
259 crate::ui::popups::run_script::PopupEvent::Close => {
260 app.run_script_popup = None;
261 if let Some(script_path) = app.script_execution_queue.pop_front() {
262 match RunScriptPopup::new(
263 script_path,
264 app.log_mode,
265 app.theme.clone(),
266 ) {
267 Ok(next_popup) => {
268 app.run_script_popup = Some(next_popup);
269 }
270 Err(e) => {
271 log::error!("Failed to start next script popup: {e}");
272 app.run_script_popup = None;
273 app.mode = AppMode::Normal;
274 }
275 }
276 } else {
277 app.mode = AppMode::Normal;
278 }
279 }
280 crate::ui::popups::run_script::PopupEvent::None => {}
281 }
282 }
283 } else {
284 match app.mode {
285 AppMode::Normal => app.handle_key_normal_mode(key),
286 AppMode::Preview => app.handle_key_preview_mode(key),
287 AppMode::Search => app.handle_search_input(key),
288 AppMode::Confirm => app.handle_key_confirmation_mode(key),
289 AppMode::Help => app.handle_key_help_mode(key),
290 AppMode::Description => app.handle_key_description_mode(key),
291 AppMode::RootWarning => app.handle_key_root_warning_mode(key),
292 AppMode::TermuxWarning => app.handle_key_termux_warning_mode(key),
293 AppMode::RunScript => {}
294 }
295 }
296 }
297 Event::Resize(_, _) => {
298 app.needs_redraw = true;
299 }
300 _ => {}
301 }
302 Ok(())
303}