use crossterm::event::{KeyEvent, MouseEvent};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Modifier;
use crate::core::{Hit, HitBox, MinSize, Outcome};
use crate::draw::{self, fill, put, put_right, st};
use crate::theme::{self, Theme, Variant};
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FooterBinding {
pub key: String,
pub description: String,
pub enabled: bool,
pub priority: u8,
}
impl FooterBinding {
pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
key: key.into(),
description: desc.into(),
enabled: true,
priority: 128,
}
}
pub fn enabled(mut self, e: bool) -> Self {
self.enabled = e;
self
}
pub fn priority(mut self, p: u8) -> Self {
self.priority = p;
self
}
}
impl From<(&str, &str)> for FooterBinding {
fn from((k, d): (&str, &str)) -> Self {
FooterBinding::new(k, d)
}
}
#[derive(Clone, Debug, Default)]
pub struct KeyFooterState {
pub hits: Vec<HitBox>,
pub hover: Option<usize>,
pub pressed: Option<usize>,
}
impl KeyFooterState {
pub fn new() -> Self {
Default::default()
}
pub fn take_pressed(&mut self) -> Option<usize> {
self.pressed.take()
}
}
impl crate::core::Interactive for KeyFooterState {
fn handle_key(&mut self, _k: KeyEvent) -> Outcome {
Outcome::Ignored
}
fn handle_mouse(&mut self, m: MouseEvent) -> Outcome {
let mut out = Outcome::Ignored;
self.hover = None;
for (i, hit) in self.hits.iter_mut().enumerate() {
let h = hit.mouse(&m);
if h == Hit::Press {
self.pressed = Some(i);
out = Outcome::Submitted;
} else if h == Hit::HoverChanged && hit.hover {
self.hover = Some(i);
out = Outcome::Consumed;
}
}
out
}
}
#[derive(Clone, Debug)]
pub struct KeyFooter {
bindings: Vec<FooterBinding>,
compact: bool,
groups: Vec<Vec<usize>>,
right: Option<String>,
message: Option<(String, Variant)>,
theme: Option<Theme>,
}
impl KeyFooter {
pub fn new() -> Self {
Self {
bindings: Vec::new(),
compact: false,
groups: Vec::new(),
right: None,
message: None,
theme: None,
}
}
pub fn bindings(mut self, b: &[(&str, &str)]) -> Self {
self.bindings = b
.iter()
.map(|&(k, d)| FooterBinding::from((k, d)))
.collect();
self
}
pub fn bindings_full(mut self, b: Vec<FooterBinding>) -> Self {
self.bindings = b;
self
}
pub fn compact(mut self, c: bool) -> Self {
self.compact = c;
self
}
pub fn groups(mut self, g: &[&[usize]]) -> Self {
self.groups = g.iter().map(|&slice| slice.to_vec()).collect();
self
}
pub fn right(mut self, r: impl Into<String>) -> Self {
self.right = Some(r.into());
self
}
pub fn message(mut self, msg: impl Into<String>, v: Variant) -> Self {
self.message = Some((msg.into(), v));
self
}
pub fn theme(mut self, t: &Theme) -> Self {
self.theme = Some(*t);
self
}
pub fn render(self, area: Rect, buf: &mut Buffer, state: &mut KeyFooterState) {
let th = self.theme.unwrap_or_else(theme::current);
if draw::refuse(buf, area, self.min_size(), th.text_disabled) {
state.hits.clear();
return;
}
fill(buf, area, th.panel);
if let Some((msg, variant)) = self.message {
let msg_color = th.text_variant(variant);
put(
buf,
area.x + 1,
area.y,
&msg,
area.width.saturating_sub(2),
st(msg_color, th.panel).add_modifier(Modifier::BOLD),
);
state.hits.clear();
return;
}
let mut limit = area.right();
if let Some(ref right_text) = self.right {
let w = (right_text.width() as u16).min(area.width / 2);
let text = crate::draw::truncate(right_text, w as usize);
put_right(buf, area, &text, st(th.text_muted, th.panel));
limit = limit.saturating_sub(w + 1);
}
let mut sorted: Vec<(usize, &FooterBinding)> = self.bindings.iter().enumerate().collect();
sorted.sort_by_key(|(_, b)| std::cmp::Reverse(b.priority));
let mut x = area.x;
let mut visible = Vec::new();
let mut overflow = false;
for &(orig_idx, binding) in &sorted {
let key_w = binding.key.len() as u16 + 2;
let desc_w = if self.compact {
0
} else {
binding.description.len() as u16 + 1
};
let w = key_w + desc_w;
if x + w > limit.saturating_sub(2) {
overflow = true;
break;
}
visible.push((orig_idx, binding, x, w, key_w));
x += w + 1;
}
state.hits.clear();
state.hits.resize(self.bindings.len(), HitBox::default());
for (orig_idx, binding, bx, w, key_w) in visible {
let r = Rect {
x: bx,
y: area.y,
width: w,
height: 1,
};
state.hits[orig_idx].set_area(r);
let bg = if state.hover == Some(orig_idx) {
th.hover_bg
} else {
th.panel
};
fill(buf, r, bg);
let key_box_bg = th.footer_bg;
let key_box = Rect {
x: bx,
y: area.y,
width: key_w,
height: 1,
};
fill(buf, key_box, key_box_bg);
put(
buf,
bx,
area.y,
&format!(" {} ", binding.key),
key_w,
st(th.footer_key, key_box_bg).add_modifier(Modifier::BOLD),
);
if !self.compact {
let desc_color = if binding.enabled {
th.footer_desc
} else {
th.text_disabled
};
put(
buf,
bx + key_w,
area.y,
&format!(" {}", binding.description),
w - key_w,
st(desc_color, bg),
);
}
}
if overflow {
put(
buf,
limit.saturating_sub(2),
area.y,
"…",
1,
st(th.text_muted, th.panel),
);
}
}
}
impl Default for KeyFooter {
fn default() -> Self {
Self::new()
}
}
impl MinSize for KeyFooter {
fn min_size(&self) -> (u16, u16) {
(8, 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui_core::buffer::Buffer;
#[test]
fn footer_renders_bindings() {
let mut state = KeyFooterState::new();
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
let area = buf.area;
KeyFooter::new()
.bindings(&[("q", "Quit"), ("^S", "Save")])
.render(area, &mut buf, &mut state);
let row: String = (0..area.width)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
assert!(row.contains("q"), "{row}");
assert!(row.contains("Quit"), "{row}");
assert!(row.contains("^S"), "{row}");
assert!(row.contains("Save"), "{row}");
}
#[test]
fn footer_compact_mode() {
let mut state = KeyFooterState::new();
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
let area = buf.area;
KeyFooter::new()
.bindings(&[("q", "Quit"), ("^S", "Save")])
.render(area, &mut buf, &mut state);
let normal_row: String = (0..area.width)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
let mut buf2 = Buffer::empty(Rect::new(0, 0, 80, 1));
KeyFooter::new()
.bindings(&[("q", "Quit"), ("^S", "Save")])
.compact(true)
.render(area, &mut buf2, &mut state);
let compact_row: String = (0..area.width)
.map(|x| buf2[(x, 0)].symbol().to_string())
.collect();
assert_ne!(normal_row, compact_row, "compact should differ from normal");
assert!(!compact_row.contains("Quit"), "{compact_row}");
assert!(!compact_row.contains("Save"), "{compact_row}");
assert!(compact_row.contains("q"), "{compact_row}");
assert!(compact_row.contains("^S"), "{compact_row}");
}
#[test]
fn footer_overflow_drops_low_priority() {
let mut state = KeyFooterState::new();
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1));
let area = buf.area;
let bindings = vec![
FooterBinding::new("q", "Quit").priority(255),
FooterBinding::new("^S", "Save").priority(200),
FooterBinding::new("F1", "Help").priority(100),
FooterBinding::new("F2", "Info").priority(50),
];
KeyFooter::new()
.bindings_full(bindings)
.render(area, &mut buf, &mut state);
let row: String = (0..area.width)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
assert!(row.contains("Quit"), "{row}");
assert!(!row.contains("Info"), "{row}");
assert!(row.contains("…"), "{row}");
}
#[test]
fn footer_message_mode() {
let mut state = KeyFooterState::new();
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
KeyFooter::new()
.message("Saved successfully", Variant::Success)
.render(buf.area, &mut buf, &mut state);
assert!(state.hits.is_empty());
}
}