//! The keymap overlay: everything the focused pane can do, on one screen. //! //! A keyboard-driven TUI has no menu bar, so what a pane can do is knowable only //! by having been told. The footer carries a handful of hints and nothing //! carries the rest. This is the rest. //! //! Two rules it exists to enforce, both from the classic Mac tradition of //! keeping the whole command surface visible: //! //! **Dimmed, never hidden.** An unavailable binding keeps its place and its //! spelling, greyed, with the reason beside it. A binding that disappears when //! it does not apply takes its own existence with it, so the user never learns //! the pane has that power at all, and the rows they *can* use move under them //! every time the selection changes. //! //! **The reserved keys are always shown.** A view supplies only its own //! bindings; [`keys::RESERVED`] is appended here so the same block appears in //! every pane of every Alloy TUI, in the same order, whether or not the view //! remembered it. //! //! Pure render, like everything else in this crate: the shell owns the flag that //! says the overlay is open, and rebuilds this each frame. //! //! use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; use crate::keys::{self, Action}; use crate::theme::Theme; use crate::widgets::floating_shadow; /// One key and what it does here. pub struct Binding<'a> { pub key: &'a str, pub label: &'a str, /// Whether pressing it right now does anything. pub enabled: bool, /// Why not, when it does not. Shown beside a disabled binding, because /// "greyed out and unexplained" is its own small mystery. pub reason: Option<&'a str>, } /// An available binding. pub const fn binding<'a>(key: &'a str, label: &'a str) -> Binding<'a> { Binding { key, label, enabled: true, reason: None, } } /// A binding that is real but not available right now. pub const fn unavailable<'a>(key: &'a str, label: &'a str, reason: &'a str) -> Binding<'a> { Binding { key, label, enabled: false, reason: Some(reason), } } /// A titled run of bindings. pub struct KeyGroup<'a> { pub title: &'a str, pub bindings: Vec>, } impl<'a> KeyGroup<'a> { pub fn new(title: &'a str, bindings: Vec>) -> Self { Self { title, bindings } } } /// The overlay itself. pub struct AlloyKeymap<'a> { theme: &'a Theme, title: &'a str, groups: Vec>, unavailable: &'a [Action], } impl<'a> AlloyKeymap<'a> { /// `title` names the pane whose keys these are, so an overlay opened by /// accident says where the user is as well as what they can press. pub fn new(theme: &'a Theme, title: &'a str, groups: Vec>) -> Self { Self { theme, title, groups, unavailable: &[], } } /// Reserved actions this view does not answer, so they render dimmed rather /// than promising something that will not happen. A view with no tabs passes /// the tab movers here. #[must_use] pub fn unavailable(mut self, actions: &'a [Action]) -> Self { self.unavailable = actions; self } /// The view's groups, then the reserved block. fn all_groups(&self) -> Vec> { let mut groups: Vec> = self .groups .iter() .map(|g| KeyGroup { title: g.title, bindings: g .bindings .iter() .map(|b| Binding { key: b.key, label: b.label, enabled: b.enabled, reason: b.reason, }) .collect(), }) .collect(); groups.push(KeyGroup { title: "EVERYWHERE", bindings: keys::RESERVED .iter() .map(|r| Binding { key: r.key, label: r.label, enabled: !self.unavailable.contains(&r.action), reason: if self.unavailable.contains(&r.action) { Some("not here") } else { None }, }) .collect(), }); groups } } impl Widget for AlloyKeymap<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let base = Style::default() .bg(self.theme.makeover.surface_overlay) .fg(self.theme.makeover.content_primary); // The overlay covers what is behind it rather than blending with it: a // half-legible keymap over live content is harder to read than either. Clear.render(area, buf); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(self.theme.border_strong)) .style(base) .shadow(floating_shadow(self.theme)) .title(format!(" keys: {} ", self.title)); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 || inner.width == 0 { return; } let groups = self.all_groups(); // One key column for the whole overlay, not one per group, so the labels // line up top to bottom and the eye has a single edge to run down. let key_width = groups .iter() .flat_map(|g| g.bindings.iter()) .map(|b| b.key.chars().count()) .max() .unwrap_or(0); let mut lines: Vec = Vec::new(); for (i, group) in groups.iter().enumerate() { if i > 0 { lines.push(Line::default()); } // Uppercase and bold: the two levers a terminal has for hierarchy, // per docs/DESIGN-LANGUAGE.md. No color, because a section header is // chrome and color here is information. lines.push(Line::from(Span::styled( group.title.to_uppercase(), base.add_modifier(Modifier::BOLD), ))); for b in &group.bindings { let (key_style, label_style) = if b.enabled { ( base.fg(self.theme.makeover.action_primary), base.fg(self.theme.makeover.content_primary), ) } else { ( base.fg(self.theme.makeover.content_muted), base.fg(self.theme.makeover.content_muted), ) }; let mut spans = vec![ Span::styled(" ", base), Span::styled(format!("{:key_width$}", b.key), key_style), Span::styled(" ", base), Span::styled(b.label, label_style), ]; if let Some(reason) = b.reason.filter(|_| !b.enabled) { spans.push(Span::styled( format!(" ({reason})"), base.fg(self.theme.makeover.content_muted), )); } lines.push(Line::from(spans)); } } // Truncation is announced. A help screen that quietly stops short is // the one place a silent cut is least forgivable: the reader is here // precisely because they are looking for something they cannot find. let height = inner.height as usize; if lines.len() > height { let shown = height.saturating_sub(1); let hidden = lines.len() - shown; lines.truncate(shown); lines.push(Line::from(Span::styled( format!(" ... {hidden} more, resize to see"), base.fg(self.theme.makeover.content_muted), ))); } Paragraph::new(lines).style(base).render(inner, buf); } } #[cfg(test)] mod tests { use super::*; fn theme() -> Theme { crate::theme::test_theme(crate::theme::Mode::Dark) } fn pane_group() -> Vec> { vec![KeyGroup::new( "this pane", vec![ binding("j/k", "select"), unavailable("w", "wifi radio", "no wifi device"), ], )] } fn render(w: u16, h: u16, keymap: AlloyKeymap) -> Vec { let area = Rect::new(0, 0, w, h); let mut buf = Buffer::empty(area); keymap.render(area, &mut buf); (0..h) .map(|y| { (0..w) .map(|x| buf[(x, y)].symbol()) .collect::() .trim_end() .to_string() }) .collect() } #[test] fn it_lists_the_panes_keys_then_the_reserved_ones() { let theme = theme(); let rendered = render(46, 22, AlloyKeymap::new(&theme, "network", pane_group())).join("\n"); assert!(rendered.contains("THIS PANE"), "{rendered}"); assert!(rendered.contains("j/k"), "{rendered}"); assert!(rendered.contains("EVERYWHERE"), "{rendered}"); // The reserved block is appended without the caller supplying it. assert!(rendered.contains("Shift-Tab"), "{rendered}"); assert!(rendered.contains("Ctrl-S"), "{rendered}"); // And the overlay says where you are. assert!(rendered.contains("keys: network"), "{rendered}"); } // The whole point. An unavailable binding is still on the screen, in its // place, spelled the same, with a reason. #[test] fn an_unavailable_binding_is_dimmed_and_explained_not_removed() { let theme = theme(); let area = Rect::new(0, 0, 46, 22); let mut buf = Buffer::empty(area); AlloyKeymap::new(&theme, "network", pane_group()).render(area, &mut buf); let rendered: Vec = (0..area.height) .map(|y| { (0..area.width) .map(|x| buf[(x, y)].symbol()) .collect::() }) .collect(); let row = rendered .iter() .position(|r| r.contains("wifi radio")) .expect("the unavailable binding is still listed"); assert!( rendered[row].contains("(no wifi device)"), "{:?}", rendered[row] ); // Dimmed, and the available one above it is not. let x = rendered[row].find('w').expect("the key is drawn") as u16; assert_eq!(buf[(x, row as u16)].fg, theme.makeover.content_muted); let live = rendered .iter() .position(|r| r.contains("select")) .expect("the available binding is listed"); let lx = rendered[live].find('j').expect("the key is drawn") as u16; assert_eq!(buf[(lx, live as u16)].fg, theme.makeover.action_primary); } // A view without tabs says so rather than advertising two keys that do // nothing in it. #[test] fn reserved_keys_the_view_does_not_answer_are_dimmed_too() { let theme = theme(); let unavailable = [Action::NextTab, Action::PrevTab]; let rendered = render( 46, 22, AlloyKeymap::new(&theme, "network", pane_group()).unavailable(&unavailable), ); let row = rendered .iter() .find(|r| r.contains("next tab")) .expect("the tab key is still listed"); assert!(row.contains("(not here)"), "{row}"); } // Never a silent cut, least of all here. #[test] fn an_overlay_too_short_for_its_content_says_how_much_is_missing() { let theme = theme(); let rendered = render(46, 8, AlloyKeymap::new(&theme, "network", pane_group())); // The last row of the area is the block's bottom edge; the marker sits // on the last row inside it. let marker = rendered .iter() .rev() .find(|r| r.contains("more, resize to see")); assert!(marker.is_some(), "{rendered:#?}"); // And it is the final line of content, not something floating mid-list. let marker_row = rendered .iter() .position(|r| r.contains("more, resize to see")) .expect("just asserted present"); assert_eq!(marker_row, rendered.len() - 2, "{rendered:#?}"); } }