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::ThemeSelector => {
121 let area = app.script_panel_area;
122 let popup_area = centered_rect(50, 40, area);
123 popups::theme_selector::render_theme_selector_popup(f, app, popup_area);
124 }
125 AppMode::Normal => {}
126 AppMode::RootWarning => {
127 let area = app.script_panel_area;
128 let popup_area = centered_rect(98, 96, area);
129 popups::root_warning::render_root_warning_popup(f, app, popup_area);
130 }
131 AppMode::TermuxWarning => {
132 let area = app.script_panel_area;
133 let popup_area = centered_rect(98, 96, area);
134 popups::termux_warning::render_termux_warning_popup(f, app, popup_area);
135 }
136 }
137}
138
139fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
140 enable_raw_mode()?;
141 let mut stdout = io::stdout();
142 execute!(stdout, EnterAlternateScreen, Clear(ClearType::All))?;
143 let backend = CrosstermBackend::new(stdout);
144 Terminal::new(backend).map_err(Into::into)
145}
146
147fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
148 disable_raw_mode()?;
149 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
150 terminal.show_cursor()?;
151 Ok(())
152}
153
154pub fn run_ui_with_options(modules_dir: &Path, options: &UiOptions) -> Result<()> {
155 if options.log_mode {
156 info!("UI initialization started");
157 }
158
159 let mut terminal = setup_terminal()?;
160 install_panic_hook();
161
162 let result = run_ui_loop(modules_dir, options, &mut terminal);
163
164 cleanup_terminal(&mut terminal)?;
165
166 if options.log_mode {
167 match &result {
168 Ok(()) => info!("UI terminated normally"),
169 Err(e) => log::error!("UI terminated with error: {e}"),
170 }
171 }
172
173 result
174}
175
176fn run_ui_loop(
177 modules_dir: &Path,
178 options: &UiOptions,
179 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
180) -> Result<()> {
181 let mut app = App::new(options);
182 app.modules_dir = modules_dir.to_path_buf();
183
184 if options.log_mode {
185 info!("Loading scripts from modules directory");
186 }
187
188 app.load_scripts(modules_dir)?;
189
190 if options.log_mode {
191 info!(
192 "Loaded {} scripts in {} categories",
193 app.all_scripts.values().map(Vec::len).sum::<usize>(),
194 app.categories.items.len()
195 );
196 }
197
198 while !app.quit {
199 let popup_has_new_data =
200 app.run_script_popup.as_mut().is_some_and(RunScriptPopup::has_new_data);
201
202 if app.needs_redraw || popup_has_new_data {
203 if app.last_size == Rect::default() {
204 terminal.autoresize()?;
205 }
206
207 terminal.draw(|f| ui(f, &mut app))?;
208 app.last_size = terminal.get_frame().area();
209 app.needs_redraw = false;
210
211 if let Some(popup) = app.run_script_popup.as_mut() {
212 popup.acknowledge_data();
213 }
214 }
215
216 let poll_duration = if app.mode == AppMode::RunScript {
217 Duration::from_millis(16)
218 } else {
219 Duration::from_millis(100)
220 };
221
222 if event::poll(poll_duration)?
223 && let Ok(event) = event::read()
224 {
225 app.needs_redraw = true;
226 handle_event(&mut app, event, options)?;
227 }
228 }
229
230 Ok(())
231}
232
233fn install_panic_hook() {
234 use std::sync::Once;
235 static ONCE: Once = Once::new();
236 ONCE.call_once(|| {
237 let original = std::panic::take_hook();
238 std::panic::set_hook(Box::new(move |info| {
239 let _ = disable_raw_mode();
240 let _ = execute!(io::stdout(), LeaveAlternateScreen);
241 original(info);
242 }));
243 });
244}
245
246fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
247 match event {
248 Event::Key(key) => {
249 if matches!(key.kind, KeyEventKind::Release | KeyEventKind::Repeat) {
250 return Ok(());
251 }
252
253 if options.log_mode {
254 let key_name = match key.code {
255 KeyCode::Char(c) => format!("Char('{c}')"),
256 _ => format!("{:?}", key.code),
257 };
258 debug!("Key pressed: {} in mode: {:?}", key_name, app.mode);
259 }
260
261 if app.mode == AppMode::RunScript {
262 if let Some(popup) = &mut app.run_script_popup {
263 match popup.handle_key_event(key) {
264 crate::ui::popups::run_script::PopupEvent::Close => {
265 app.run_script_popup = None;
266 if let Some(script_path) = app.script_execution_queue.pop_front() {
267 match RunScriptPopup::new(
268 script_path,
269 app.log_mode,
270 app.theme.clone(),
271 app.log_path.clone(),
272 ) {
273 Ok(next_popup) => {
274 app.run_script_popup = Some(next_popup);
275 }
276 Err(e) => {
277 log::error!("Failed to start next script popup: {e}");
278 app.run_script_popup = None;
279 app.mode = AppMode::Normal;
280 }
281 }
282 } else {
283 app.mode = AppMode::Normal;
284 }
285 }
286 crate::ui::popups::run_script::PopupEvent::None => {}
287 }
288 }
289 } else {
290 match app.mode {
291 AppMode::Normal => app.handle_key_normal_mode(key),
292 AppMode::Preview => app.handle_key_preview_mode(key),
293 AppMode::Search => app.handle_search_input(key),
294 AppMode::Confirm => app.handle_key_confirmation_mode(key),
295 AppMode::Help => app.handle_key_help_mode(key),
296 AppMode::Description => app.handle_key_description_mode(key),
297 AppMode::ThemeSelector => app.handle_key_theme_selector_mode(key),
298 AppMode::RootWarning => app.handle_key_root_warning_mode(key),
299 AppMode::TermuxWarning => app.handle_key_termux_warning_mode(key),
300 AppMode::RunScript => {}
301 }
302 }
303 }
304 Event::Resize(_, _) => {
305 app.needs_redraw = true;
306 }
307 _ => {}
308 }
309 Ok(())
310}