use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
use crate::components::clickable::is_left_press;
use crate::settings::themes::Theme;
struct Chip {
key: KeyEvent,
key_text: String,
label_text: String,
enabled: bool,
style: Option<Style>,
}
pub struct HintRow {
chips: Vec<Chip>,
indent: u16,
gap: u16,
prefix: String,
rects: Vec<Rect>,
}
impl HintRow {
pub fn new(chips: &[(KeyCode, &str, &str)]) -> Self {
Self {
chips: chips
.iter()
.map(|(code, key, label)| Chip {
key: KeyEvent::new(*code, KeyModifiers::NONE),
key_text: format!("[{key}]"),
label_text: format!(" {label}"),
enabled: true,
style: None,
})
.collect(),
indent: 2,
gap: 2,
prefix: String::new(),
rects: Vec::new(),
}
}
pub fn with_gap(mut self, gap: u16) -> Self {
self.gap = gap;
self
}
pub fn with_indent(mut self, indent: u16) -> Self {
self.indent = indent;
self
}
pub fn with_prefix(mut self, prefix: &str) -> Self {
self.prefix = prefix.to_string();
self
}
pub fn hide(&mut self) {
self.rects.clear();
}
pub fn set_prefix(&mut self, prefix: &str) {
self.prefix = prefix.to_string();
}
pub fn with_modifiers(mut self, idx: usize, modifiers: KeyModifiers) -> Self {
if let Some(c) = self.chips.get_mut(idx) {
c.key.modifiers = modifiers;
}
self
}
pub fn set_enabled(&mut self, idx: usize, enabled: bool) {
if let Some(c) = self.chips.get_mut(idx) {
c.enabled = enabled;
}
}
pub fn set_style(&mut self, idx: usize, style: Option<Style>) {
if let Some(c) = self.chips.get_mut(idx) {
c.style = style;
}
}
pub fn set_label(&mut self, idx: usize, key: &str, label: &str) {
if let Some(c) = self.chips.get_mut(idx) {
c.key_text = format!("[{key}]");
c.label_text = format!(" {label}");
}
}
pub fn render(&mut self, f: &mut Frame, rect: Rect, style: Style, theme: &Theme) {
self.rects.clear();
let mut spans = vec![Span::styled(" ".repeat(self.indent as usize), style)];
let mut x = rect.x.saturating_add(self.indent);
if !self.prefix.is_empty() {
spans.push(Span::styled(self.prefix.clone(), style));
x = x.saturating_add(self.prefix.width() as u16);
}
for (i, chip) in self.chips.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ".repeat(self.gap as usize), style));
x = x.saturating_add(self.gap);
}
let w = (chip.key_text.width() + chip.label_text.width()) as u16;
let r = Rect {
x,
y: rect.y,
width: rect.right().saturating_sub(x).min(w),
height: 1,
};
self.rects.push(r);
let clickable = chip.enabled && chip.key.code != KeyCode::Null;
if clickable {
crate::components::clickable::register(r);
}
let base = chip.style.unwrap_or(style);
let (key_style, label_style) = if !chip.enabled {
let dim = base.add_modifier(Modifier::DIM);
(dim, dim)
} else if clickable {
(base.patch(theme.action()), base)
} else {
(base, base)
};
spans.push(Span::styled(chip.key_text.clone(), key_style));
spans.push(Span::styled(chip.label_text.clone(), label_style));
x = x.saturating_add(w);
}
f.render_widget(Paragraph::new(Line::from(spans)).style(style), rect);
}
pub fn hit(&self, m: &MouseEvent) -> Option<KeyEvent> {
if !is_left_press(m) {
return None;
}
let pos = Position::new(m.column, m.row);
self.rects
.iter()
.zip(&self.chips)
.find(|(r, c)| c.enabled && c.key.code != KeyCode::Null && r.contains(pos))
.map(|(_, c)| c.key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
fn press(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn drawn(row: &mut HintRow) -> String {
let mut t = Terminal::new(TestBackend::new(40, 1)).unwrap();
t.draw(|f| row.render(f, f.area(), Style::default(), &Theme::gruvbox_dark()))
.unwrap();
let buf = t.backend().buffer().clone();
(0..40).map(|x| buf[(x, 0)].symbol().to_string()).collect()
}
#[test]
fn draws_like_the_old_hint_text() {
let mut row = HintRow::new(&[
(KeyCode::Enter, "Enter", "Rename"),
(KeyCode::Esc, "Esc", "Cancel"),
]);
assert!(drawn(&mut row).starts_with(" [Enter] Rename [Esc] Cancel"));
}
#[test]
fn a_click_on_a_chip_is_its_key() {
let mut row = HintRow::new(&[
(KeyCode::Enter, "Enter", "Rename"),
(KeyCode::Esc, "Esc", "Cancel"),
]);
drawn(&mut row);
assert_eq!(row.hit(&press(2, 0)).map(|k| k.code), Some(KeyCode::Enter));
assert_eq!(row.hit(&press(15, 0)).map(|k| k.code), Some(KeyCode::Enter));
assert_eq!(row.hit(&press(16, 0)), None, "the gap is not a chip");
assert_eq!(row.hit(&press(18, 0)).map(|k| k.code), Some(KeyCode::Esc));
assert_eq!(row.hit(&press(0, 0)), None, "the indent is not a chip");
}
#[test]
fn a_disabled_chip_ignores_clicks() {
let mut row = HintRow::new(&[(KeyCode::Enter, "Enter", "Rename")]);
row.set_enabled(0, false);
drawn(&mut row);
assert_eq!(row.hit(&press(3, 0)), None);
}
#[test]
fn modifiers_ride_along() {
let mut row = HintRow::new(&[(KeyCode::Enter, "Shift+Enter", "Save & Open")])
.with_modifiers(0, KeyModifiers::SHIFT);
drawn(&mut row);
assert_eq!(
row.hit(&press(3, 0)).map(|k| k.modifiers),
Some(KeyModifiers::SHIFT)
);
}
#[test]
fn an_informational_chip_ignores_clicks() {
let mut row = HintRow::new(&[(KeyCode::Null, "↑↓", "Move")]);
drawn(&mut row);
assert_eq!(row.hit(&press(3, 0)), None);
}
#[test]
fn a_prefix_shifts_the_chips() {
let mut row = HintRow::new(&[(KeyCode::Esc, "Esc", "Close")]).with_prefix("type · ");
assert!(drawn(&mut row).starts_with(" type · [Esc] Close"));
assert_eq!(row.hit(&press(3, 0)), None, "the prefix is inert");
assert_eq!(row.hit(&press(9, 0)).map(|k| k.code), Some(KeyCode::Esc));
}
#[test]
fn nothing_hits_before_render() {
let row = HintRow::new(&[(KeyCode::Enter, "Enter", "Go")]);
assert_eq!(row.hit(&press(3, 0)), None);
}
}