use crate::composer::key_hint::{KeyBinding, ctrl, plain, shift};
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
#[derive(Debug, Clone, Copy)]
pub struct Hint {
pub key: KeyBinding,
pub verb: &'static str,
}
impl Hint {
pub const fn new(key: KeyBinding, verb: &'static str) -> Self {
Self { key, verb }
}
}
pub struct KeyHints {
items: Vec<Hint>,
}
impl KeyHints {
pub fn default_set() -> Self {
Self {
items: vec![
Hint::new(plain(KeyCode::Enter), "send"),
Hint::new(shift(KeyCode::Enter), "newline"),
Hint::new(plain(KeyCode::Tab), "complete"),
Hint::new(plain(KeyCode::Esc), "menu"),
Hint::new(plain(KeyCode::Up), "history"),
Hint::new(ctrl(KeyCode::Char('c')), "quit"),
],
}
}
}
impl Widget for KeyHints {
fn render(self, area: Rect, buf: &mut Buffer) {
let spans = build_hint_spans(&self.items, area.width as usize);
Paragraph::new(Line::from(spans)).render(area, buf);
}
}
fn build_hint_spans(items: &[Hint], max_width: usize) -> Vec<Span<'static>> {
if items.is_empty() || max_width == 0 {
return Vec::new();
}
for take in (1..=items.len()).rev() {
let spans = render_n(items, take);
let width: usize = spans.iter().map(|s| s.width()).sum();
if width <= max_width {
return spans;
}
}
render_n(items, 1)
}
fn render_n(items: &[Hint], n: usize) -> Vec<Span<'static>> {
let verb_style = Style::default().fg(Color::Rgb(140, 140, 140));
let sep_style = Style::default().fg(Color::Rgb(80, 80, 80));
let mut spans = Vec::with_capacity(n * 4);
for (i, hint) in items.iter().take(n).enumerate() {
if i > 0 {
spans.push(Span::styled(" \u{00b7} ", sep_style));
}
spans.push(Span::from(hint.key));
spans.push(Span::raw(" "));
spans.push(Span::styled(hint.verb, verb_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_set_fits_when_width_is_generous() {
let items = KeyHints::default_set().items;
let spans = build_hint_spans(&items, 200);
let width: usize = spans.iter().map(|s| s.width()).sum();
assert!(
width <= 200,
"default set must fit in 200 cols (got {width})"
);
assert_eq!(
spans.iter().filter(|s| s.content == " \u{00b7} ").count(),
items.len() - 1,
"n items \u{2192} n-1 separators",
);
}
#[test]
fn narrow_terminal_drops_low_priority_items_first() {
let items = KeyHints::default_set().items;
let spans = build_hint_spans(&items, 30);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("enter") && text.contains("send"),
"highest-priority hint must survive narrow render: {text}"
);
assert!(
!text.contains("ctrl+c"),
"lowest-priority hint must be dropped on 30-col terminal: {text}"
);
}
#[test]
fn zero_width_returns_empty() {
let items = KeyHints::default_set().items;
assert!(build_hint_spans(&items, 0).is_empty());
}
#[test]
fn empty_items_returns_empty() {
assert!(build_hint_spans(&[], 200).is_empty());
}
#[test]
fn widget_render_writes_all_verbs_into_buffer() {
let area = Rect::new(0, 0, 120, 1);
let mut buf = Buffer::empty(area);
KeyHints::default_set().render(area, &mut buf);
let text: String = (0..area.width)
.map(|x| buf.cell((x, 0)).map(|c| c.symbol()).unwrap_or(" "))
.collect();
for verb in ["send", "newline", "complete", "menu", "history", "quit"] {
assert!(
text.contains(verb),
"rendered footer must contain {verb:?}: {text}"
);
}
}
}