Skip to main content

netget/tui/modal/
mod.rs

1//! Overlay modals: the dashboard's drill-in and editing surfaces.
2//!
3//! Modals form a stack (`DashboardApp::modals`) so nesting — server → routing
4//! → handler → script editor — unwinds naturally with Esc. The topmost modal
5//! owns all input while it is open.
6
7pub mod composer;
8pub mod confirm;
9pub mod form;
10pub mod help;
11pub mod intercept;
12pub mod protocol_picker;
13pub mod request_detail;
14pub mod routing;
15pub mod text_editor;
16
17use crate::state::app_state::{AccessLogEntry, WebApprovalResponse};
18use crate::tui::app::{Section, UiKey};
19
20use composer::ComposerModel;
21use form::{FieldTarget, FormModel};
22use protocol_picker::ProtocolEntry;
23use text_editor::TextEditorModel;
24
25/// A modal's preferred size: percentages of the screen, each capped by what
26/// the content needs so a short modal stays short on a big terminal.
27#[derive(Debug, Clone, Copy)]
28pub struct ModalSize {
29    pub percent_x: u16,
30    pub percent_y: u16,
31    pub max_cols: u16,
32    pub max_rows: u16,
33}
34
35impl ModalSize {
36    pub fn new(percent_x: u16, percent_y: u16, max_cols: u16, max_rows: u16) -> Self {
37        Self {
38            percent_x,
39            percent_y,
40            max_cols,
41            max_rows,
42        }
43    }
44}
45
46/// Rows the handler editor's body needs for the kind being edited.
47fn draft_rows(model: &routing::RoutingModel, draft: &routing::HandlerDraft) -> u16 {
48    use routing::{DraftFocus, HandlerKind};
49    // Kind control + its blurb (2 rows) + the pattern row + a blank.
50    let mut rows = 5u16;
51    // The pattern chooser lists the protocol's events while it has focus.
52    if draft.focus == DraftFocus::Pattern && draft.editing.is_none() {
53        rows += (model.event_ids.len() as u16 + 1).min(12);
54    }
55    rows += match draft.kind {
56        HandlerKind::Static => 1 + draft.actions.len() as u16,
57        HandlerKind::Script => 3 + draft.code.lines().count().min(6) as u16,
58        HandlerKind::Llm => 1,
59        // The timeout row plus its two-line explanation.
60        HandlerKind::Manual => 3,
61    };
62    if draft.error.is_some() {
63        rows += 2;
64    }
65    rows
66}
67
68/// An action deferred behind a confirmation. Closure-free so `Modal` stays a
69/// plain data enum that can be inspected and tested.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum PendingAction {
72    StopServer(crate::state::ServerId),
73    StopClient(crate::state::ClientId),
74    StopAll,
75    Quit,
76}
77
78pub enum Modal {
79    /// Keybinding reference.
80    Help { scroll: u16 },
81    /// Full request/response JSON for one access-log entry.
82    RequestDetail {
83        entry: Box<AccessLogEntry>,
84        scroll: u16,
85    },
86    /// Yes/no confirmation carrying the action to run on confirm.
87    Confirm {
88        message: String,
89        action: PendingAction,
90    },
91    /// Web-search approval (ASK mode), answering the oneshot the LLM waits on.
92    WebApproval {
93        url: String,
94        response_tx: tokio::sync::oneshot::Sender<WebApprovalResponse>,
95    },
96    /// Read-only detail of a band's full config.
97    BandDetail { key: UiKey, scroll: u16 },
98    /// The Wireshark / tshark command and filters that capture one instance's
99    /// traffic — reachable from a running instance's row and from the
100    /// create/edit form, so the capture can start before the instance does.
101    Wireshark {
102        plan: Box<crate::tui::wireshark::CapturePlan>,
103        scroll: u16,
104    },
105    /// Choose a protocol — and with it the kind — for a new instance.
106    ProtocolPicker {
107        entries: Vec<ProtocolEntry>,
108        filter: String,
109        selected: usize,
110        /// When set, the created client is aimed at this address (the
111        /// `[+ client]` affordance on a running server).
112        prefill_remote: Option<String>,
113    },
114    /// Create/edit form for an instance.
115    Form(Box<FormModel>),
116    /// Multi-line editor for one form field.
117    TextEditor {
118        editor: Box<TextEditorModel>,
119        target: FieldTarget,
120    },
121    /// Compose and send an action through a running client.
122    Composer(Box<ComposerModel>),
123    /// Edit the instance's handler table (static / script / LLM / manual).
124    Routing(Box<routing::RoutingModel>),
125    /// Answer a request a `manual` rule parked for you.
126    Intercept(Box<intercept::InterceptModel>),
127}
128
129impl Modal {
130    /// Title shown in the modal's border.
131    pub fn title(&self) -> String {
132        match self {
133            Modal::Help { .. } => "Keys".to_string(),
134            Modal::RequestDetail { entry, .. } => format!("Request #{}", entry.id),
135            Modal::Confirm { .. } => "Confirm".to_string(),
136            Modal::WebApproval { .. } => "Web search approval".to_string(),
137            Modal::BandDetail { key, .. } => match key {
138                UiKey::Server(id) => format!("Server #{}", id.as_u32()),
139                UiKey::Client(id) => format!("Client #{}", id.as_u32()),
140            },
141            Modal::Wireshark { plan, .. } => {
142                format!("View in Wireshark — {}", plan.target.protocol)
143            }
144            Modal::ProtocolPicker { .. } => "New server or client — pick a protocol".to_string(),
145            Modal::Form(form) => match &form.mode {
146                form::FormMode::Create(Section::Servers) => {
147                    format!("New {} server", form.protocol)
148                }
149                form::FormMode::Create(Section::Clients) => {
150                    format!("New {} client", form.protocol)
151                }
152                form::FormMode::Edit(UiKey::Server(id)) => {
153                    format!("Edit server #{}", id.as_u32())
154                }
155                form::FormMode::Edit(UiKey::Client(id)) => {
156                    format!("Edit client #{}", id.as_u32())
157                }
158            },
159            Modal::TextEditor { editor, .. } => format!("Edit {}", editor.label),
160            Modal::Composer(composer) => match composer.target {
161                composer::ComposerTarget::Client(id) => {
162                    format!("Send a request — client #{}", id.as_u32())
163                }
164                composer::ComposerTarget::Peer { server, connection } => format!(
165                    "Message peer — connection #{connection} of server #{}",
166                    server.as_u32()
167                ),
168                composer::ComposerTarget::Intercept { id, .. } => {
169                    format!("Answer request #{id} — pick what to send")
170                }
171            },
172            Modal::Routing(model) => match model.key {
173                UiKey::Server(id) => format!("Routing — server #{}", id.as_u32()),
174                UiKey::Client(id) => format!("Auto-reply rules — client #{}", id.as_u32()),
175            },
176            Modal::Intercept(model) => match model.owner {
177                UiKey::Server(id) => {
178                    format!("Answer this request — server #{}", id.as_u32())
179                }
180                UiKey::Client(id) => {
181                    format!("Answer this reply — client #{}", id.as_u32())
182                }
183            },
184        }
185    }
186
187    /// Footer hint line for the modal.
188    pub fn hint(&self) -> &'static str {
189        match self {
190            Modal::Help { .. } => "↑/↓ scroll · Esc close",
191            Modal::RequestDetail { .. } | Modal::BandDetail { .. } => "↑/↓ scroll · Esc close",
192            Modal::Wireshark { .. } => {
193                "Ctrl-T frees the mouse to select text · ↑/↓ scroll · Esc close"
194            }
195            Modal::Confirm { .. } => "y confirm · n/Esc cancel",
196            Modal::WebApproval { .. } => "y allow once · a always · n/Esc deny",
197            Modal::ProtocolPicker { .. } => {
198                "type to filter · ↑/↓ select · Enter choose · Esc cancel"
199            }
200            Modal::Form(form) => {
201                if form.busy {
202                    "working… (network call in flight)"
203                } else if form.editing.is_some() {
204                    "type to edit · Enter accept · Esc cancel field"
205                } else {
206                    "↑/↓ field · Enter edit · Tab → buttons · Esc cancel"
207                }
208            }
209            Modal::TextEditor { editor, .. } => {
210                if editor.focused_button.is_some() {
211                    "Tab next button · Enter press · type to go back to the text"
212                } else {
213                    "Tab → buttons · Esc cancel"
214                }
215            }
216            Modal::Composer(composer) => {
217                if composer.busy {
218                    "sending…"
219                } else if composer.editing.is_some() {
220                    "type to edit · Enter accept · Esc cancel field"
221                } else if composer.chosen.is_some() {
222                    "↑/↓ field · Enter edit (Space flips a checkbox) · Tab → buttons · Esc back"
223                } else {
224                    "↑/↓ action · Enter choose · Esc cancel"
225                }
226            }
227            Modal::Routing(model) => {
228                if model.busy {
229                    "applying…"
230                } else if model.draft.is_some() {
231                    "Tab moves through everything · ←/→ change a choice · Enter act · Esc back"
232                } else {
233                    "Tab moves through everything · Enter act · Esc close"
234                }
235            }
236            Modal::Intercept(_) => {
237                "Tab moves between the buttons · Enter act · Esc keeps it waiting"
238            }
239        }
240    }
241
242    /// How big this modal wants to be.
243    ///
244    /// Everything used to be 85% x 85%, so a two-line confirmation and a
245    /// four-field form both opened as a near-fullscreen box of whitespace.
246    /// Each modal now caps itself at what its content actually needs; the
247    /// percentages only bound it on a small terminal.
248    pub fn size(&self) -> ModalSize {
249        // Border top + bottom, plus the button row where there is one.
250        const CHROME: u16 = 4;
251        // Scrolling modals want the screen; content-sized ones do not.
252        const TALL: u16 = 500;
253
254        match self {
255            Modal::Confirm { .. } => ModalSize::new(50, 40, 72, 5),
256            Modal::WebApproval { .. } => ModalSize::new(60, 40, 88, 8),
257            Modal::Help { .. } => ModalSize::new(72, 88, 92, TALL),
258            Modal::RequestDetail { .. } | Modal::BandDetail { .. } => {
259                ModalSize::new(80, 85, 140, TALL)
260            }
261            // A copyable command line plus its explanation: wide, and tall
262            // enough to scroll rather than sized to the content.
263            Modal::Wireshark { .. } => ModalSize::new(80, 75, 132, TALL),
264            Modal::ProtocolPicker { .. } => ModalSize::new(78, 80, 128, TALL),
265            Modal::TextEditor { .. } => ModalSize::new(76, 70, 120, TALL),
266            Modal::Form(form) => {
267                // One row per field, plus the help line under the selected one.
268                let rows = form.fields.len() as u16 + 3;
269                ModalSize::new(70, 70, 104, rows + CHROME)
270            }
271            Modal::Composer(composer) => {
272                let rows = match composer.chosen {
273                    // Parameter form: a row per field, the help line under the
274                    // selected one (which can wrap to three), the heading (which
275                    // wraps too for long descriptions), or the raw JSON text.
276                    Some(_) => match &composer.raw_json {
277                        Some(raw) => raw.lines().count() as u16 + 5,
278                        None => composer.fields.len() as u16 + 9,
279                    },
280                    // Action list: one row each, plus the heading.
281                    None => composer.actions.len() as u16 + 3,
282                };
283                // The validation line ("'code' is required") lives below the
284                // fields; without room for it the popup clipped it and a send
285                // with an empty required field looked like nothing happened.
286                let error = if composer.error.is_some() { 2 } else { 0 };
287                ModalSize::new(70, 75, 112, rows + error + CHROME)
288            }
289            Modal::Intercept(model) => {
290                let payload = model
291                    .event_data
292                    .as_ref()
293                    .map(|data| {
294                        serde_json::to_string_pretty(data)
295                            .map(|text| text.lines().count() as u16)
296                            .unwrap_or(1)
297                            .min(20)
298                            + 1
299                    })
300                    .unwrap_or(0);
301                ModalSize::new(72, 75, 120, payload + 7 + CHROME)
302            }
303            Modal::Routing(model) => {
304                let rows = match &model.draft {
305                    Some(draft) => draft_rows(model, draft),
306                    // Heading, a row per rule, the fallback note.
307                    None => model.handlers.len().max(1) as u16 + 5,
308                };
309                ModalSize::new(74, 80, 124, rows + CHROME)
310            }
311        }
312    }
313
314    pub fn scroll_by(&mut self, delta: i16) {
315        let slot = match self {
316            Modal::Help { scroll }
317            | Modal::RequestDetail { scroll, .. }
318            | Modal::BandDetail { scroll, .. }
319            | Modal::Wireshark { scroll, .. } => scroll,
320            _ => return,
321        };
322        *slot = if delta < 0 {
323            slot.saturating_sub(delta.unsigned_abs())
324        } else {
325            slot.saturating_add(delta as u16)
326        };
327    }
328}