src/input.rs 16.4 K raw
1
//! Vim-style key handling.
2
3
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5
use crate::app::{
6
    App, CellCol, EditTarget, EditorTab, ExternalEdit, Focus, Mode, Popup, SidebarRow,
7
};
8
9
pub fn handle_key(app: &mut App, key: KeyEvent) {
10
    if app.popup != Popup::None {
11
        handle_popup(app, key);
12
        return;
13
    }
14
    match app.mode {
15
        Mode::Command => handle_command(app, key),
16
        Mode::Insert => handle_insert(app, key),
17
        Mode::Search => handle_search(app, key),
18
        Mode::Normal => handle_normal(app, key),
19
    }
20
}
21
22
// ----- Normal mode -----
23
24
fn handle_normal(app: &mut App, key: KeyEvent) {
25
    match (key.modifiers, key.code) {
26
        (_, KeyCode::Char(':')) => {
27
            app.command.clear();
28
            app.mode = Mode::Command;
29
        }
30
        (_, KeyCode::Char('?')) => {
31
            app.help_scroll = 0;
32
            app.popup = Popup::Help;
33
        }
34
        (_, KeyCode::Char('q')) => app.try_quit(),
35
        (_, KeyCode::Tab) => {
36
            app.focus = match app.focus {
37
                Focus::Sidebar => Focus::Editor,
38
                Focus::Editor => Focus::Response,
39
                Focus::Response => Focus::Sidebar,
40
            };
41
        }
42
        (_, KeyCode::BackTab) => {
43
            app.focus = match app.focus {
44
                Focus::Sidebar => Focus::Response,
45
                Focus::Editor => Focus::Sidebar,
46
                Focus::Response => Focus::Editor,
47
            };
48
        }
49
        (_, KeyCode::Char('z')) => {
50
            app.zoom = !app.zoom;
51
            app.status = if app.zoom {
52
                "Pane maximized — z to restore".into()
53
            } else {
54
                "Panes restored".into()
55
            };
56
        }
57
        (_, KeyCode::Char('1')) => app.focus = Focus::Sidebar,
58
        (_, KeyCode::Char('2')) => app.focus = Focus::Editor,
59
        (_, KeyCode::Char('3')) => app.focus = Focus::Response,
60
61
        // Editor tab cycling. `]`/`[` are the primary bindings; `L`/`H` are
62
        // aliases for keyboards where the brackets are awkward to reach.
63
        (_, KeyCode::Char(']') | KeyCode::Char('L')) => {
64
            app.tab = app.tab.next();
65
            app.table_row = 0;
66
        }
67
        (_, KeyCode::Char('[') | KeyCode::Char('H')) => {
68
            app.tab = app.tab.prev();
69
            app.table_row = 0;
70
        }
71
        (m, KeyCode::Right) if m.contains(KeyModifiers::SHIFT) => {
72
            app.tab = app.tab.next();
73
            app.table_row = 0;
74
        }
75
        (m, KeyCode::Left) if m.contains(KeyModifiers::SHIFT) => {
76
            app.tab = app.tab.prev();
77
            app.table_row = 0;
78
        }
79
80
        (_, KeyCode::Char('/')) => app.start_search(),
81
82
        (_, KeyCode::Char('E')) => {
83
            app.env_sel = app.collection.active_server;
84
            app.popup = Popup::Env;
85
        }
86
        (_, KeyCode::Char('A')) => app.open_auth_popup(),
87
88
        _ => match app.focus {
89
            Focus::Sidebar => normal_sidebar(app, key),
90
            Focus::Editor => normal_editor(app, key),
91
            Focus::Response => normal_response(app, key),
92
        },
93
    }
94
}
95
96
fn normal_sidebar(app: &mut App, key: KeyEvent) {
97
    match key.code {
98
        KeyCode::Char('j') | KeyCode::Down if app.sidebar_sel + 1 < app.sidebar_rows.len() => {
99
            app.sidebar_sel += 1;
100
        }
101
        KeyCode::Char('k') | KeyCode::Up => {
102
            app.sidebar_sel = app.sidebar_sel.saturating_sub(1);
103
        }
104
        KeyCode::Char('g') => app.sidebar_sel = 0,
105
        KeyCode::Char('G') => {
106
            app.sidebar_sel = app.sidebar_rows.len().saturating_sub(1);
107
        }
108
        KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('l') | KeyCode::Char('h') => {
109
            app.activate_sidebar()
110
        }
111
        KeyCode::Esc => app.clear_filter(),
112
        KeyCode::Char('t') => app.cycle_label_mode(),
113
        KeyCode::Char('n') => app.start_edit(EditTarget::NewRequest),
114
        KeyCode::Char('r') => {
115
            if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() {
116
                // Select the highlighted request but keep focus in the sidebar.
117
                app.select_request(idx);
118
                app.focus = Focus::Sidebar;
119
                app.start_edit(EditTarget::Rename);
120
            }
121
        }
122
        KeyCode::Char('d') => {
123
            if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() {
124
                app.collection.requests.remove(idx);
125
                app.dirty = true;
126
                if app.selected == Some(idx) {
127
                    app.selected = None;
128
                    app.set_body_text("");
129
                }
130
                app.selected = app.selected.map(|s| if s > idx { s - 1 } else { s });
131
                app.rebuild_sidebar();
132
                app.status = "Request deleted".into();
133
            }
134
        }
135
        // `y` for yank, rather than something next to destructive `d`.
136
        KeyCode::Char('y') => {
137
            if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() {
138
                app.duplicate_request(idx);
139
                // `duplicate_request` opens the clone, which moves focus to the
140
                // editor; stay here so repeated `y` works (same as `r`).
141
                app.focus = Focus::Sidebar;
142
            }
143
        }
144
        _ => {}
145
    }
146
}
147
148
fn normal_editor(app: &mut App, key: KeyEvent) {
149
    if app.tab == EditorTab::Body && body_scroll(app, key) {
150
        return;
151
    }
152
    if app.tab == EditorTab::Docs && docs_scroll(app, key) {
153
        return;
154
    }
155
    match key.code {
156
        KeyCode::Enter => app.send_selected(),
157
        KeyCode::Char('j') | KeyCode::Down => {
158
            if let Some(t) = app.tab.table() {
159
                let len = app.table_len(t);
160
                if len > 0 && app.table_row + 1 < len {
161
                    app.table_row += 1;
162
                }
163
            }
164
        }
165
        KeyCode::Char('k') | KeyCode::Up if app.tab.table().is_some() => {
166
            app.table_row = app.table_row.saturating_sub(1);
167
        }
168
        KeyCode::Char('g') => app.table_row = 0,
169
        KeyCode::Char('G') => {
170
            if let Some(t) = app.tab.table() {
171
                app.table_row = app.table_len(t).saturating_sub(1);
172
            }
173
        }
174
        KeyCode::Char(' ') => {
175
            if let Some(t) = app.tab.table() {
176
                app.toggle_row(t, app.table_row);
177
            }
178
        }
179
        // Body has no in-app editor — use `e` for `$EDITOR`. Tables edit inline.
180
        KeyCode::Char('i') | KeyCode::Char('a') => {
181
            let is_add = key.code == KeyCode::Char('a');
182
            if let Some(t) = app.tab.table() {
183
                if is_add {
184
                    app.add_row(t);
185
                } else if app.table_row < app.table_len(t) {
186
                    app.chain_to_value = false;
187
                    app.start_edit(EditTarget::Cell {
188
                        table: t,
189
                        row: app.table_row,
190
                        col: CellCol::Value,
191
                    });
192
                }
193
            }
194
        }
195
        KeyCode::Char('d') => {
196
            if let Some(t) = app.tab.table() {
197
                app.delete_row(t, app.table_row);
198
            }
199
        }
200
        KeyCode::Char('e') if app.tab == EditorTab::Body && app.selected.is_some() => {
201
            app.pending_external = Some(ExternalEdit::Body);
202
        }
203
        KeyCode::Char('m') => cycle_method(app),
204
        KeyCode::Char('r') if app.selected.is_some() => {
205
            app.start_edit(EditTarget::Rename);
206
        }
207
        // `p` for path/paste. Not `u` — that is page-up on every scrollable pane.
208
        KeyCode::Char('p') if app.selected.is_some() => {
209
            app.start_edit(EditTarget::Url);
210
        }
211
        _ => {}
212
    }
213
}
214
215
/// Body-tab movement. The read-only body view is a plain scroll offset,
216
/// clamped against the rendered height in `ui::draw_body`. Returns whether
217
/// the key was consumed.
218
fn body_scroll(app: &mut App, key: KeyEvent) -> bool {
219
    match key.code {
220
        KeyCode::Char('j') | KeyCode::Down => app.body_scroll += 1,
221
        KeyCode::Char('k') | KeyCode::Up => app.body_scroll = app.body_scroll.saturating_sub(1),
222
        KeyCode::Char('g') => app.body_scroll = 0,
223
        KeyCode::Char('G') => app.body_scroll = usize::MAX / 2,
224
        KeyCode::Char('d') => app.body_scroll += 15,
225
        KeyCode::Char('u') => app.body_scroll = app.body_scroll.saturating_sub(15),
226
        _ => return false,
227
    }
228
    true
229
}
230
231
/// Docs-tab scrolling. The tab is read-only, so `d`/`u` page here instead of
232
/// deleting rows. Clamped against the rendered height in `ui::draw_docs`.
233
fn docs_scroll(app: &mut App, key: KeyEvent) -> bool {
234
    match key.code {
235
        KeyCode::Char('j') | KeyCode::Down => app.docs_scroll += 1,
236
        KeyCode::Char('k') | KeyCode::Up => app.docs_scroll = app.docs_scroll.saturating_sub(1),
237
        KeyCode::Char('d') => app.docs_scroll += 15,
238
        KeyCode::Char('u') => app.docs_scroll = app.docs_scroll.saturating_sub(15),
239
        KeyCode::Char('g') => app.docs_scroll = 0,
240
        KeyCode::Char('G') => app.docs_scroll = usize::MAX / 2,
241
        _ => return false,
242
    }
243
    true
244
}
245
246
fn cycle_method(app: &mut App) {
247
    let Some(i) = app.selected else { return };
248
    use crate::model::Method::*;
249
    let req = &mut app.collection.requests[i];
250
    req.method = match req.method {
251
        Get => Post,
252
        Post => Put,
253
        Put => Patch,
254
        Patch => Delete,
255
        Delete => Head,
256
        Head => Options,
257
        Options => Get,
258
    };
259
    app.dirty = true;
260
}
261
262
fn normal_response(app: &mut App, key: KeyEvent) {
263
    match key.code {
264
        KeyCode::Char('j') | KeyCode::Down => app.response_scroll += 1,
265
        KeyCode::Char('k') | KeyCode::Up => {
266
            app.response_scroll = app.response_scroll.saturating_sub(1)
267
        }
268
        KeyCode::Char('g') => app.response_scroll = 0,
269
        KeyCode::Char('G') => app.response_scroll = usize::MAX / 2, // clamped at render
270
        KeyCode::Char('d') => app.response_scroll += 15,
271
        KeyCode::Char('u') => app.response_scroll = app.response_scroll.saturating_sub(15),
272
        KeyCode::Char('e') if app.response.is_some() => {
273
            app.pending_external = Some(ExternalEdit::Response);
274
        }
275
        _ => {}
276
    }
277
}
278
279
// ----- Insert mode -----
280
281
fn handle_insert(app: &mut App, key: KeyEvent) {
282
    if app.editing.is_some() {
283
        match key.code {
284
            KeyCode::Enter => app.commit_edit(),
285
            KeyCode::Esc => app.cancel_edit(),
286
            KeyCode::Char(c) => app.input.insert(c),
287
            KeyCode::Backspace => app.input.backspace(),
288
            KeyCode::Delete => app.input.delete(),
289
            KeyCode::Left => app.input.left(),
290
            KeyCode::Right => app.input.right(),
291
            KeyCode::Home => app.input.home(),
292
            KeyCode::End => app.input.end(),
293
            _ => {}
294
        }
295
        return;
296
    }
297
    // No in-app body editor: body is edited via `$EDITOR` (`e`). Any stray
298
    // Insert-mode key just returns to Normal.
299
    if key.code == KeyCode::Esc {
300
        app.mode = Mode::Normal;
301
    }
302
}
303
304
// ----- Search mode (sidebar filter) -----
305
306
/// The filter is applied on every keystroke; `Enter` keeps it, `Esc` drops it.
307
fn handle_search(app: &mut App, key: KeyEvent) {
308
    match key.code {
309
        KeyCode::Enter => {
310
            app.finish_search();
311
            return;
312
        }
313
        KeyCode::Esc => {
314
            app.search.set("");
315
            app.apply_search();
316
            app.mode = Mode::Normal;
317
            app.status = "Filter cleared".into();
318
            return;
319
        }
320
        KeyCode::Char(c) => app.search.insert(c),
321
        KeyCode::Backspace => app.search.backspace(),
322
        KeyCode::Delete => app.search.delete(),
323
        KeyCode::Left => app.search.left(),
324
        KeyCode::Right => app.search.right(),
325
        KeyCode::Home => app.search.home(),
326
        KeyCode::End => app.search.end(),
327
        KeyCode::Down => {
328
            // Step through matches without leaving the prompt.
329
            if app.sidebar_sel + 1 < app.sidebar_rows.len() {
330
                app.sidebar_sel += 1;
331
            }
332
            return;
333
        }
334
        KeyCode::Up => {
335
            app.sidebar_sel = app.sidebar_sel.saturating_sub(1);
336
            return;
337
        }
338
        _ => return,
339
    }
340
    app.apply_search();
341
}
342
343
// ----- Command mode -----
344
345
fn handle_command(app: &mut App, key: KeyEvent) {
346
    match key.code {
347
        KeyCode::Enter => app.exec_command(),
348
        KeyCode::Esc => {
349
            app.command.clear();
350
            app.mode = Mode::Normal;
351
        }
352
        KeyCode::Char(c) => app.command.push(c),
353
        KeyCode::Backspace => {
354
            app.command.pop();
355
        }
356
        _ => {}
357
    }
358
}
359
360
// ----- Popups -----
361
362
fn handle_popup(app: &mut App, key: KeyEvent) {
363
    // While a popup field is being edited, input goes to the line editor.
364
    if app.editing.is_some() {
365
        match key.code {
366
            KeyCode::Enter => app.commit_edit(),
367
            KeyCode::Esc => app.cancel_edit(),
368
            KeyCode::Char(c) => app.input.insert(c),
369
            KeyCode::Backspace => app.input.backspace(),
370
            KeyCode::Delete => app.input.delete(),
371
            KeyCode::Left => app.input.left(),
372
            KeyCode::Right => app.input.right(),
373
            _ => {}
374
        }
375
        return;
376
    }
377
378
    match app.popup {
379
        Popup::Help => match key.code {
380
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => app.popup = Popup::None,
381
            // Clamped against the rendered height in `ui::draw_help`.
382
            KeyCode::Char('j') | KeyCode::Down => app.help_scroll += 1,
383
            KeyCode::Char('k') | KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1),
384
            KeyCode::Char('d') => app.help_scroll += 10,
385
            KeyCode::Char('u') => app.help_scroll = app.help_scroll.saturating_sub(10),
386
            KeyCode::Char('g') => app.help_scroll = 0,
387
            KeyCode::Char('G') => app.help_scroll = usize::MAX / 2,
388
            _ => {}
389
        },
390
        Popup::Env => match key.code {
391
            KeyCode::Esc | KeyCode::Char('q') => app.popup = Popup::None,
392
            KeyCode::Char('j') | KeyCode::Down
393
                if app.env_sel + 1 < app.collection.servers.len() =>
394
            {
395
                app.env_sel += 1;
396
            }
397
            KeyCode::Char('k') | KeyCode::Up => {
398
                app.env_sel = app.env_sel.saturating_sub(1);
399
            }
400
            KeyCode::Enter => {
401
                if app.env_sel < app.collection.servers.len() {
402
                    app.collection.active_server = app.env_sel;
403
                    app.dirty = true;
404
                    app.status = format!("Base URL: {}", app.collection.servers[app.env_sel]);
405
                }
406
                app.popup = Popup::None;
407
            }
408
            KeyCode::Char('a') => app.start_edit(EditTarget::EnvNew),
409
            KeyCode::Char('d') if app.env_sel < app.collection.servers.len() => {
410
                app.collection.servers.remove(app.env_sel);
411
                if app.collection.active_server >= app.collection.servers.len() {
412
                    app.collection.active_server = app.collection.servers.len().saturating_sub(1);
413
                }
414
                app.env_sel = app.env_sel.saturating_sub(1);
415
                app.dirty = true;
416
            }
417
            _ => {}
418
        },
419
        Popup::Auth => {
420
            let count = app.auth_fields().len();
421
            match key.code {
422
                KeyCode::Esc => {
423
                    app.apply_auth_form();
424
                    app.popup = Popup::None;
425
                    app.status = "Auth config saved".into();
426
                }
427
                KeyCode::Char('j') | KeyCode::Down | KeyCode::Tab => {
428
                    app.auth_field = (app.auth_field + 1) % count;
429
                }
430
                KeyCode::Char('k') | KeyCode::Up | KeyCode::BackTab => {
431
                    app.auth_field = (app.auth_field + count - 1) % count;
432
                }
433
                KeyCode::Enter | KeyCode::Char('i') => {
434
                    if app.auth_field_at(app.auth_field).is_toggle() {
435
                        app.toggle_auth_field(app.auth_field);
436
                    } else {
437
                        app.start_edit(EditTarget::AuthField(app.auth_field));
438
                    }
439
                }
440
                KeyCode::Char(' ') if app.auth_field_at(app.auth_field).is_toggle() => {
441
                    app.toggle_auth_field(app.auth_field);
442
                }
443
                _ => {}
444
            }
445
        }
446
        Popup::None => {}
447
    }
448
}
449
450
// Keep KeyModifiers referenced for future Ctrl bindings.
451
#[allow(dead_code)]
452
fn _has_ctrl(key: &KeyEvent) -> bool {
453
    key.modifiers.contains(KeyModifiers::CONTROL)
454
}