Skip to main content

horto_os_ui_tui/
event.rs

1//! Keyboard handling for the TUI event loop.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use horto_os_ui_shared::SetupKind;
5use std::sync::atomic::Ordering;
6
7use crate::app::{App, Modal};
8use crate::prompt::{confirm_key, ConfirmResult, TextInputResult};
9use crate::tabs::Screen;
10
11pub fn is_quit(key: KeyEvent) -> bool {
12    matches!(key.code, KeyCode::Char('q' | 'Q'))
13        || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
14}
15
16pub fn ctrl_c_quit(key: KeyEvent) -> bool {
17    key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
18}
19
20pub fn handle_screen_key(app: &mut App, key: KeyEvent) {
21    match key.code {
22        KeyCode::Char('?') => {
23            app.help_open = true;
24            app.message = "Help".into();
25        }
26        KeyCode::Tab => {
27            app.apply = !app.apply;
28            app.message = if app.apply {
29                "APPLY".into()
30            } else {
31                "PLAN".into()
32            };
33            if app.remote.is_none() {
34                app.refresh();
35            }
36        }
37        KeyCode::Char(d) if d.is_ascii_digit() => {
38            if let Some(screen) = Screen::from_digit(d, app.is_remote()) {
39                app.select_screen(screen);
40            }
41        }
42        KeyCode::Char('c') if app.screen == Screen::Logs => app.clear_logs(),
43        KeyCode::Char('r') => {
44            app.refresh();
45            if app.remote.is_none() {
46                app.message = "Refreshed".into();
47            }
48        }
49        KeyCode::Char('f' | 'F') => {
50            app.fetch_current_tab();
51        }
52        KeyCode::Char('a') => app.run_all(),
53        KeyCode::Char('b') => app.run_backup_etc(),
54        KeyCode::Char('B') => app.show_disk_backup_status(),
55        KeyCode::Char('p' | 'P') => {
56            let next = if app.kind == SetupKind::Full {
57                SetupKind::Minimal
58            } else {
59                SetupKind::Full
60            };
61            app.set_pipeline_kind_local(next);
62        }
63        KeyCode::Char('e' | 'E') if app.screen == Screen::Ssh => {
64            app.open_host_editor();
65        }
66        KeyCode::Char('i' | 'I') if app.screen == Screen::Ssh => {
67            app.install_ssh_key_action();
68        }
69        KeyCode::Enter if app.screen == Screen::Setup => {
70            app.run_selected();
71        }
72        KeyCode::Enter
73            if matches!(
74                app.screen,
75                Screen::Ssh
76                    | Screen::Cli
77                    | Screen::Api
78                    | Screen::Mcp
79                    | Screen::Overview
80                    | Screen::Reboot
81            ) =>
82        {
83            app.run_surface_enter();
84        }
85        KeyCode::Up | KeyCode::Char('k') => {
86            if let Some(i) = app.step_state.selected() {
87                if i > 0 {
88                    app.step_state.select(Some(i - 1));
89                }
90            }
91        }
92        KeyCode::Down | KeyCode::Char('j') => {
93            let len = app.status_lines.len();
94            if let Some(i) = app.step_state.selected() {
95                if i + 1 < len {
96                    app.step_state.select(Some(i + 1));
97                }
98            }
99        }
100        KeyCode::Left => {
101            app.prev_screen();
102        }
103        KeyCode::Right => {
104            app.next_screen();
105        }
106        _ => {}
107    }
108}
109
110/// Handle confirm / text / secret modals. Returns true when the key was consumed.
111pub fn handle_modal_key(app: &mut App, key: KeyEvent) -> bool {
112    match app.modal {
113        Some(Modal::TextHost(_)) => {
114            let Some(Modal::TextHost(mut input)) = app.modal.take() else {
115                return true;
116            };
117            match input.handle_key(key) {
118                TextInputResult::Continue => {
119                    app.modal = Some(Modal::TextHost(input));
120                }
121                TextInputResult::Submit(value) => {
122                    app.apply_host_edit(&value);
123                }
124                TextInputResult::Cancel => {
125                    app.push_log("Host edit cancelled");
126                }
127            }
128            true
129        }
130        Some(Modal::SudoPassword(_)) => {
131            let Some(Modal::SudoPassword(mut input)) = app.modal.take() else {
132                return true;
133            };
134            match input.handle_key(key) {
135                TextInputResult::Continue => {
136                    app.modal = Some(Modal::SudoPassword(input));
137                }
138                TextInputResult::Submit(password) => {
139                    if password.is_empty() {
140                        app.push_log("Reboot cancelled (empty password)");
141                        app.note("Reboot cancelled");
142                    } else {
143                        app.do_reboot(password);
144                    }
145                }
146                TextInputResult::Cancel => {
147                    app.push_log("Reboot cancelled");
148                    app.note("Reboot cancelled");
149                }
150            }
151            true
152        }
153        Some(Modal::Rebooting) => {
154            if key.code == KeyCode::Esc {
155                app.reboot_cancel.store(true, Ordering::SeqCst);
156                app.reboot_inflight = false;
157                app.reboot_rx = None;
158                app.modal = None;
159            }
160            true
161        }
162        Some(Modal::Confirm(_)) => {
163            match confirm_key(key) {
164                ConfirmResult::Yes => app.resolve_confirm_yes(),
165                ConfirmResult::No => app.resolve_confirm_no(),
166                ConfirmResult::Ignore => {}
167            }
168            true
169        }
170        None => false,
171    }
172}