use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::Widget, }; use unicode_width::UnicodeWidthStr; use crate::protocol::TileBarCache; use crate::protocol::face::Face; use crate::ui::app::AppMode; use crate::view::Axis; const PREFIX: &str = " Tiles: "; /// Where the first drawn tile begins: past the prefix and a gap, and past /// the `◀` once the bar has scrolled. fn tiles_left(start: usize) -> u16 { PREFIX.width() as u16 + 1 + u16::from(start > 0) } /// Scroll by whole tiles: the least start from which tile `sel` is drawn /// whole, measured from where [`tiles_left`] draws (improvise-gtn7.1). fn first_tile(widths: &[u16], sel: usize, width: u16) -> usize { let sel = sel.min(widths.len().saturating_sub(1)); (0..sel) .find(|&start| { let used: u32 = widths[start..=sel].iter().map(|&w| u32::from(w)).sum(); used <= u32::from(width.saturating_sub(tiles_left(start))) }) .unwrap_or(sel) } pub struct TileBar<'a> { pub tiles: &'a TileBarCache, pub mode: &'a AppMode, pub tile_cat_idx: usize, } impl<'a> TileBar<'a> { pub fn new(tiles: &'a TileBarCache, mode: &'a AppMode, tile_cat_idx: usize) -> Self { Self { tiles, mode, tile_cat_idx, } } fn axis_display(axis: Axis) -> (&'static str, Style) { ( axis.short_label(), crate::draw::face_style(Face::Axis(axis)), ) } } impl<'a> Widget for TileBar<'a> { fn render(self, area: Rect, buf: &mut Buffer) { // Clear the line to avoid stale characters from previous renders buf.set_string( area.x, area.y, " ".repeat(area.width as usize), Style::default(), ); let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) { Some(self.tile_cat_idx) } else { None }; let prefix_w = PREFIX.width() as u16; buf.set_string(area.x, area.y, PREFIX, Style::default().fg(Color::Gray)); let tiles = &self.tiles.tiles; // Compute label widths for all tiles let labels: Vec = tiles .iter() .map(|tile| { let (axis_symbol, _) = TileBar::axis_display(tile.axis); format!(" [{} {axis_symbol}] ", tile.category) }) .collect(); let widths: Vec = labels.iter().map(|l| l.width() as u16).collect(); let start = first_tile(&widths, selected_cat_idx.unwrap_or(0), area.width); // Draw an overflow indicator if we scrolled past the beginning let mut x = area.x + tiles_left(start); if start > 0 { buf.set_string( area.x + prefix_w, area.y, "◀", Style::default().fg(Color::DarkGray), ); } // Render tiles from `start` let mut last_drawn = start; for i in start..labels.len() { let label_w = widths[i]; if x + label_w > area.x + area.width { break; } let (_, axis_style) = TileBar::axis_display(tiles[i].axis); let is_selected = selected_cat_idx == Some(i); let style = if is_selected { Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { axis_style }; buf.set_string(x, area.y, &labels[i], style); x += label_w; last_drawn = i; } // Draw overflow indicator if tiles remain after the visible area if last_drawn + 1 < labels.len() && x < area.x + area.width { buf.set_string(x, area.y, "▶", Style::default().fg(Color::DarkGray)); } // The bar draws tiles and advertises no keys: the status line's hint is // derived from the keymap and this one was not (improvise-rr8n). } } #[cfg(test)] mod tests { use super::*; use crate::ui::app::App; use crate::workbook::Workbook; use improvise_command::ui::project; fn app_with_two_categories() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_category("Product").unwrap(); wb.add_item("Product", "Shirts").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); let mut app = App::new(wb, None); app.rebuild_layout(); app } fn buf_text(buf: &Buffer) -> String { let w = buf.area().width as usize; buf.content() .chunks(w) .map(|row| { row.iter() .map(|c| c.symbol()) .collect::() .trim_end() .to_string() }) .collect::>() .join("\n") } /// The cache must carry exactly the pairs the widget used to read off the /// model (improvise-35e) — `context/test-evidence/tui.md`. #[test] fn the_cache_carries_what_the_widget_used_to_read_off_the_model() { let app = app_with_two_categories(); let wb = &app.model_state.workbook; let view = wb.active_view(); let was: Vec<(String, Axis)> = view .tileable_cats(&wb.model.category_names()) .into_iter() .map(|cat| (cat.to_string(), view.axis_of(cat))) .collect(); let now: Vec<(String, Axis)> = project::tile_bar_of(&app) .tiles .into_iter() .map(|t| (t.category, t.axis)) .collect(); assert!(!was.is_empty(), "fixture must offer some tiles"); assert_eq!(was, now); } /// Pins the rendered line. The equivalence test above says the *inputs* /// are unchanged by the migration; this says the drawing of them is. #[test] fn renders_each_tile_with_its_axis_label() { let app = app_with_two_categories(); let cache = project::tile_bar_of(&app); let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf); let text = buf_text(&buf); assert_eq!( text.trim_end(), " Tiles: [_Index ·] [_Dim ·] [_Measure Col] [Region Row] [Product Col]" ); } /// The tile bar advertised keys that do something else (improvise-rr8n) — /// `context/test-evidence/tui.md`. #[test] fn the_tile_bar_advertises_no_keys_of_its_own() { let app = app_with_two_categories(); let cache = project::tile_bar_of(&app); for mode in [AppMode::Normal, AppMode::TileSelect] { // Wide enough that a hint would fit: the shipped 80-column fixture // is too narrow to draw one, which is how the false claim survived. let area = Rect::new(0, 0, 140, 1); let mut buf = Buffer::empty(area); TileBar::new(&cache, &mode, 0).render(area, &mut buf); let text = buf_text(&buf); for token in ["Ctrl", "Enter", "Esc", "←→", "move tiles"] { assert!( !text.contains(token), "the tile bar names {token:?} in {mode:?}, and nothing checks \ that claim against the keymap: {text:?}" ); } } } /// improvise-gtn7.1: the tile cursor could scroll off the bar — /// `context/test-evidence/tui.md`. #[test] fn the_selected_tile_is_drawn_at_every_width_it_fits_in() { let mut wb = Workbook::new("Test"); for i in 0..8 { wb.add_category(format!("Category_{i}")).unwrap(); } let app = App::new(wb, None); let cache = project::tile_bar_of(&app); let names: Vec<&str> = cache.tiles.iter().map(|t| t.category.as_str()).collect(); assert!(names.len() > 8, "setup: the narrow widths must scroll"); for width in 40u16..=200 { for (sel, name) in names.iter().enumerate() { let area = Rect::new(0, 0, width, 1); let mut buf = Buffer::empty(area); TileBar::new(&cache, &AppMode::TileSelect, sel).render(area, &mut buf); let highlighted: String = buf .content() .iter() .filter(|c| c.bg == Color::Cyan) .map(|c| c.symbol()) .collect(); assert!( highlighted.contains(&format!("[{name} ")), "width {width}: tile {sel} ({name}) is selected but not drawn: {:?}", buf_text(&buf) ); } } } /// The tiles themselves are what the bar is for, and deleting the hint must /// not delete them. improvise-rr8n. #[test] fn the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to() { let app = app_with_two_categories(); let cache = project::tile_bar_of(&app); for width in [80u16, 140] { let area = Rect::new(0, 0, width, 1); let mut buf = Buffer::empty(area); TileBar::new(&cache, &AppMode::TileSelect, 0).render(area, &mut buf); let text = buf_text(&buf); assert!( text.contains("[Region Row]") && text.contains("Tiles:"), "at width {width} the bar lost its tiles: {text:?}" ); } } }