|
1 |
+ |
//! The keymap overlay: everything the focused pane can do, on one screen.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! A keyboard-driven TUI has no menu bar, so what a pane can do is knowable only
|
|
4 |
+ |
//! by having been told. The footer carries a handful of hints and nothing
|
|
5 |
+ |
//! carries the rest. This is the rest.
|
|
6 |
+ |
//!
|
|
7 |
+ |
//! Two rules it exists to enforce, both from the classic Mac tradition of
|
|
8 |
+ |
//! keeping the whole command surface visible:
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! **Dimmed, never hidden.** An unavailable binding keeps its place and its
|
|
11 |
+ |
//! spelling, greyed, with the reason beside it. A binding that disappears when
|
|
12 |
+ |
//! it does not apply takes its own existence with it, so the user never learns
|
|
13 |
+ |
//! the pane has that power at all, and the rows they *can* use move under them
|
|
14 |
+ |
//! every time the selection changes.
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! **The reserved keys are always shown.** A view supplies only its own
|
|
17 |
+ |
//! bindings; [`keys::RESERVED`] is appended here so the same block appears in
|
|
18 |
+ |
//! every pane of every Alloy TUI, in the same order, whether or not the view
|
|
19 |
+ |
//! remembered it.
|
|
20 |
+ |
//!
|
|
21 |
+ |
//! Pure render, like everything else in this crate: the shell owns the flag that
|
|
22 |
+ |
//! says the overlay is open, and rebuilds this each frame.
|
|
23 |
+ |
//!
|
|
24 |
+ |
//! <!-- wiki: alloy-console -->
|
|
25 |
+ |
|
|
26 |
+ |
use ratatui::buffer::Buffer;
|
|
27 |
+ |
use ratatui::layout::Rect;
|
|
28 |
+ |
use ratatui::style::{Modifier, Style};
|
|
29 |
+ |
use ratatui::text::{Line, Span};
|
|
30 |
+ |
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
|
|
31 |
+ |
|
|
32 |
+ |
use crate::keys::{self, Action};
|
|
33 |
+ |
use crate::theme::Theme;
|
|
34 |
+ |
use crate::widgets::floating_shadow;
|
|
35 |
+ |
|
|
36 |
+ |
/// One key and what it does here.
|
|
37 |
+ |
pub struct Binding<'a> {
|
|
38 |
+ |
pub key: &'a str,
|
|
39 |
+ |
pub label: &'a str,
|
|
40 |
+ |
/// Whether pressing it right now does anything.
|
|
41 |
+ |
pub enabled: bool,
|
|
42 |
+ |
/// Why not, when it does not. Shown beside a disabled binding, because
|
|
43 |
+ |
/// "greyed out and unexplained" is its own small mystery.
|
|
44 |
+ |
pub reason: Option<&'a str>,
|
|
45 |
+ |
}
|
|
46 |
+ |
|
|
47 |
+ |
/// An available binding.
|
|
48 |
+ |
pub const fn binding<'a>(key: &'a str, label: &'a str) -> Binding<'a> {
|
|
49 |
+ |
Binding {
|
|
50 |
+ |
key,
|
|
51 |
+ |
label,
|
|
52 |
+ |
enabled: true,
|
|
53 |
+ |
reason: None,
|
|
54 |
+ |
}
|
|
55 |
+ |
}
|
|
56 |
+ |
|
|
57 |
+ |
/// A binding that is real but not available right now.
|
|
58 |
+ |
pub const fn unavailable<'a>(key: &'a str, label: &'a str, reason: &'a str) -> Binding<'a> {
|
|
59 |
+ |
Binding {
|
|
60 |
+ |
key,
|
|
61 |
+ |
label,
|
|
62 |
+ |
enabled: false,
|
|
63 |
+ |
reason: Some(reason),
|
|
64 |
+ |
}
|
|
65 |
+ |
}
|
|
66 |
+ |
|
|
67 |
+ |
/// A titled run of bindings.
|
|
68 |
+ |
pub struct KeyGroup<'a> {
|
|
69 |
+ |
pub title: &'a str,
|
|
70 |
+ |
pub bindings: Vec<Binding<'a>>,
|
|
71 |
+ |
}
|
|
72 |
+ |
|
|
73 |
+ |
impl<'a> KeyGroup<'a> {
|
|
74 |
+ |
pub fn new(title: &'a str, bindings: Vec<Binding<'a>>) -> Self {
|
|
75 |
+ |
Self { title, bindings }
|
|
76 |
+ |
}
|
|
77 |
+ |
}
|
|
78 |
+ |
|
|
79 |
+ |
/// The overlay itself.
|
|
80 |
+ |
pub struct AlloyKeymap<'a> {
|
|
81 |
+ |
theme: &'a Theme,
|
|
82 |
+ |
title: &'a str,
|
|
83 |
+ |
groups: Vec<KeyGroup<'a>>,
|
|
84 |
+ |
unavailable: &'a [Action],
|
|
85 |
+ |
}
|
|
86 |
+ |
|
|
87 |
+ |
impl<'a> AlloyKeymap<'a> {
|
|
88 |
+ |
/// `title` names the pane whose keys these are, so an overlay opened by
|
|
89 |
+ |
/// accident says where the user is as well as what they can press.
|
|
90 |
+ |
pub fn new(theme: &'a Theme, title: &'a str, groups: Vec<KeyGroup<'a>>) -> Self {
|
|
91 |
+ |
Self {
|
|
92 |
+ |
theme,
|
|
93 |
+ |
title,
|
|
94 |
+ |
groups,
|
|
95 |
+ |
unavailable: &[],
|
|
96 |
+ |
}
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
/// Reserved actions this view does not answer, so they render dimmed rather
|
|
100 |
+ |
/// than promising something that will not happen. A view with no tabs passes
|
|
101 |
+ |
/// the tab movers here.
|
|
102 |
+ |
#[must_use]
|
|
103 |
+ |
pub fn unavailable(mut self, actions: &'a [Action]) -> Self {
|
|
104 |
+ |
self.unavailable = actions;
|
|
105 |
+ |
self
|
|
106 |
+ |
}
|
|
107 |
+ |
|
|
108 |
+ |
/// The view's groups, then the reserved block.
|
|
109 |
+ |
fn all_groups(&self) -> Vec<KeyGroup<'_>> {
|
|
110 |
+ |
let mut groups: Vec<KeyGroup<'_>> = self
|
|
111 |
+ |
.groups
|
|
112 |
+ |
.iter()
|
|
113 |
+ |
.map(|g| KeyGroup {
|
|
114 |
+ |
title: g.title,
|
|
115 |
+ |
bindings: g
|
|
116 |
+ |
.bindings
|
|
117 |
+ |
.iter()
|
|
118 |
+ |
.map(|b| Binding {
|
|
119 |
+ |
key: b.key,
|
|
120 |
+ |
label: b.label,
|
|
121 |
+ |
enabled: b.enabled,
|
|
122 |
+ |
reason: b.reason,
|
|
123 |
+ |
})
|
|
124 |
+ |
.collect(),
|
|
125 |
+ |
})
|
|
126 |
+ |
.collect();
|
|
127 |
+ |
|
|
128 |
+ |
groups.push(KeyGroup {
|
|
129 |
+ |
title: "EVERYWHERE",
|
|
130 |
+ |
bindings: keys::RESERVED
|
|
131 |
+ |
.iter()
|
|
132 |
+ |
.map(|r| Binding {
|
|
133 |
+ |
key: r.key,
|
|
134 |
+ |
label: r.label,
|
|
135 |
+ |
enabled: !self.unavailable.contains(&r.action),
|
|
136 |
+ |
reason: if self.unavailable.contains(&r.action) {
|
|
137 |
+ |
Some("not here")
|
|
138 |
+ |
} else {
|
|
139 |
+ |
None
|
|
140 |
+ |
},
|
|
141 |
+ |
})
|
|
142 |
+ |
.collect(),
|
|
143 |
+ |
});
|
|
144 |
+ |
|
|
145 |
+ |
groups
|
|
146 |
+ |
}
|
|
147 |
+ |
}
|
|
148 |
+ |
|
|
149 |
+ |
impl Widget for AlloyKeymap<'_> {
|
|
150 |
+ |
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
151 |
+ |
if area.height == 0 || area.width == 0 {
|
|
152 |
+ |
return;
|
|
153 |
+ |
}
|
|
154 |
+ |
|
|
155 |
+ |
let base = Style::default()
|
|
156 |
+ |
.bg(self.theme.surface_overlay)
|
|
157 |
+ |
.fg(self.theme.content_primary);
|
|
158 |
+ |
|
|
159 |
+ |
// The overlay covers what is behind it rather than blending with it: a
|
|
160 |
+ |
// half-legible keymap over live content is harder to read than either.
|
|
161 |
+ |
Clear.render(area, buf);
|
|
162 |
+ |
|
|
163 |
+ |
let block = Block::default()
|
|
164 |
+ |
.borders(Borders::ALL)
|
|
165 |
+ |
.border_style(Style::default().fg(self.theme.border_strong))
|
|
166 |
+ |
.style(base)
|
|
167 |
+ |
.shadow(floating_shadow(self.theme))
|
|
168 |
+ |
.title(format!(" keys: {} ", self.title));
|
|
169 |
+ |
let inner = block.inner(area);
|
|
170 |
+ |
block.render(area, buf);
|
|
171 |
+ |
if inner.height == 0 || inner.width == 0 {
|
|
172 |
+ |
return;
|
|
173 |
+ |
}
|
|
174 |
+ |
|
|
175 |
+ |
let groups = self.all_groups();
|
|
176 |
+ |
// One key column for the whole overlay, not one per group, so the labels
|
|
177 |
+ |
// line up top to bottom and the eye has a single edge to run down.
|
|
178 |
+ |
let key_width = groups
|
|
179 |
+ |
.iter()
|
|
180 |
+ |
.flat_map(|g| g.bindings.iter())
|
|
181 |
+ |
.map(|b| b.key.chars().count())
|
|
182 |
+ |
.max()
|
|
183 |
+ |
.unwrap_or(0);
|
|
184 |
+ |
|
|
185 |
+ |
let mut lines: Vec<Line> = Vec::new();
|
|
186 |
+ |
for (i, group) in groups.iter().enumerate() {
|
|
187 |
+ |
if i > 0 {
|
|
188 |
+ |
lines.push(Line::default());
|
|
189 |
+ |
}
|
|
190 |
+ |
// Uppercase and bold: the two levers a terminal has for hierarchy,
|
|
191 |
+ |
// per docs/DESIGN-LANGUAGE.md. No color, because a section header is
|
|
192 |
+ |
// chrome and color here is information.
|
|
193 |
+ |
lines.push(Line::from(Span::styled(
|
|
194 |
+ |
group.title.to_uppercase(),
|
|
195 |
+ |
base.add_modifier(Modifier::BOLD),
|
|
196 |
+ |
)));
|
|
197 |
+ |
for b in &group.bindings {
|
|
198 |
+ |
let (key_style, label_style) = if b.enabled {
|
|
199 |
+ |
(
|
|
200 |
+ |
base.fg(self.theme.action_primary),
|
|
201 |
+ |
base.fg(self.theme.content_primary),
|
|
202 |
+ |
)
|
|
203 |
+ |
} else {
|
|
204 |
+ |
(
|
|
205 |
+ |
base.fg(self.theme.content_muted),
|
|
206 |
+ |
base.fg(self.theme.content_muted),
|
|
207 |
+ |
)
|
|
208 |
+ |
};
|
|
209 |
+ |
let mut spans = vec![
|
|
210 |
+ |
Span::styled(" ", base),
|
|
211 |
+ |
Span::styled(format!("{:key_width$}", b.key), key_style),
|
|
212 |
+ |
Span::styled(" ", base),
|
|
213 |
+ |
Span::styled(b.label, label_style),
|
|
214 |
+ |
];
|
|
215 |
+ |
if let Some(reason) = b.reason.filter(|_| !b.enabled) {
|
|
216 |
+ |
spans.push(Span::styled(
|
|
217 |
+ |
format!(" ({reason})"),
|
|
218 |
+ |
base.fg(self.theme.content_muted),
|
|
219 |
+ |
));
|
|
220 |
+ |
}
|
|
221 |
+ |
lines.push(Line::from(spans));
|
|
222 |
+ |
}
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
// Truncation is announced. A help screen that quietly stops short is
|
|
226 |
+ |
// the one place a silent cut is least forgivable: the reader is here
|
|
227 |
+ |
// precisely because they are looking for something they cannot find.
|
|
228 |
+ |
let height = inner.height as usize;
|
|
229 |
+ |
if lines.len() > height {
|
|
230 |
+ |
let shown = height.saturating_sub(1);
|
|
231 |
+ |
let hidden = lines.len() - shown;
|
|
232 |
+ |
lines.truncate(shown);
|
|
233 |
+ |
lines.push(Line::from(Span::styled(
|
|
234 |
+ |
format!(" ... {hidden} more, resize to see"),
|
|
235 |
+ |
base.fg(self.theme.content_muted),
|
|
236 |
+ |
)));
|
|
237 |
+ |
}
|
|
238 |
+ |
|
|
239 |
+ |
Paragraph::new(lines).style(base).render(inner, buf);
|
|
240 |
+ |
}
|
|
241 |
+ |
}
|
|
242 |
+ |
|
|
243 |
+ |
#[cfg(test)]
|
|
244 |
+ |
mod tests {
|
|
245 |
+ |
use super::*;
|
|
246 |
+ |
use ratatui::style::Color;
|
|
247 |
+ |
|
|
248 |
+ |
fn theme() -> Theme {
|
|
249 |
+ |
Theme {
|
|
250 |
+ |
mode: crate::theme::Mode::Dark,
|
|
251 |
+ |
surface_page: Color::Rgb(0, 0, 0),
|
|
252 |
+ |
surface_raised: Color::Rgb(1, 1, 1),
|
|
253 |
+ |
surface_sunken: Color::Rgb(2, 2, 2),
|
|
254 |
+ |
surface_overlay: Color::Rgb(3, 3, 3),
|
|
255 |
+ |
content_primary: Color::Rgb(4, 4, 4),
|
|
256 |
+ |
content_secondary: Color::Rgb(5, 5, 5),
|
|
257 |
+ |
content_muted: Color::Rgb(6, 6, 6),
|
|
258 |
+ |
action_primary: Color::Rgb(7, 7, 7),
|
|
259 |
+ |
status_danger: Color::Rgb(8, 8, 8),
|
|
260 |
+ |
status_success: Color::Rgb(9, 9, 9),
|
|
261 |
+ |
status_warning: Color::Rgb(10, 10, 10),
|
|
262 |
+ |
status_info: Color::Rgb(11, 11, 11),
|
|
263 |
+ |
line_border: Color::Rgb(12, 12, 12),
|
|
264 |
+ |
border_subtle: Color::Rgb(13, 13, 13),
|
|
265 |
+ |
border_strong: Color::Rgb(14, 14, 14),
|
|
266 |
+ |
bevel_light: Color::Rgb(16, 16, 16),
|
|
267 |
+ |
bevel_dark: Color::Rgb(17, 17, 17),
|
|
268 |
+ |
category: [Color::Rgb(15, 15, 15); 6],
|
|
269 |
+ |
}
|
|
270 |
+ |
}
|
|
271 |
+ |
|
|
272 |
+ |
fn pane_group() -> Vec<KeyGroup<'static>> {
|
|
273 |
+ |
vec![KeyGroup::new(
|
|
274 |
+ |
"this pane",
|
|
275 |
+ |
vec![
|
|
276 |
+ |
binding("j/k", "select"),
|
|
277 |
+ |
unavailable("w", "wifi radio", "no wifi device"),
|
|
278 |
+ |
],
|
|
279 |
+ |
)]
|
|
280 |
+ |
}
|
|
281 |
+ |
|
|
282 |
+ |
fn render(w: u16, h: u16, keymap: AlloyKeymap) -> Vec<String> {
|
|
283 |
+ |
let area = Rect::new(0, 0, w, h);
|
|
284 |
+ |
let mut buf = Buffer::empty(area);
|
|
285 |
+ |
keymap.render(area, &mut buf);
|
|
286 |
+ |
(0..h)
|
|
287 |
+ |
.map(|y| {
|
|
288 |
+ |
(0..w)
|
|
289 |
+ |
.map(|x| buf[(x, y)].symbol())
|
|
290 |
+ |
.collect::<String>()
|
|
291 |
+ |
.trim_end()
|
|
292 |
+ |
.to_string()
|
|
293 |
+ |
})
|
|
294 |
+ |
.collect()
|
|
295 |
+ |
}
|
|
296 |
+ |
|
|
297 |
+ |
#[test]
|
|
298 |
+ |
fn it_lists_the_panes_keys_then_the_reserved_ones() {
|
|
299 |
+ |
let theme = theme();
|
|
300 |
+ |
let rendered = render(46, 22, AlloyKeymap::new(&theme, "network", pane_group())).join("\n");
|
|
301 |
+ |
assert!(rendered.contains("THIS PANE"), "{rendered}");
|
|
302 |
+ |
assert!(rendered.contains("j/k"), "{rendered}");
|
|
303 |
+ |
assert!(rendered.contains("EVERYWHERE"), "{rendered}");
|
|
304 |
+ |
// The reserved block is appended without the caller supplying it.
|
|
305 |
+ |
assert!(rendered.contains("Shift-Tab"), "{rendered}");
|
|
306 |
+ |
assert!(rendered.contains("Ctrl-S"), "{rendered}");
|
|
307 |
+ |
// And the overlay says where you are.
|
|
308 |
+ |
assert!(rendered.contains("keys: network"), "{rendered}");
|
|
309 |
+ |
}
|
|
310 |
+ |
|
|
311 |
+ |
// The whole point. An unavailable binding is still on the screen, in its
|
|
312 |
+ |
// place, spelled the same, with a reason.
|
|
313 |
+ |
#[test]
|
|
314 |
+ |
fn an_unavailable_binding_is_dimmed_and_explained_not_removed() {
|
|
315 |
+ |
let theme = theme();
|
|
316 |
+ |
let area = Rect::new(0, 0, 46, 22);
|
|
317 |
+ |
let mut buf = Buffer::empty(area);
|
|
318 |
+ |
AlloyKeymap::new(&theme, "network", pane_group()).render(area, &mut buf);
|
|
319 |
+ |
|
|
320 |
+ |
let rendered: Vec<String> = (0..area.height)
|
|
321 |
+ |
.map(|y| {
|
|
322 |
+ |
(0..area.width)
|
|
323 |
+ |
.map(|x| buf[(x, y)].symbol())
|
|
324 |
+ |
.collect::<String>()
|
|
325 |
+ |
})
|
|
326 |
+ |
.collect();
|
|
327 |
+ |
let row = rendered
|
|
328 |
+ |
.iter()
|
|
329 |
+ |
.position(|r| r.contains("wifi radio"))
|
|
330 |
+ |
.expect("the unavailable binding is still listed");
|
|
331 |
+ |
assert!(
|
|
332 |
+ |
rendered[row].contains("(no wifi device)"),
|
|
333 |
+ |
"{:?}",
|
|
334 |
+ |
rendered[row]
|
|
335 |
+ |
);
|
|
336 |
+ |
|
|
337 |
+ |
// Dimmed, and the available one above it is not.
|
|
338 |
+ |
let x = rendered[row].find('w').expect("the key is drawn") as u16;
|
|
339 |
+ |
assert_eq!(buf[(x, row as u16)].fg, theme.content_muted);
|
|
340 |
+ |
let live = rendered
|
|
341 |
+ |
.iter()
|
|
342 |
+ |
.position(|r| r.contains("select"))
|
|
343 |
+ |
.expect("the available binding is listed");
|
|
344 |
+ |
let lx = rendered[live].find('j').expect("the key is drawn") as u16;
|
|
345 |
+ |
assert_eq!(buf[(lx, live as u16)].fg, theme.action_primary);
|
|
346 |
+ |
}
|
|
347 |
+ |
|
|
348 |
+ |
// A view without tabs says so rather than advertising two keys that do
|
|
349 |
+ |
// nothing in it.
|
|
350 |
+ |
#[test]
|
|
351 |
+ |
fn reserved_keys_the_view_does_not_answer_are_dimmed_too() {
|
|
352 |
+ |
let theme = theme();
|
|
353 |
+ |
let unavailable = [Action::NextTab, Action::PrevTab];
|
|
354 |
+ |
let rendered = render(
|
|
355 |
+ |
46,
|
|
356 |
+ |
22,
|
|
357 |
+ |
AlloyKeymap::new(&theme, "network", pane_group()).unavailable(&unavailable),
|
|
358 |
+ |
);
|
|
359 |
+ |
let row = rendered
|
|
360 |
+ |
.iter()
|
|
361 |
+ |
.find(|r| r.contains("next tab"))
|
|
362 |
+ |
.expect("the tab key is still listed");
|
|
363 |
+ |
assert!(row.contains("(not here)"), "{row}");
|
|
364 |
+ |
}
|
|
365 |
+ |
|
|
366 |
+ |
// Never a silent cut, least of all here.
|
|
367 |
+ |
#[test]
|
|
368 |
+ |
fn an_overlay_too_short_for_its_content_says_how_much_is_missing() {
|
|
369 |
+ |
let theme = theme();
|
|
370 |
+ |
let rendered = render(46, 8, AlloyKeymap::new(&theme, "network", pane_group()));
|
|
371 |
+ |
// The last row of the area is the block's bottom edge; the marker sits
|
|
372 |
+ |
// on the last row inside it.
|
|
373 |
+ |
let marker = rendered
|
|
374 |
+ |
.iter()
|
|
375 |
+ |
.rev()
|
|
376 |
+ |
.find(|r| r.contains("more, resize to see"));
|
|
377 |
+ |
assert!(marker.is_some(), "{rendered:#?}");
|
|
378 |
+ |
|
|
379 |
+ |
// And it is the final line of content, not something floating mid-list.
|
|
380 |
+ |
let marker_row = rendered
|
|
381 |
+ |
.iter()
|
|
382 |
+ |
.position(|r| r.contains("more, resize to see"))
|
|
383 |
+ |
.expect("just asserted present");
|
|
384 |
+ |
assert_eq!(marker_row, rendered.len() - 2, "{rendered:#?}");
|
|
385 |
+ |
}
|
|
386 |
+ |
}
|