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