Skip to main content

rush_sync_server/ui/
screen.rs

1// src/ui/screen.rs
2use crate::commands::{history::HistoryKeyboardHandler, lang::LanguageService, theme::ThemeSystem};
3use crate::core::prelude::*;
4use crate::input::{
5    keyboard::{KeyAction, KeyboardManager},
6    state::InputState,
7    AppEvent, EventHandler,
8};
9use crate::output::display::MessageDisplay;
10use crate::ui::{
11    color::AppColor,
12    terminal::TerminalManager,
13    viewport::ScrollDirection,
14    widget::{AnimatedWidget, CursorWidget, StatefulWidget, Widget},
15};
16use crossterm::{event::KeyEvent, execute};
17use ratatui::{backend::CrosstermBackend, Terminal};
18use std::{
19    io::{self, Stdout},
20    sync::OnceLock,
21};
22
23#[derive(Clone)]
24struct TerminalInfo {
25    term_program: String,
26    tmux: bool,
27}
28static TERMINAL_INFO: OnceLock<TerminalInfo> = OnceLock::new();
29
30pub type TerminalBackend = Terminal<CrosstermBackend<Stdout>>;
31
32pub struct ScreenManager {
33    terminal: TerminalBackend,
34    pub message_display: MessageDisplay,
35    input_state: InputState,
36    config: Config,
37    terminal_mgr: TerminalManager,
38    events: EventHandler,
39    keyboard_manager: KeyboardManager,
40    waiting_for_restart_confirmation: bool,
41    progress_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
42}
43
44impl ScreenManager {
45    pub async fn new(config: &Config) -> Result<Self> {
46        let mut terminal_mgr = TerminalManager::new().await?;
47        terminal_mgr.setup().await?;
48
49        let backend = CrosstermBackend::new(io::stdout());
50        let terminal = Terminal::new(backend)?;
51        let size = terminal.size()?;
52
53        // Initialize progress channel for non-blocking bulk commands
54        let progress_rx = crate::input::init_progress_channel();
55
56        let mut screen_manager = Self {
57            terminal,
58            terminal_mgr,
59            message_display: MessageDisplay::new(config, size.width, size.height),
60            input_state: InputState::new(config),
61            config: config.clone(),
62            events: EventHandler::new(config.poll_rate),
63            keyboard_manager: KeyboardManager::new(),
64            waiting_for_restart_confirmation: false,
65            progress_rx,
66        };
67
68        let version = crate::core::constants::VERSION;
69        let startup_msg = get_command_translation("system.startup.version", &[version]);
70        screen_manager
71            .message_display
72            .add_message_instant(startup_msg);
73
74        Ok(screen_manager)
75    }
76
77    pub async fn run(&mut self) -> Result<()> {
78        let result = loop {
79            // Poll both event sources: TUI events AND background progress messages
80            tokio::select! {
81                event = self.events.next() => {
82                    if let Some(event) = event {
83                        match event {
84                            AppEvent::Input(key) => {
85                                if self.handle_input(key).await? {
86                                    self.events.shutdown().await;
87                                    break Ok(());
88                                }
89                            }
90                            AppEvent::MouseScrollUp => {
91                                self.message_display.handle_scroll(ScrollDirection::Up, 3);
92                            }
93                            AppEvent::MouseScrollDown => {
94                                self.message_display.handle_scroll(ScrollDirection::Down, 3);
95                            }
96                            AppEvent::Resize(w, h) => self.handle_resize(w, h).await?,
97                            AppEvent::Tick => self.handle_tick().await?,
98                            AppEvent::Progress(msg) => {
99                                self.message_display.add_message_instant(msg);
100                            }
101                        }
102                    }
103                }
104                progress = self.progress_rx.recv() => {
105                    if let Some(msg) = progress {
106                        self.message_display.add_message_instant(msg);
107                    }
108                }
109            }
110            self.render().await?;
111        };
112        self.terminal_mgr.cleanup().await?;
113        result
114    }
115
116    async fn handle_input(&mut self, key: KeyEvent) -> Result<bool> {
117        // History handling
118        if HistoryKeyboardHandler::get_history_action(&key).is_some() {
119            if let Some(input) = self.input_state.handle_input(key) {
120                self.process_special_input(&input).await;
121            }
122            return Ok(false);
123        }
124
125        // Scroll/Action handling
126        match self.keyboard_manager.get_action(&key) {
127            KeyAction::ScrollUp => {
128                self.message_display.handle_scroll(ScrollDirection::Up, 1);
129                Ok(false)
130            }
131            KeyAction::ScrollDown => {
132                self.message_display.handle_scroll(ScrollDirection::Down, 1);
133                Ok(false)
134            }
135            KeyAction::PageUp => {
136                self.message_display
137                    .handle_scroll(ScrollDirection::PageUp, 0);
138                Ok(false)
139            }
140            KeyAction::PageDown => {
141                self.message_display
142                    .handle_scroll(ScrollDirection::PageDown, 0);
143                Ok(false)
144            }
145            KeyAction::Submit => self.handle_submit(key).await,
146            KeyAction::Quit => Ok(true),
147            _ => {
148                if let Some(input) = self.input_state.handle_input(key) {
149                    self.process_special_input(&input).await;
150                }
151                Ok(false)
152            }
153        }
154    }
155
156    async fn handle_submit(&mut self, key: KeyEvent) -> Result<bool> {
157        use crate::core::constants::*;
158        let Some(input) = self.input_state.handle_input(key) else {
159            return Ok(false);
160        };
161
162        if input == SIG_CLEAR {
163            self.message_display.clear_messages();
164            return Ok(false);
165        }
166
167        if input == SIG_EXIT {
168            return Ok(true);
169        }
170
171        if input.starts_with(SIG_RESTART) {
172            self.handle_restart(&input).await;
173            return Ok(false);
174        }
175
176        // Process special messages (theme, language updates)
177        if self.process_special_input(&input).await {
178            return Ok(false);
179        }
180
181        // Only add to display if it was not a system command
182        let cmd = input.trim().to_lowercase();
183        if input.starts_with("__")
184            || ["theme", "help", "lang"]
185                .iter()
186                .any(|&c| cmd.starts_with(c))
187        {
188            self.message_display.add_message_instant(input.clone());
189        } else {
190            self.message_display.add_message(input.clone());
191        }
192
193        Ok(false)
194    }
195
196    async fn process_special_input(&mut self, input: &str) -> bool {
197        // Language updates
198        if let Some(processed) = LanguageService::process_save_message(input).await {
199            self.message_display.add_message_instant(processed);
200            return true;
201        }
202
203        // Theme updates
204        if let Some(processed) = self.process_theme_update(input).await {
205            self.message_display.add_message_instant(processed);
206            return true;
207        }
208
209        false
210    }
211
212    async fn process_theme_update(&mut self, message: &str) -> Option<String> {
213        use crate::core::constants::*;
214        if !message.starts_with(SIG_LIVE_THEME_UPDATE) {
215            return None;
216        }
217
218        let parts: Vec<&str> = message.split(SIG_THEME_MSG_SEP).collect();
219        if parts.len() != 2 {
220            return None;
221        }
222
223        let theme_name = parts[0].replace(SIG_LIVE_THEME_UPDATE, "");
224        let display_msg = parts[1];
225
226        // Load and apply theme
227        let theme_system = ThemeSystem::load().ok()?;
228        let theme_def = theme_system.get_theme(&theme_name)?;
229        let new_theme = self.create_theme(theme_def).ok()?;
230
231        // Backup state, update config, restore state
232        let backup = self.input_state.export_state();
233        self.config.theme = new_theme;
234        self.config.current_theme_name = theme_name;
235
236        self.message_display.clear_messages();
237        self.message_display.update_config(&self.config);
238
239        self.input_state = InputState::new(&self.config);
240        self.input_state.import_state(backup);
241
242        Some(display_msg.to_string())
243    }
244
245    fn create_theme(
246        &self,
247        def: &crate::commands::theme::ThemeDefinition,
248    ) -> Result<crate::core::config::Theme> {
249        Ok(crate::core::config::Theme {
250            input_text: AppColor::from_string(&def.input_text)?,
251            input_bg: AppColor::from_string(&def.input_bg)?,
252            output_text: AppColor::from_string(&def.output_text)?,
253            output_bg: AppColor::from_string(&def.output_bg)?,
254            input_cursor_prefix: def.input_cursor_prefix.clone(),
255            input_cursor_color: AppColor::from_string(&def.input_cursor_color)?,
256            input_cursor: def.input_cursor.clone(),
257            output_cursor: def.output_cursor.clone(),
258            output_cursor_color: AppColor::from_string(&def.output_cursor_color)?,
259        })
260    }
261
262    async fn handle_restart(&mut self, input: &str) {
263        use crate::core::constants::SIG_RESTART_WITH_MSG;
264        if input.starts_with(SIG_RESTART_WITH_MSG) {
265            let msg = input.replace(SIG_RESTART_WITH_MSG, "").trim().to_string();
266            if !msg.is_empty() {
267                self.message_display.add_message_instant(msg);
268                tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
269            }
270        }
271
272        if let Err(e) = self.perform_restart().await {
273            self.message_display
274                .add_message_instant(get_translation("screen.restart.failed", &[&e.to_string()]));
275        }
276    }
277
278    async fn handle_resize(&mut self, width: u16, height: u16) -> Result<()> {
279        self.message_display.handle_resize(width, height);
280        Ok(())
281    }
282
283    async fn handle_tick(&mut self) -> Result<()> {
284        self.message_display.update_typewriter();
285        self.input_state.tick();
286        Ok(())
287    }
288
289    async fn render(&mut self) -> Result<()> {
290        let (input_widget, cursor_pos) = self.input_state.render_with_cursor();
291
292        let viewport_ok = self.message_display.viewport().is_usable();
293        let output_area = self.message_display.viewport().output_area();
294        let input_area = self.message_display.viewport().input_area();
295
296        let (messages, config, layout, cursor_state) =
297            self.message_display.create_output_widget_for_rendering();
298
299        self.terminal.draw(|frame| {
300            let size = frame.size();
301
302            // Emergency cases with i18n
303            if size.width < 10 || size.height < 5 {
304                let widget = ratatui::widgets::Paragraph::new(get_translation(
305                    "screen.render.terminal_too_small",
306                    &[],
307                ))
308                .block(ratatui::widgets::Block::default());
309                frame.render_widget(widget, size);
310                return;
311            }
312
313            if !viewport_ok || !output_area.is_valid() || !input_area.is_valid() {
314                let widget = ratatui::widgets::Paragraph::new(get_translation(
315                    "screen.render.viewport_error",
316                    &[],
317                ))
318                .block(ratatui::widgets::Block::default());
319                frame.render_widget(widget, size);
320                return;
321            }
322
323            // Check bounds
324            if Self::exceeds_bounds(&output_area, &input_area, size) {
325                return;
326            }
327
328            // Render normally
329            let output_widget = crate::output::display::create_output_widget(
330                &messages,
331                layout,
332                &config,
333                cursor_state,
334            );
335
336            frame.render_widget(output_widget, output_area.as_rect());
337            frame.render_widget(input_widget, input_area.as_rect());
338
339            if let Some((x, y)) = cursor_pos {
340                frame.set_cursor(input_area.x + 3 + x, input_area.y + 1 + y);
341            }
342        })?;
343
344        // Cursor styling (unchanged)
345        if cursor_pos.is_some() {
346            self.apply_cursor_styling()?;
347        } else {
348            execute!(std::io::stdout(), crossterm::style::Print("\x1B[?25l"))?;
349        }
350        Ok(())
351    }
352
353    fn exceeds_bounds(
354        output: &crate::ui::viewport::LayoutArea,
355        input: &crate::ui::viewport::LayoutArea,
356        size: ratatui::layout::Rect,
357    ) -> bool {
358        output.x + output.width > size.width
359            || output.y + output.height > size.height
360            || input.x + input.width > size.width
361            || input.y + input.height > size.height
362    }
363
364    fn apply_cursor_styling(&self) -> Result<()> {
365        let form = match self.config.theme.input_cursor.to_uppercase().as_str() {
366            "PIPE" => "\x1B[6 q",
367            "UNDERSCORE" => "\x1B[4 q",
368            "BLOCK" => "\x1B[2 q",
369            _ => "\x1B[6 q",
370        };
371
372        let color_cmds = self.get_cursor_colors(&self.config.theme.input_cursor_color);
373
374        execute!(std::io::stdout(), crossterm::style::Print(form))?;
375        for cmd in color_cmds {
376            execute!(std::io::stdout(), crossterm::style::Print(cmd))?;
377        }
378        execute!(std::io::stdout(), crossterm::style::Print("\x1B[?25h"))?;
379        Ok(())
380    }
381
382    fn get_cursor_colors(&self, color: &AppColor) -> Vec<String> {
383        let (r, g, b) = self.get_rgb(color);
384        let info = Self::terminal_info();
385
386        if info.tmux {
387            return vec![format!(
388                "\x1BPtmux;\x1B\x1B]12;#{:02x}{:02x}{:02x}\x07\x1B\\",
389                r, g, b
390            )];
391        }
392
393        let base = format!("\x1B]12;#{:02x}{:02x}{:02x}\x07", r, g, b);
394        match info.term_program.as_str() {
395            "Apple_Terminal" => vec![base],
396            p if p.starts_with("iTerm") => {
397                vec![format!("\x1B]Pl{:02x}{:02x}{:02x}\x1B\\", r, g, b), base]
398            }
399            _ => vec![base],
400        }
401    }
402
403    fn get_rgb(&self, color: &AppColor) -> (u8, u8, u8) {
404        match color.to_name() {
405            "black" => (0, 0, 0),
406            "red" => (255, 0, 0),
407            "green" => (0, 255, 0),
408            "yellow" => (255, 255, 0),
409            "blue" => (0, 0, 255),
410            "magenta" => (255, 0, 255),
411            "cyan" => (0, 255, 255),
412            "white" => (255, 255, 255),
413            "gray" => (128, 128, 128),
414            "darkgray" => (64, 64, 64),
415            _ => (255, 255, 255),
416        }
417    }
418
419    fn terminal_info() -> &'static TerminalInfo {
420        TERMINAL_INFO.get_or_init(|| TerminalInfo {
421            term_program: std::env::var("TERM_PROGRAM").unwrap_or_default(),
422            tmux: std::env::var("TMUX").is_ok(),
423        })
424    }
425
426    async fn perform_restart(&mut self) -> Result<()> {
427        execute!(
428            std::io::stdout(),
429            crossterm::style::Print("\x1B[0 q"),
430            crossterm::style::Print("\x1B[?25h")
431        )?;
432
433        self.terminal_mgr.cleanup().await?;
434        self.terminal_mgr = TerminalManager::new().await?;
435        self.terminal_mgr.setup().await?;
436
437        let backend = CrosstermBackend::new(io::stdout());
438        self.terminal = Terminal::new(backend)?;
439        let size = self.terminal.size()?;
440
441        self.message_display = MessageDisplay::new(&self.config, size.width, size.height);
442        self.input_state = InputState::new(&self.config);
443        self.waiting_for_restart_confirmation = false;
444
445        self.message_display
446            .add_message(get_translation("screen.restart.success", &[]));
447        Ok(())
448    }
449
450    pub async fn switch_theme_safely(&mut self, theme_name: &str) -> Result<String> {
451        let system = ThemeSystem::load().map_err(|e| {
452            AppError::Validation(get_translation(
453                "screen.theme.load_failed",
454                &[&e.to_string()],
455            ))
456        })?;
457
458        let def = system.get_theme(theme_name).ok_or_else(|| {
459            AppError::Validation(get_translation("screen.theme.not_found", &[theme_name]))
460        })?;
461
462        let theme = self.create_theme(def)?;
463        let backup = self.input_state.export_state();
464
465        self.config.theme = theme;
466        self.config.current_theme_name = theme_name.to_string();
467        self.message_display.update_config(&self.config);
468
469        self.input_state = InputState::new(&self.config);
470        self.input_state.import_state(backup);
471
472        Ok(get_translation(
473            "screen.theme.switched_success",
474            &[&theme_name.to_uppercase()],
475        ))
476    }
477
478    /// Returns any missing i18n translation keys used by this module.
479    pub fn validate_i18n_keys() -> Vec<String> {
480        [
481            "screen.theme.failed",
482            "screen.render.too_small.text",
483            "screen.render.viewport_error.text",
484            "system.commands.restart.success",
485        ]
486        .iter()
487        .filter(|&&key| !crate::i18n::has_translation(key))
488        .map(|&key| key.to_string())
489        .collect()
490    }
491}