Skip to main content

droidtui/
app.rs

1use crate::{
2    event::{AppEvent, Event, EventHandler},
3    message::Message,
4    model::{AppState, Model},
5    update,
6};
7use ratatui::{crossterm::event::KeyCode, DefaultTerminal};
8
9/// Main application following Elm architecture
10/// This is a thin wrapper that connects the event loop to the Model-Update-View cycle
11pub struct App {
12    /// Application model (all state)
13    pub model: Model,
14
15    /// Event handler
16    pub events: EventHandler,
17}
18
19impl App {
20    /// Create a new application
21    pub fn new() -> Self {
22        Self {
23            model: Model::new(),
24            events: EventHandler::new(),
25        }
26    }
27
28    /// Main application loop following Elm architecture:
29    /// 1. Wait for events
30    /// 2. Convert events to messages
31    /// 3. Update model with message
32    /// 4. Render view from model
33    pub async fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
34        while !self.model.should_quit() {
35            // View: Render current model state
36            terminal.draw(|frame| {
37                crate::view::render(&mut self.model, frame.area(), frame.buffer_mut())
38            })?;
39
40            // Event: Wait for next event
41            let event = self.events.next().await?;
42
43            // Update: Convert event to message and update model
44            let message = self.event_to_message(event)?;
45            if let Some(msg) = message {
46                update::update(&mut self.model, msg).await;
47            }
48        }
49
50        Ok(())
51    }
52
53    /// Convert events to messages (Elm architecture pattern)
54    fn event_to_message(&self, event: Event) -> color_eyre::Result<Option<Message>> {
55        match event {
56            Event::Tick => Ok(Some(Message::Tick)),
57
58            Event::Crossterm(event) => {
59                if let crossterm::event::Event::Key(key_event) = event {
60                    Ok(self.key_to_message(key_event.code))
61                } else {
62                    Ok(None)
63                }
64            }
65
66            Event::App(app_event) => Ok(Some(match app_event {
67                AppEvent::MenuUp => Message::MenuUp,
68                AppEvent::MenuDown => Message::MenuDown,
69                AppEvent::Execute => {
70                    let command = self.model.get_selected_command();
71                    Message::ExecuteCommand(command)
72                }
73                AppEvent::EnterChild => Message::EnterChild,
74                AppEvent::ExitChild => Message::ExitChild,
75                AppEvent::Quit => Message::Quit,
76            })),
77        }
78    }
79
80    /// Map keyboard input to messages based on current state
81    fn key_to_message(&self, key: KeyCode) -> Option<Message> {
82        match self.model.state {
83            AppState::Startup => Some(Message::SkipStartup),
84
85            AppState::Menu => match key {
86                KeyCode::Esc | KeyCode::Char('q') => Some(Message::Quit),
87                KeyCode::Up | KeyCode::Char('k') => Some(Message::MenuUp),
88                KeyCode::Down | KeyCode::Char('j') => Some(Message::MenuDown),
89                KeyCode::Tab => Some(Message::SectionNext),
90                KeyCode::BackTab => Some(Message::SectionPrev),
91                KeyCode::Char('r') => Some(Message::RefreshDeviceInfo),
92                KeyCode::Char('d') => Some(Message::NextDevice),
93                KeyCode::Char('L') => Some(Message::OpenLogcat),
94                KeyCode::Enter => {
95                    let command = self.model.get_selected_command();
96                    Some(Message::ExecuteCommand(command))
97                }
98                _ => None,
99            },
100
101            AppState::Loading => match key {
102                KeyCode::Esc | KeyCode::Char('q') => Some(Message::ReturnToMenu),
103                _ => None,
104            },
105
106            AppState::ShowResult => match key {
107                KeyCode::Up | KeyCode::Char('k') => Some(Message::ScrollUp),
108                KeyCode::Down | KeyCode::Char('j') => Some(Message::ScrollDown),
109                KeyCode::PageUp => Some(Message::ScrollPageUp),
110                KeyCode::PageDown => Some(Message::ScrollPageDown),
111                KeyCode::Home => Some(Message::ScrollToTop),
112                KeyCode::End => Some(Message::ScrollToBottom),
113                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter | KeyCode::Backspace => {
114                    Some(Message::ReturnToMenu)
115                }
116                _ => Some(Message::ReturnToMenu),
117            },
118
119            AppState::Logcat => {
120                use crate::logcat::FilterField;
121                use crate::model::LogcatSaveMode;
122
123                // ── Save dialog active ──────────────────────────────────────
124                if self.model.logcat_save_active {
125                    // ── File browser sub-mode ───────────────────────────────
126                    if self.model.logcat_save_mode == LogcatSaveMode::FileBrowser {
127                        // Forward the raw KeyEvent to the file explorer
128                        let key_event = crossterm::event::KeyEvent::new(
129                            key,
130                            crossterm::event::KeyModifiers::NONE,
131                        );
132                        return Some(Message::LogcatFileExplorerKey(key_event));
133                    }
134
135                    // ── Path-input sub-mode ─────────────────────────────────
136                    return match key {
137                        KeyCode::Esc => Some(Message::LogcatCancelSave),
138                        KeyCode::Enter => {
139                            let path = self.model.logcat_save_path.clone();
140                            if path.trim().is_empty() {
141                                Some(Message::LogcatCancelSave)
142                            } else {
143                                Some(Message::LogcatFileSaved(path))
144                            }
145                        }
146                        KeyCode::F(2) => Some(Message::LogcatSaveAs),
147                        KeyCode::Backspace => Some(Message::LogcatSearchBackspace),
148                        KeyCode::Left => Some(Message::LogcatCursorLeft),
149                        KeyCode::Right => Some(Message::LogcatCursorRight),
150                        KeyCode::Tab => Some(Message::LogcatSaveFilteredOnly),
151                        KeyCode::Char(c) => Some(Message::LogcatSearchInput(c)),
152                        _ => None,
153                    };
154                }
155
156                // ── Detail popup active ─────────────────────────────────
157                if self.model.logcat.detail_open {
158                    return match key {
159                        KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
160                            Some(Message::LogcatToggleDetail)
161                        }
162                        KeyCode::Up | KeyCode::Char('k') => Some(Message::LogcatSelectUp),
163                        KeyCode::Down | KeyCode::Char('j') => Some(Message::LogcatSelectDown),
164                        KeyCode::Char('y') => Some(Message::LogcatCopyLine),
165                        KeyCode::Char('m') => Some(Message::LogcatBookmarkToggle),
166                        _ => None,
167                    };
168                }
169
170                // ── Filter text input active ────────────────────────────────
171                let editing = self.model.logcat.filter.active_field != FilterField::None;
172
173                if editing {
174                    match key {
175                        KeyCode::Esc => Some(Message::LogcatExitFilter),
176                        KeyCode::Enter => Some(Message::LogcatExitFilter),
177                        KeyCode::Backspace => Some(Message::LogcatSearchBackspace),
178                        KeyCode::Delete => Some(Message::LogcatSearchDelete),
179                        KeyCode::Left => Some(Message::LogcatCursorLeft),
180                        KeyCode::Right => Some(Message::LogcatCursorRight),
181                        KeyCode::Char(c) => Some(Message::LogcatSearchInput(c)),
182                        _ => None,
183                    }
184                } else {
185                    // ── Normal logcat navigation mode ────────────────────────
186                    match key {
187                        KeyCode::Esc | KeyCode::Char('q') => Some(Message::CloseLogcat),
188                        KeyCode::Up | KeyCode::Char('k') => Some(Message::LogcatScrollUp),
189                        KeyCode::Down | KeyCode::Char('j') => Some(Message::LogcatScrollDown),
190                        KeyCode::PageUp => Some(Message::LogcatScrollPageUp),
191                        KeyCode::PageDown => Some(Message::LogcatScrollPageDown),
192                        KeyCode::Home => Some(Message::LogcatScrollToTop),
193                        KeyCode::End => Some(Message::LogcatScrollToBottom),
194                        KeyCode::Char('G') => Some(Message::LogcatScrollToBottom),
195                        KeyCode::Char('g') => Some(Message::LogcatScrollToTop),
196                        KeyCode::Char(' ') => Some(Message::LogcatTogglePause),
197                        KeyCode::Char('c') => Some(Message::LogcatClear),
198                        KeyCode::Char('l') => Some(Message::LogcatCycleLevel),
199                        KeyCode::Char('w') => Some(Message::LogcatToggleWordWrap),
200                        KeyCode::Char('/') => Some(Message::LogcatToggleSearch),
201                        KeyCode::Char('t') => Some(Message::LogcatToggleTagFilter),
202                        KeyCode::Char('p') => Some(Message::LogcatTogglePackageFilter),
203                        KeyCode::Char('s') => Some(Message::LogcatSave),
204                        KeyCode::Char('S') => Some(Message::LogcatSaveFilteredOnly),
205                        // New feature keys
206                        KeyCode::Char('r') => Some(Message::LogcatToggleRegex),
207                        KeyCode::Char('e') => Some(Message::LogcatToggleExclude),
208                        KeyCode::Char('x') => Some(Message::LogcatToggleCompact),
209                        KeyCode::Enter => Some(Message::LogcatToggleDetail),
210                        KeyCode::Char('m') => Some(Message::LogcatBookmarkToggle),
211                        KeyCode::Char('[') => Some(Message::LogcatBookmarkPrev),
212                        KeyCode::Char(']') => Some(Message::LogcatBookmarkNext),
213                        KeyCode::Left => Some(Message::LogcatHScrollLeft),
214                        KeyCode::Right => Some(Message::LogcatHScrollRight),
215                        KeyCode::Char('0') => Some(Message::LogcatHScrollReset),
216                        KeyCode::Char('y') => Some(Message::LogcatCopyLine),
217                        KeyCode::Char('f') => Some(Message::LogcatToggleFold),
218                        _ => None,
219                    }
220                }
221            }
222        }
223    }
224}
225
226impl Default for App {
227    fn default() -> Self {
228        Self::new()
229    }
230}