use crate::geometry::{Rect, Size};
use crate::keymap::Scope;
use crate::text;
use crate::widget::{MeasureCx, PaintCx, Widget};
use super::cells;
#[derive(Debug, Clone, Default)]
pub struct KeyHints {
left: Vec<Hint>,
actions: Vec<Action>,
faint: bool,
}
#[derive(Debug, Clone)]
struct Action {
scope: Scope,
name: String,
place: Place,
label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Place {
First,
Left,
Right,
}
impl KeyHints {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn hint(mut self, key: impl Into<String>, label: impl Into<String>) -> Self {
self.left.push((key.into(), label.into()));
self
}
#[must_use]
pub fn action(self, scope: Scope, action: impl Into<String>) -> Self {
self.with_action(scope, action.into(), Place::Left, None)
}
#[must_use]
pub fn action_first(self, scope: Scope, action: impl Into<String>) -> Self {
self.with_action(scope, action.into(), Place::First, None)
}
#[must_use]
pub fn action_labelled(self, scope: Scope, action: impl Into<String>, label: impl Into<String>) -> Self {
self.with_action(scope, action.into(), Place::Left, Some(label.into()))
}
#[must_use]
pub fn action_labelled_first(self, scope: Scope, action: impl Into<String>, label: impl Into<String>) -> Self {
self.with_action(scope, action.into(), Place::First, Some(label.into()))
}
#[must_use]
pub fn action_right(self, scope: Scope, action: impl Into<String>) -> Self {
self.with_action(scope, action.into(), Place::Right, None)
}
#[must_use]
pub fn faint(mut self, faint: bool) -> Self {
self.faint = faint;
self
}
fn with_action(mut self, scope: Scope, name: String, place: Place, label: Option<String>) -> Self {
self.actions.push(Action { scope, name, place, label });
self
}
fn resolved(&self, cx: &PaintCx<'_>) -> (Vec<Hint>, Vec<Hint>) {
let mut first = Vec::new();
let mut left = self.left.clone();
let mut right = Vec::new();
for action in &self.actions {
let Some(key) = cx.env().keymap().label_for(action.scope, &action.name) else {
continue;
};
let label = action
.label
.clone()
.unwrap_or_else(|| cx.env().i18n().translate(&action.scope.label_key(&action.name), &[]));
match action.place {
Place::First => first.push((key, label)),
Place::Left => left.push((key, label)),
Place::Right => right.push((key, label)),
}
}
first.append(&mut left);
(first, right)
}
}
type Hint = (String, String);
const SPACING: u16 = 3;
fn hint_width(hint: &Hint) -> u16 {
cells::sum([text::width(&hint.0), 2, 1, text::width(&hint.1)])
}
impl<Msg: 'static> Widget<Msg> for KeyHints {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
Size::new(available.width, 1.min(available.height))
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let bar = cx.style("key-hints", None, &[]);
let background = bar.text().bg.unwrap_or_else(|| cx.color("surface"));
cx.clear(area, background);
let inner = area.inset(bar.padding());
let variant = self.faint.then_some("faint");
let key_style = cx.style("key-hint-key", variant, &[]).text();
let label_style = cx.style("key-hint-label", variant, &[]).text();
let (mut left, right) = self.resolved(cx);
let group_width = |hints: &[Hint]| -> u16 {
let count = u16::try_from(hints.len()).unwrap_or(u16::MAX);
let hints = cells::sum(hints.iter().map(hint_width));
hints.saturating_add(SPACING.saturating_mul(count.saturating_sub(1)))
};
let right_width = group_width(&right);
let separation = if right.is_empty() { 0 } else { SPACING };
let left_budget = inner.width.saturating_sub(right_width.saturating_add(separation));
while group_width(&left) > left_budget {
left.pop();
}
let draw = |cx: &mut PaintCx<'_>, mut x: i32, hints: &[Hint]| {
for (key, label) in hints {
let padded = format!(" {key} ");
x += i32::from(cx.text(x, inner.y, &padded, key_style, text::width(&padded))) + 1;
x += i32::from(cx.text(x, inner.y, label, label_style, text::width(label))) + i32::from(SPACING);
}
};
draw(cx, inner.x, &left);
draw(cx, inner.right() - i32::from(right_width), &right);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widget::View;
struct Demo;
impl App for Demo {
type Msg = ();
fn update(&mut self, _: ()) -> Command<()> {
Command::none()
}
fn view(&self, ui: &mut View<'_, ()>) {
ui.add(
KeyHints::new()
.hint("↑↓", "move")
.action(Scope::Global, "focus-next")
.action_right(Scope::Global, "quit"),
)
.fill_width();
}
}
#[test]
fn draws_hints_from_keymap_and_drops_what_does_not_fit() {
let wide = Harness::new(Demo, 50, 1);
assert_eq!(wide.screen(), " ↑↓ move tab next ctrl q quit\n");
let narrow = Harness::new(Demo, 30, 1);
assert_eq!(narrow.screen(), " ↑↓ move ctrl q quit\n");
}
struct Ordered {
faint: bool,
}
impl App for Ordered {
type Msg = ();
fn update(&mut self, (): ()) -> Command<()> {
Command::none()
}
fn view(&self, ui: &mut View<'_, ()>) {
ui.add(
KeyHints::new()
.hint("↑↓", "move")
.action_labelled(Scope::Global, "focus-next", "install")
.action_first(Scope::Global, "quit")
.faint(self.faint),
)
.fill_width();
}
}
#[test]
fn a_first_action_comes_before_the_plain_hints_and_is_the_last_to_drop() {
let wide = Harness::new(Ordered { faint: false }, 60, 1);
assert_eq!(wide.screen(), " ctrl q quit ↑↓ move tab install\n");
let narrow = Harness::new(Ordered { faint: false }, 20, 1);
assert_eq!(narrow.screen(), " ctrl q quit\n", "the others drop first");
}
#[test]
fn a_labelled_and_a_first_action_follow_the_persons_keymap() {
let mut env = crate::env::Env::builtin();
env.keymap_mut().bind(Scope::Global, "quit", &["ctrl+x".parse().expect("chord")]);
env.keymap_mut().bind(Scope::Global, "focus-next", &["f6".parse().expect("chord")]);
let h = Harness::with_env(Ordered { faint: false }, env, 60, 1);
assert_eq!(h.screen(), " ctrl x quit ↑↓ move f6 install\n");
}
struct Details;
impl App for Details {
type Msg = ();
fn update(&mut self, (): ()) -> Command<()> {
Command::none()
}
fn view(&self, ui: &mut View<'_, ()>) {
ui.add(KeyHints::new().hint("↑↓", "choose").action_labelled_first(Scope::Global, "focus-next", "details"))
.fill_width();
}
}
#[test]
fn a_labelled_first_action_is_read_first_and_kept_when_the_bar_narrows() {
let wide = Harness::new(Details, 40, 1);
assert_eq!(wide.screen(), " tab details ↑↓ choose\n");
let narrow = Harness::new(Details, 20, 1);
assert_eq!(narrow.screen(), " tab details\n", "the plain hint drops first");
}
#[test]
fn a_faint_bar_draws_its_keys_and_labels_quieter() {
let loud = Harness::new(Ordered { faint: false }, 60, 1);
let quiet = Harness::new(Ordered { faint: true }, 60, 1);
assert_eq!(loud.screen(), quiet.screen(), "the same hints");
assert_ne!(loud.fg(4, 0), quiet.fg(4, 0), "the key steps back");
assert_ne!(loud.bg(4, 0), quiet.bg(4, 0));
assert_ne!(loud.fg(11, 0), quiet.fg(11, 0), "and so does its label");
}
#[test]
fn labels_follow_the_language() {
let mut h = Harness::new(Demo, 50, 1);
h.set_locale("tr");
assert!(h.screen().contains("ctrl q çık"));
}
#[test]
fn hints_wider_than_any_screen_are_dropped_without_overflowing() {
struct Huge;
impl App for Huge {
type Msg = ();
fn update(&mut self, (): ()) -> Command<()> {
Command::none()
}
fn view(&self, ui: &mut View<'_, ()>) {
let long = "k".repeat(40_000);
ui.add(KeyHints::new().hint(long.clone(), long.clone()).hint(long, "move")).fill_width();
}
}
let h = Harness::new(Huge, 30, 1);
assert_eq!(h.screen(), "\n");
}
}