| 10 |
10 |
|
use std::time::Duration;
|
| 11 |
11 |
|
|
| 12 |
12 |
|
use alloy_tui::keys::{Action, classify};
|
| 13 |
|
- |
use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
|
|
13 |
+ |
use alloy_tui::{
|
|
14 |
+ |
AlloyKeymap, AlloyLog, AlloyModal, AlloyStatusBar, Hint, KeyGroup, Severity, Theme, binding,
|
|
15 |
+ |
hint, layout,
|
|
16 |
+ |
};
|
| 14 |
17 |
|
use anyhow::Result;
|
| 15 |
18 |
|
use ratatui::Frame;
|
| 16 |
19 |
|
use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
|
| 74 |
77 |
|
/// so a view lists only its own keys.
|
| 75 |
78 |
|
fn hints(&self) -> Vec<Hint>;
|
| 76 |
79 |
|
|
|
80 |
+ |
/// Everything this view can do, for the `?` overlay.
|
|
81 |
+ |
///
|
|
82 |
+ |
/// The default derives one group from [`hints`](View::hints), so every view
|
|
83 |
+ |
/// answers `?` with something true the day it is written. Override it to say
|
|
84 |
+ |
/// more than a footer has room for: a binding the footer omits for space, or
|
|
85 |
+ |
/// one that is real but unavailable right now and should be shown dimmed
|
|
86 |
+ |
/// with a reason rather than left out.
|
|
87 |
+ |
fn keys(&self) -> Vec<KeyGroup<'static>> {
|
|
88 |
+ |
vec![KeyGroup::new(
|
|
89 |
+ |
"this pane",
|
|
90 |
+ |
self.hints()
|
|
91 |
+ |
.into_iter()
|
|
92 |
+ |
.map(|h| binding(h.key, h.label))
|
|
93 |
+ |
.collect(),
|
|
94 |
+ |
)]
|
|
95 |
+ |
}
|
|
96 |
+ |
|
|
97 |
+ |
/// Reserved actions this view does not answer, shown dimmed in the overlay
|
|
98 |
+ |
/// rather than promising something that will not happen. A view with no tabs
|
|
99 |
+ |
/// names the tab movers here.
|
|
100 |
+ |
fn unanswered(&self) -> &'static [Action] {
|
|
101 |
+ |
&[]
|
|
102 |
+ |
}
|
|
103 |
+ |
|
| 77 |
104 |
|
/// Transient status for the right end of the footer, if any.
|
| 78 |
105 |
|
fn status(&self) -> Option<(Severity, String)> {
|
| 79 |
106 |
|
None
|
| 192 |
219 |
|
// confirm is a design smell, and an unbounded stack turns Esc into "how
|
| 193 |
220 |
|
// many times do I press this" rather than "back out".
|
| 194 |
221 |
|
let mut modal: Option<Confirm> = None;
|
|
222 |
+ |
// The `?` overlay. A flag rather than a slot: it carries no state of its
|
|
223 |
+ |
// own, being rebuilt from the view every frame like the rest of the chrome.
|
|
224 |
+ |
let mut help = false;
|
| 195 |
225 |
|
|
| 196 |
226 |
|
loop {
|
| 197 |
|
- |
terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?;
|
|
227 |
+ |
terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref(), help))?;
|
| 198 |
228 |
|
|
| 199 |
229 |
|
// Poll rather than block, so a view can refresh state that changes
|
| 200 |
230 |
|
// without the user. `poll` returns as soon as an event arrives, so
|
| 238 |
268 |
|
continue;
|
| 239 |
269 |
|
}
|
| 240 |
270 |
|
|
|
271 |
+ |
// The overlay owns every key while it is open, for the reason the modal
|
|
272 |
+ |
// does: a user who opened it to find out what a key does should not
|
|
273 |
+ |
// discover the answer by having it happen behind the overlay. It sits
|
|
274 |
+ |
// after the modal because a confirm raised before it still outranks it.
|
|
275 |
+ |
if help {
|
|
276 |
+ |
if help_key(action) == HelpOutcome::Close {
|
|
277 |
+ |
help = false;
|
|
278 |
+ |
}
|
|
279 |
+ |
continue;
|
|
280 |
+ |
}
|
|
281 |
+ |
|
| 241 |
282 |
|
// Both reserved claims are the view's to decline, so the routing is
|
| 242 |
283 |
|
// decided first and the flow it produces is handled in one place.
|
| 243 |
284 |
|
let flow = match reserved(action, view.text_entry()) {
|
| 244 |
285 |
|
Reserved::Cancel => view.cancel(),
|
| 245 |
286 |
|
Reserved::Quit => view.quit(),
|
|
287 |
+ |
Reserved::Help => {
|
|
288 |
+ |
help = true;
|
|
289 |
+ |
Flow::Continue
|
|
290 |
+ |
}
|
| 246 |
291 |
|
Reserved::Pass => view.handle(key, log),
|
| 247 |
292 |
|
};
|
| 248 |
293 |
|
|
| 262 |
307 |
|
Cancel,
|
| 263 |
308 |
|
/// `q` with no text field open: close the view.
|
| 264 |
309 |
|
Quit,
|
|
310 |
+ |
/// `?` with no text field open: open the keymap overlay.
|
|
311 |
+ |
Help,
|
| 265 |
312 |
|
/// Not the shell's, hand it to the view.
|
| 266 |
313 |
|
Pass,
|
| 267 |
314 |
|
}
|
| 280 |
327 |
|
match action {
|
| 281 |
328 |
|
Action::Cancel => Reserved::Cancel,
|
| 282 |
329 |
|
Action::Quit if !text_entry => Reserved::Quit,
|
|
330 |
+ |
// Gated on text entry for the same reason `q` is, and it is the same
|
|
331 |
+ |
// bug: `?` is a character someone types into a hostname or a filter,
|
|
332 |
+ |
// and a help screen opening mid-word is the classifier's documented
|
|
333 |
+ |
// caller obligation going unmet.
|
|
334 |
+ |
Action::Help if !text_entry => Reserved::Help,
|
| 283 |
335 |
|
_ => Reserved::Pass,
|
| 284 |
336 |
|
}
|
| 285 |
337 |
|
}
|
| 286 |
338 |
|
|
|
339 |
+ |
/// What a key does to the open keymap overlay.
|
|
340 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
341 |
+ |
enum HelpOutcome {
|
|
342 |
+ |
Close,
|
|
343 |
+ |
Ignored,
|
|
344 |
+ |
}
|
|
345 |
+ |
|
|
346 |
+ |
/// Route a reserved action against the open overlay.
|
|
347 |
+ |
///
|
|
348 |
+ |
/// Everything that means "get me out of here" closes it, and nothing else does
|
|
349 |
+ |
/// anything. `?` closes it too, so the key that opened it is also the key that
|
|
350 |
+ |
/// dismisses it and a user who pressed it by accident does not have to guess.
|
|
351 |
+ |
/// `q` closes the overlay rather than the console, matching the modal: while
|
|
352 |
+ |
/// something is over the view, leaving means leaving that.
|
|
353 |
+ |
const fn help_key(action: Action) -> HelpOutcome {
|
|
354 |
+ |
match action {
|
|
355 |
+ |
Action::Cancel | Action::Help | Action::Quit => HelpOutcome::Close,
|
|
356 |
+ |
_ => HelpOutcome::Ignored,
|
|
357 |
+ |
}
|
|
358 |
+ |
}
|
|
359 |
+ |
|
| 287 |
360 |
|
/// What a key does to an open modal.
|
| 288 |
361 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 289 |
362 |
|
enum ModalOutcome {
|
| 351 |
424 |
|
view: &dyn View,
|
| 352 |
425 |
|
log: &mut CommandLog,
|
| 353 |
426 |
|
modal: Option<&Confirm>,
|
|
427 |
+ |
help: bool,
|
| 354 |
428 |
|
) {
|
| 355 |
429 |
|
let areas = layout::console(frame.area());
|
| 356 |
430 |
|
|
| 375 |
449 |
|
area,
|
| 376 |
450 |
|
);
|
| 377 |
451 |
|
}
|
|
452 |
+ |
|
|
453 |
+ |
// Over the whole frame rather than the body, unlike the modal. A confirm is
|
|
454 |
+ |
// about the thing on the log's last line and wants it visible; the keymap is
|
|
455 |
+ |
// about the console itself and wants the room.
|
|
456 |
+ |
if help {
|
|
457 |
+ |
let area = layout::centered(frame.area(), HELP_WIDTH, HELP_HEIGHT);
|
|
458 |
+ |
frame.render_widget(
|
|
459 |
+ |
AlloyKeymap::new(theme, &view.title(), view.keys()).unavailable(view.unanswered()),
|
|
460 |
+ |
area,
|
|
461 |
+ |
);
|
|
462 |
+ |
}
|
| 378 |
463 |
|
}
|
| 379 |
464 |
|
|
| 380 |
465 |
|
/// Modal box size. Wide enough for a package name plus a sentence about what
|
| 382 |
467 |
|
const MODAL_WIDTH: u16 = 54;
|
| 383 |
468 |
|
const MODAL_HEIGHT: u16 = 7;
|
| 384 |
469 |
|
|
|
470 |
+ |
/// Keymap overlay size. Tall enough for a view's own keys plus the eleven
|
|
471 |
+ |
/// reserved ones without truncating on a standard terminal, and `centered`
|
|
472 |
+ |
/// clamps it on anything smaller.
|
|
473 |
+ |
const HELP_WIDTH: u16 = 52;
|
|
474 |
+ |
const HELP_HEIGHT: u16 = 24;
|
|
475 |
+ |
|
| 385 |
476 |
|
/// Title text for a view's body block, padded so it does not sit flush against
|
| 386 |
477 |
|
/// the border corner.
|
| 387 |
478 |
|
pub(crate) fn block_title(title: &str) -> String {
|
| 590 |
681 |
|
Action::Save,
|
| 591 |
682 |
|
Action::Filter,
|
| 592 |
683 |
|
Action::Command,
|
| 593 |
|
- |
Action::Help,
|
| 594 |
684 |
|
Action::Passthrough,
|
| 595 |
685 |
|
] {
|
| 596 |
686 |
|
assert_eq!(reserved(action, false), Reserved::Pass, "{action:?}");
|
| 598 |
688 |
|
}
|
| 599 |
689 |
|
}
|
| 600 |
690 |
|
|
|
691 |
+ |
// `?` is a character, so it belongs to a text field that is open, exactly
|
|
692 |
+ |
// like `q`. Without this a user typing a hostname with a question mark in it
|
|
693 |
+ |
// gets a help screen mid-word.
|
|
694 |
+ |
#[test]
|
|
695 |
+ |
fn question_mark_opens_help_unless_something_is_being_typed() {
|
|
696 |
+ |
assert_eq!(reserved(Action::Help, false), Reserved::Help);
|
|
697 |
+ |
assert_eq!(reserved(Action::Help, true), Reserved::Pass);
|
|
698 |
+ |
}
|
|
699 |
+ |
|
|
700 |
+ |
// Whatever means "get me out" closes the overlay, including the key that
|
|
701 |
+ |
// opened it, so an accidental `?` does not need a guess to undo.
|
|
702 |
+ |
#[test]
|
|
703 |
+ |
fn any_way_out_closes_the_keymap_overlay() {
|
|
704 |
+ |
assert_eq!(help_key(Action::Cancel), HelpOutcome::Close);
|
|
705 |
+ |
assert_eq!(help_key(Action::Help), HelpOutcome::Close);
|
|
706 |
+ |
assert_eq!(help_key(Action::Quit), HelpOutcome::Close);
|
|
707 |
+ |
}
|
|
708 |
+ |
|
|
709 |
+ |
// And q closes the overlay rather than the console, matching the modal.
|
|
710 |
+ |
// Quitting the whole console from behind an overlay the user opened to read
|
|
711 |
+ |
// is the one outcome this must not have.
|
|
712 |
+ |
#[test]
|
|
713 |
+ |
fn q_closes_the_overlay_rather_than_the_console() {
|
|
714 |
+ |
assert_eq!(help_key(Action::Quit), HelpOutcome::Close);
|
|
715 |
+ |
}
|
|
716 |
+ |
|
|
717 |
+ |
#[test]
|
|
718 |
+ |
fn the_overlay_swallows_everything_that_is_not_a_way_out() {
|
|
719 |
+ |
for action in [
|
|
720 |
+ |
Action::NextFocus,
|
|
721 |
+ |
Action::PrevFocus,
|
|
722 |
+ |
Action::NextTab,
|
|
723 |
+ |
Action::PrevTab,
|
|
724 |
+ |
Action::Activate,
|
|
725 |
+ |
Action::Save,
|
|
726 |
+ |
Action::Filter,
|
|
727 |
+ |
Action::Command,
|
|
728 |
+ |
Action::Passthrough,
|
|
729 |
+ |
] {
|
|
730 |
+ |
assert_eq!(help_key(action), HelpOutcome::Ignored, "{action:?}");
|
|
731 |
+ |
}
|
|
732 |
+ |
}
|
|
733 |
+ |
|
| 601 |
734 |
|
// No shipped view takes text input, so the default must leave all four of
|
| 602 |
735 |
|
// them on exactly the reserved map they were written against.
|
| 603 |
736 |
|
#[test]
|