use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::text::{char_cell_width, display_width};
use super::{qc, set_cell};
use crate::primitives::toolbar::{
Toolbar, ToolbarButton, ToolbarItemMeasure, ToolbarLayout, ToolbarPaintOptions, ToolbarVAlign,
};
use crate::theme::Theme;
use crate::types::WidgetId;
pub(crate) fn tui_text_row(valign: ToolbarVAlign, area: Rect) -> u16 {
match valign {
ToolbarVAlign::Top => area.y,
ToolbarVAlign::Center => area.y + area.height.saturating_sub(1) / 2,
ToolbarVAlign::Bottom => area.y + area.height.saturating_sub(1),
}
}
pub(crate) fn tui_item_width(btn: &ToolbarButton) -> f32 {
match btn {
ToolbarButton::Action {
label,
icon,
key_hint,
..
} => {
let label_w = display_width(label);
let icon_w = icon.as_ref().map(|s| display_width(s)).unwrap_or(0);
let icon_block = if icon.is_some() && label_w > 0 {
icon_w + 1
} else {
icon_w
};
let hint_w = key_hint
.as_ref()
.map(|s| display_width(s) + 3) .unwrap_or(0);
(4 + icon_block + label_w + hint_w) as f32
}
ToolbarButton::Separator => 2.0,
ToolbarButton::Label { text, .. } => display_width(text) as f32,
}
}
pub fn tui_toolbar_layout(bar: &Toolbar, area: Rect) -> ToolbarLayout {
bar.layout(
area.x as f32,
area.y as f32,
area.width as f32,
area.height.max(1) as f32,
|btn| ToolbarItemMeasure::new(tui_item_width(btn)),
)
}
pub fn draw_toolbar(
buf: &mut Buffer,
area: Rect,
bar: &Toolbar,
theme: &Theme,
hovered_id: Option<&WidgetId>,
pressed_id: Option<&WidgetId>,
) -> ToolbarLayout {
draw_toolbar_with_options(
buf,
area,
bar,
theme,
hovered_id,
pressed_id,
ToolbarPaintOptions::default(),
)
}
pub fn draw_toolbar_with_options(
buf: &mut Buffer,
area: Rect,
bar: &Toolbar,
theme: &Theme,
hovered_id: Option<&WidgetId>,
pressed_id: Option<&WidgetId>,
options: ToolbarPaintOptions,
) -> ToolbarLayout {
if area.width == 0 || area.height == 0 {
return ToolbarLayout::empty(crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
));
}
let layout = tui_toolbar_layout(bar, area);
let bar_bg = qc(bar.bg.unwrap_or(theme.header_bg));
let fg = qc(theme.foreground);
let muted = qc(theme.muted_fg);
let hover_bg = qc(theme.hover_bg);
let hover_fg = qc(theme.hover_fg);
let active_bg = qc(theme.selected_bg);
let focus_bg = qc(theme.accent_bg);
let text_row = tui_text_row(options.valign, area);
for dy in 0..area.height {
for dx in 0..area.width {
set_cell(buf, area.x + dx, area.y + dy, ' ', fg, bar_bg);
}
}
for vis in &layout.visible_items {
let item_x = vis.bounds.x.round() as u16;
let item_w = vis.bounds.width.round() as u16;
if item_w == 0 {
continue;
}
let btn = &bar.buttons[vis.item_idx];
match btn {
ToolbarButton::Action {
id,
label,
icon,
key_hint,
enabled,
is_active,
..
} => {
let is_hovered = *enabled && hovered_id == Some(id);
let is_pressed = *enabled && pressed_id == Some(id);
let is_focused = *enabled && bar.focused_index == Some(vis.item_idx);
let (cell_fg, cell_bg) = if !*enabled {
(muted, bar_bg)
} else if is_pressed || *is_active {
(fg, active_bg)
} else if is_hovered {
(hover_fg, hover_bg)
} else if is_focused {
(fg, focus_bg)
} else {
(fg, bar_bg)
};
let label_w = display_width(label);
let icon_only = icon.is_some() && label_w == 0;
let mut cells: Vec<char> = Vec::with_capacity(item_w as usize);
cells.push('[');
cells.push(' ');
if let Some(icon) = icon {
for ch in icon.chars() {
cells.push(ch);
}
if !icon_only {
cells.push(' ');
}
}
for ch in label.chars() {
cells.push(ch);
}
if let Some(hint) = key_hint {
cells.push(' ');
cells.push('(');
for ch in hint.chars() {
cells.push(ch);
}
cells.push(')');
}
cells.push(' ');
cells.push(']');
for dy in 0..area.height {
for dx in 0..item_w {
set_cell(buf, item_x + dx, area.y + dy, ' ', cell_fg, cell_bg);
}
}
let mut col: u16 = 0;
for ch in cells {
let cw = char_cell_width(ch);
if col + cw > item_w {
break;
}
set_cell(buf, item_x + col, text_row, ch, cell_fg, cell_bg);
col += cw;
}
}
ToolbarButton::Separator => {
for dy in 0..area.height {
if item_w >= 1 {
set_cell(buf, item_x, area.y + dy, ' ', muted, bar_bg);
}
if item_w >= 2 {
set_cell(buf, item_x + 1, area.y + dy, '│', muted, bar_bg);
}
}
}
ToolbarButton::Label { text, fg: label_fg } => {
let color = label_fg.map(qc).unwrap_or(muted);
let mut col: u16 = 0;
for ch in text.chars() {
let cw = char_cell_width(ch);
if col + cw > item_w {
break;
}
set_cell(buf, item_x + col, text_row, ch, color, bar_bg);
col += cw;
}
}
}
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::toolbar::{ToolbarButton, ToolbarHit};
use crate::types::WidgetId;
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
fn mk_action(id: &str, label: &str, enabled: bool) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.to_string(),
icon: None,
key_hint: None,
enabled,
is_active: false,
tooltip: String::new(),
}
}
#[test]
fn draws_action_buttons_with_brackets() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Refine", true)],
bg: None,
focused_index: None,
};
let _layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(cell_char(&buf, 0, 0), '[');
assert_eq!(cell_char(&buf, 1, 0), ' ');
}
fn click_round_trip_at(origin_x: u16, origin_y: u16) {
let area = Rect::new(origin_x, origin_y, 40, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Refine", true), mk_action("b", "Drop", true)],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
let b = layout.visible_items[0].bounds;
assert_eq!(
b.x, origin_x as f32,
"bounds should be absolute (area.x baked in)"
);
assert_eq!(
b.y, origin_y as f32,
"bounds should be absolute (area.y baked in)"
);
let hit = layout.hit_test(b.x + 1.0, b.y);
match hit {
ToolbarHit::Button(id) => assert_eq!(id.as_str(), "a"),
_ => panic!("expected button-a hit"),
}
let b = layout.visible_items[1].bounds;
let hit = layout.hit_test(b.x + 1.0, b.y);
match hit {
ToolbarHit::Button(id) => assert_eq!(id.as_str(), "b"),
_ => panic!("expected button-b hit"),
}
}
#[test]
fn click_round_trip_through_layout() {
click_round_trip_at(0, 0);
}
#[test]
fn click_round_trip_through_layout_at_nonzero_origin() {
click_round_trip_at(7, 13);
}
#[test]
fn disabled_action_not_clickable_after_paint() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Refine", false)],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
let b = layout.visible_items[0].bounds;
assert_eq!(layout.hit_test(b.x + 1.0, b.y), ToolbarHit::Empty);
}
#[test]
fn separator_draws_pipe() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Separator],
bg: None,
focused_index: None,
};
let _layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(cell_char(&buf, 0, 0), ' ');
assert_eq!(cell_char(&buf, 1, 0), '│');
}
#[test]
fn label_draws_text() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Label {
text: "2/5".into(),
fg: None,
}],
bg: None,
focused_index: None,
};
let _layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(cell_char(&buf, 0, 0), '2');
assert_eq!(cell_char(&buf, 1, 0), '/');
assert_eq!(cell_char(&buf, 2, 0), '5');
}
#[test]
fn zero_size_area_is_noop() {
let buf_area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(buf_area);
let area = Rect::new(0, 0, 0, 0);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "X", true)],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(cell_char(&buf, 0, 0), ' ');
assert!(layout.visible_items.is_empty());
assert_eq!(layout.hit_test(0.0, 0.0), ToolbarHit::Empty);
}
#[test]
fn zero_height_area_has_no_hit_testable_buttons() {
let buf_area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(buf_area);
let area = Rect::new(0, 0, 10, 0);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "X", true)],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert!(layout.visible_items.is_empty());
assert_eq!(layout.hit_test(1.0, 0.0), ToolbarHit::Empty);
}
#[test]
fn multi_row_fills_background_across_all_rows() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let _ = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
for y in 0..area.height {
let cell = &buf[(area.width - 1, y)];
assert_eq!(
cell.symbol(),
" ",
"row {} last column not background-filled",
y
);
}
}
#[test]
fn multi_row_center_valign_centres_button_text_vertically() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let options = ToolbarPaintOptions {
valign: ToolbarVAlign::Center,
};
let _ =
draw_toolbar_with_options(&mut buf, area, &bar, &Theme::default(), None, None, options);
assert_eq!(cell_char(&buf, 0, 1), '[');
assert_ne!(cell_char(&buf, 0, 0), '[');
assert_ne!(cell_char(&buf, 0, 2), '[');
}
#[test]
fn multi_row_default_valign_is_top() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let _ = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(cell_char(&buf, 0, 0), '[');
assert_ne!(cell_char(&buf, 0, 1), '[');
assert_ne!(cell_char(&buf, 0, 2), '[');
}
#[test]
fn multi_row_bottom_valign_puts_text_on_last_row() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let options = ToolbarPaintOptions {
valign: ToolbarVAlign::Bottom,
};
let _ =
draw_toolbar_with_options(&mut buf, area, &bar, &Theme::default(), None, None, options);
assert_eq!(cell_char(&buf, 0, 2), '[');
assert_ne!(cell_char(&buf, 0, 0), '[');
assert_ne!(cell_char(&buf, 0, 1), '[');
}
#[test]
fn one_row_slot_every_valign_resolves_identically() {
for valign in [
ToolbarVAlign::Top,
ToolbarVAlign::Center,
ToolbarVAlign::Bottom,
] {
let area = Rect::new(0, 0, 12, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let _ = draw_toolbar_with_options(
&mut buf,
area,
&bar,
&Theme::default(),
None,
None,
ToolbarPaintOptions { valign },
);
assert_eq!(cell_char(&buf, 0, 0), '[', "valign={valign:?}");
}
}
#[test]
fn toolbar_valign_defaults_to_top_when_omitted_from_serde() {
#[derive(serde::Deserialize)]
struct Wrapper {
#[serde(default)]
valign: ToolbarVAlign,
}
let w: Wrapper = serde_json::from_str("{}").unwrap();
assert_eq!(w.valign, ToolbarVAlign::Top);
let json = serde_json::to_string(&ToolbarVAlign::Bottom).unwrap();
let back: ToolbarVAlign = serde_json::from_str(&json).unwrap();
assert_eq!(back, ToolbarVAlign::Bottom);
}
#[test]
fn multi_row_click_in_any_row_hits_button() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Go", true)],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
let r = layout.visible_items[0].bounds;
for dy in 0..3 {
let hit = layout.hit_test(r.x + 1.0, r.y + dy as f32);
match hit {
ToolbarHit::Button(ref id) => assert_eq!(id.as_str(), "a"),
_ => panic!("row {dy} did not hit button"),
}
}
}
#[test]
fn multi_row_separator_extends_full_height() {
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Separator],
bg: None,
focused_index: None,
};
let _ = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
for y in 0..area.height {
assert_eq!(cell_char(&buf, 1, y), '│', "row {y} missing pipe");
}
}
#[test]
fn icon_only_button_compacts_to_three_plus_icon() {
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Action {
id: WidgetId::new("a"),
label: "".into(),
icon: Some("X".into()),
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(layout.visible_items[0].bounds.width, 5.0);
assert_eq!(cell_char(&buf, 0, 0), '[');
assert_eq!(cell_char(&buf, 1, 0), ' ');
assert_eq!(cell_char(&buf, 2, 0), 'X');
assert_eq!(cell_char(&buf, 3, 0), ' ');
assert_eq!(cell_char(&buf, 4, 0), ']');
}
#[test]
fn double_width_icon_reserves_two_cells() {
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Action {
id: WidgetId::new("a"),
label: "Go".into(),
icon: Some("一".into()),
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}],
bg: None,
focused_index: None,
};
let layout = draw_toolbar(&mut buf, area, &bar, &Theme::default(), None, None);
assert_eq!(layout.visible_items[0].bounds.width, 9.0);
}
}