use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::{ratatui_color, set_cell};
use crate::primitives::command_center::{
CommandCenter, CommandCenterHit, CommandCenterLayout, CommandCenterMeasure,
};
use crate::theme::Theme;
const TUI_ARROW_WIDTH: f32 = 2.0;
const TUI_GAP: f32 = 1.0;
pub fn tui_command_center_layout(cc: &CommandCenter, area: Rect) -> CommandCenterLayout {
let search_w = if cc.search_label.is_empty() {
0.0
} else {
(cc.search_label.chars().count() + 4) as f32
};
cc.layout(
crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
),
CommandCenterMeasure {
arrow_width: TUI_ARROW_WIDTH,
gap: TUI_GAP,
search_box_width: search_w,
height: area.height.min(1) as f32,
},
)
}
pub fn draw_command_center(
buf: &mut Buffer,
area: Rect,
cc: &CommandCenter,
theme: &Theme,
) -> CommandCenterLayout {
if area.width == 0 || area.height == 0 {
return CommandCenterLayout::empty(crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
));
}
let mut layout = tui_command_center_layout(cc, area);
let area_right = area.x as f32 + area.width as f32;
let fits = |r: crate::event::Rect| r.x >= area.x as f32 && r.x + r.width <= area_right;
let back_fits = layout.back_bounds.is_some_and(fits);
let forward_fits = layout.forward_bounds.is_some_and(fits);
let search_fits = layout.search_bounds.is_some_and(fits);
if !back_fits {
layout.back_bounds = None;
}
if !forward_fits {
layout.forward_bounds = None;
}
if !search_fits {
layout.search_bounds = None;
}
layout.hit_regions.retain(|(_, hit)| match hit {
CommandCenterHit::Back => back_fits,
CommandCenterHit::Forward => forward_fits,
CommandCenterHit::SearchBox => search_fits,
CommandCenterHit::Bar | CommandCenterHit::Outside => true,
});
let bg = ratatui_color(theme.tab_bar_bg);
let enabled_fg = ratatui_color(theme.tab_inactive_fg);
let disabled_fg = ratatui_color(theme.muted_fg);
let border_fg = ratatui_color(theme.muted_fg);
let text_fg = ratatui_color(theme.tab_inactive_fg);
let y = area.y;
for x in area.x..area.x + area.width {
set_cell(buf, x, y, ' ', enabled_fg, bg);
}
if let Some(bb) = layout.back_bounds {
let bx = bb.x.round() as u16;
let fg = if cc.back_enabled {
enabled_fg
} else {
disabled_fg
};
set_cell(buf, bx, y, '◀', fg, bg);
}
if let Some(fb) = layout.forward_bounds {
let fx = fb.x.round() as u16;
let fg = if cc.forward_enabled {
enabled_fg
} else {
disabled_fg
};
set_cell(buf, fx, y, '▶', fg, bg);
}
if let Some(sb) = layout.search_bounds {
let sx = sb.x.round() as u16;
let sw = sb.width.round() as u16;
set_cell(buf, sx, y, '[', border_fg, bg);
for (col, ch) in (sx + 1..).zip(cc.search_label.chars()) {
if col >= sx + sw - 1 {
break;
}
set_cell(buf, col, y, ch, text_fg, bg);
}
if sw > 1 {
set_cell(buf, sx + sw - 1, y, ']', border_fg, bg);
}
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
fn mk_cc(search: &str) -> CommandCenter {
CommandCenter {
id: WidgetId::new("cc"),
back_enabled: true,
forward_enabled: true,
search_label: search.into(),
}
}
fn arrows_paint_and_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 cc = mk_cc("Search");
let layout = draw_command_center(&mut buf, area, &cc, &Theme::default());
let bb = layout.back_bounds.unwrap();
let bx = bb.x.round() as u16;
assert_eq!(cell_char(&buf, bx, origin_y), '◀');
let hit = layout.hit_test(bb.x + 0.5, origin_y as f32 + 0.5);
assert_eq!(hit, CommandCenterHit::Back);
let fb = layout.forward_bounds.unwrap();
let fx = fb.x.round() as u16;
assert_eq!(cell_char(&buf, fx, origin_y), '▶');
let hit = layout.hit_test(fb.x + 0.5, origin_y as f32 + 0.5);
assert_eq!(hit, CommandCenterHit::Forward);
}
#[test]
fn arrows_paint_and_click_round_trip() {
arrows_paint_and_click_round_trip_at(0, 0);
}
#[test]
fn arrows_paint_and_click_round_trip_at_nonzero_origin() {
arrows_paint_and_click_round_trip_at(7, 13);
}
fn search_box_paint_and_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 cc = mk_cc("Test");
let layout = draw_command_center(&mut buf, area, &cc, &Theme::default());
let sb = layout.search_bounds.unwrap();
let sx = sb.x.round() as u16;
assert_eq!(cell_char(&buf, sx, origin_y), '[');
assert_eq!(cell_char(&buf, sx + 1, origin_y), 'T');
let hit = layout.hit_test(sb.x + 2.0, origin_y as f32 + 0.5);
assert_eq!(hit, CommandCenterHit::SearchBox);
}
#[test]
fn search_box_paint_and_click_round_trip() {
search_box_paint_and_click_round_trip_at(0, 0);
}
#[test]
fn search_box_paint_and_click_round_trip_at_nonzero_origin() {
search_box_paint_and_click_round_trip_at(7, 13);
}
#[test]
fn no_search_box_when_label_empty() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let cc = mk_cc("");
let layout = draw_command_center(&mut buf, area, &cc, &Theme::default());
assert!(layout.search_bounds.is_none());
}
#[test]
fn outside_hit() {
let area = Rect::new(10, 0, 20, 1);
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
let cc = mk_cc("X");
let layout = draw_command_center(&mut buf, area, &cc, &Theme::default());
assert_eq!(layout.hit_test(0.0, 0.5), CommandCenterHit::Outside);
}
#[test]
fn zero_size_is_a_no_op() {
let buf_area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(buf_area);
let cc = mk_cc("X");
let layout = draw_command_center(&mut buf, Rect::new(0, 0, 0, 0), &cc, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), ' ');
assert!(layout.back_bounds.is_none());
assert!(layout.forward_bounds.is_none());
assert!(layout.search_bounds.is_none());
assert_eq!(layout.hit_test(0.0, 0.0), CommandCenterHit::Outside);
}
#[test]
fn zero_width_area_is_hit_testably_empty() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
let cc = mk_cc("X");
let layout = draw_command_center(&mut buf, Rect::new(0, 0, 0, 1), &cc, &Theme::default());
assert!(layout.back_bounds.is_none());
assert!(layout.forward_bounds.is_none());
assert!(layout.search_bounds.is_none());
for x in 0..10 {
assert_eq!(
layout.hit_test(x as f32 + 0.5, 0.5),
CommandCenterHit::Outside
);
}
}
#[test]
fn zero_height_area_is_hit_testably_empty() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
let cc = mk_cc("X");
let layout = draw_command_center(&mut buf, Rect::new(0, 0, 10, 0), &cc, &Theme::default());
assert!(layout.back_bounds.is_none());
assert!(layout.forward_bounds.is_none());
assert!(layout.search_bounds.is_none());
assert_eq!(layout.hit_test(0.0, 0.0), CommandCenterHit::Outside);
}
#[test]
fn clipped_search_box_does_not_report_search_bounds() {
let area = Rect::new(0, 0, 8, 1);
let mut buf = Buffer::empty(area);
let cc = mk_cc("Search");
let layout = draw_command_center(&mut buf, area, &cc, &Theme::default());
assert!(
layout.search_bounds.is_none(),
"search box doesn't fit in an 8-wide area; its bounds must not be reported"
);
assert_eq!(cell_char(&buf, 6, 0), ' ');
assert_eq!(layout.hit_test(6.5, 0.5), CommandCenterHit::Bar);
}
}