1pub 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#[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
46fn draft_rows(model: &routing::RoutingModel, draft: &routing::HandlerDraft) -> u16 {
48 use routing::{DraftFocus, HandlerKind};
49 let mut rows = 5u16;
51 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 HandlerKind::Manual => 3,
61 };
62 if draft.error.is_some() {
63 rows += 2;
64 }
65 rows
66}
67
68#[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 Help { scroll: u16 },
81 RequestDetail {
83 entry: Box<AccessLogEntry>,
84 scroll: u16,
85 },
86 Confirm {
88 message: String,
89 action: PendingAction,
90 },
91 WebApproval {
93 url: String,
94 response_tx: tokio::sync::oneshot::Sender<WebApprovalResponse>,
95 },
96 BandDetail { key: UiKey, scroll: u16 },
98 Wireshark {
102 plan: Box<crate::tui::wireshark::CapturePlan>,
103 scroll: u16,
104 },
105 ProtocolPicker {
107 entries: Vec<ProtocolEntry>,
108 filter: String,
109 selected: usize,
110 prefill_remote: Option<String>,
113 },
114 Form(Box<FormModel>),
116 TextEditor {
118 editor: Box<TextEditorModel>,
119 target: FieldTarget,
120 },
121 Composer(Box<ComposerModel>),
123 Routing(Box<routing::RoutingModel>),
125 Intercept(Box<intercept::InterceptModel>),
127}
128
129impl Modal {
130 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 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 pub fn size(&self) -> ModalSize {
249 const CHROME: u16 = 4;
251 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 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 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 Some(_) => match &composer.raw_json {
277 Some(raw) => raw.lines().count() as u16 + 5,
278 None => composer.fields.len() as u16 + 9,
279 },
280 None => composer.actions.len() as u16 + 3,
282 };
283 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 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}