use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
pub trait ShortcutBarStyling {
fn key_style(&self) -> Style;
fn label_style(&self) -> Style;
fn separator_style(&self) -> Style;
fn background_style(&self) -> Style;
fn pending_key_style(&self) -> Style;
}
#[derive(Debug, Clone)]
pub struct HintItem {
pub key: String,
pub alt_key: Option<String>,
pub label: String,
pub pinned: bool,
}
impl HintItem {
#[must_use]
pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
Self {
key: key.into(),
alt_key: None,
label: label.into(),
pinned: false,
}
}
#[must_use]
pub fn paired(
key: impl Into<String>,
alt: impl Into<String>,
label: impl Into<String>,
) -> Self {
Self {
key: key.into(),
alt_key: Some(alt.into()),
label: label.into(),
pinned: false,
}
}
#[must_use]
pub fn pinned(mut self) -> Self {
self.pinned = true;
self
}
#[must_use]
pub fn display_width(&self) -> usize {
let key_w = if let Some(alt) = &self.alt_key {
self.key.chars().count() + 1 + alt.chars().count()
} else {
self.key.chars().count()
};
key_w + 2 + self.label.chars().count()
}
fn spans<S: ShortcutBarStyling>(&self, styles: &S) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(5);
if let Some(alt) = &self.alt_key {
spans.push(Span::styled(self.key.clone(), styles.key_style()));
spans.push(Span::styled("/", styles.separator_style()));
spans.push(Span::styled(alt.clone(), styles.key_style()));
} else {
spans.push(Span::styled(self.key.clone(), styles.key_style()));
}
spans.push(Span::styled(":", styles.separator_style()));
spans.push(Span::styled(self.label.clone(), styles.label_style()));
spans
}
}
#[derive(Debug, Clone, Copy)]
pub struct CompactConfig {
pub max_visible: usize,
pub help_key: &'static str,
pub help_label: &'static str,
}
impl Default for CompactConfig {
fn default() -> Self {
Self {
max_visible: 8,
help_key: "?",
help_label: "help",
}
}
}
#[derive(Clone, Copy)]
pub struct PendingHint {
pub key: &'static str,
pub label: &'static str,
}
pub struct ShortcutsBar<'a, S: ShortcutBarStyling> {
hints: &'a [HintItem],
styles: &'a S,
compact: Option<&'a CompactConfig>,
pending: Option<PendingHint>,
}
impl<'a, S: ShortcutBarStyling> ShortcutsBar<'a, S> {
#[must_use]
pub fn new(hints: &'a [HintItem], styles: &'a S) -> Self {
Self {
hints,
styles,
compact: None,
pending: None,
}
}
#[must_use]
pub fn compact(mut self, cfg: &'a CompactConfig) -> Self {
self.compact = Some(cfg);
self
}
#[must_use]
pub fn pending(mut self, hint: PendingHint) -> Self {
self.pending = Some(hint);
self
}
}
impl<S: ShortcutBarStyling> Widget for ShortcutsBar<'_, S> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
buf.set_style(area, self.styles.background_style());
if let Some(pending) = self.pending {
let line = Line::from(vec![
Span::raw("press "),
Span::styled(pending.key, self.styles.pending_key_style()),
Span::raw(" again to "),
Span::styled(pending.label, self.styles.label_style()),
]);
buf.set_line(area.x, area.y, &line, area.width);
return;
}
let effective = compute_effective_hints(self.hints, self.compact);
let mut x = area.x;
let separator = Span::styled(" ", self.styles.separator_style());
for (i, hint) in effective.iter().enumerate() {
if i > 0 {
let sep_w = separator.width() as u16;
if x + sep_w > area.x + area.width {
break;
}
buf.set_span(x, area.y, &separator, sep_w);
x += sep_w;
}
let spans = hint.spans(self.styles);
let line = Line::from(spans);
let w = hint.display_width() as u16;
if x + w > area.x + area.width {
break;
}
buf.set_line(x, area.y, &line, w);
x += w;
}
}
}
#[must_use]
pub fn compute_effective_hints<'a>(
hints: &'a [HintItem],
compact: Option<&'a CompactConfig>,
) -> Vec<&'a HintItem> {
let Some(cfg) = compact else {
return hints.iter().collect();
};
let mut result: Vec<&HintItem> = Vec::new();
let pinned_count = hints.iter().filter(|h| h.pinned).count();
for h in hints.iter().filter(|h| h.pinned) {
result.push(h);
}
let remaining = cfg.max_visible.saturating_sub(pinned_count);
for h in hints.iter().filter(|h| !h.pinned).take(remaining) {
result.push(h);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
struct TestStyles;
impl ShortcutBarStyling for TestStyles {
fn key_style(&self) -> Style {
Style::default()
}
fn label_style(&self) -> Style {
Style::default()
}
fn separator_style(&self) -> Style {
Style::default()
}
fn background_style(&self) -> Style {
Style::default()
}
fn pending_key_style(&self) -> Style {
Style::default()
}
}
#[test]
fn hint_item_display_width() {
assert_eq!(HintItem::new("Enter", "send").display_width(), 11); }
#[test]
fn compute_effective_compact_preserves_pinned() {
let hints = vec![
HintItem::new("a", "x").pinned(),
HintItem::new("b", "y"),
HintItem::new("c", "z"),
];
let cfg = CompactConfig {
max_visible: 2,
..Default::default()
};
let eff = compute_effective_hints(&hints, Some(&cfg));
assert_eq!(eff.len(), 2);
assert_eq!(eff[0].key, "a");
assert_eq!(eff[1].key, "b");
}
#[test]
fn shortcuts_bar_renders_without_panic() {
let styles = TestStyles;
let hints = vec![
HintItem::new("Enter", "send"),
HintItem::new("Esc", "cancel"),
];
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
ShortcutsBar::new(&hints, &styles).render(Rect::new(0, 0, 80, 1), &mut buf);
assert_eq!(buf[(0, 0)].symbol(), "E");
}
#[test]
fn shortcuts_bar_pending_mode() {
let styles = TestStyles;
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
ShortcutsBar::new(&[], &styles)
.pending(PendingHint {
key: "q",
label: "quit",
})
.render(Rect::new(0, 0, 80, 1), &mut buf);
assert_eq!(buf[(0, 0)].symbol(), "p");
}
}