Files
improvise/src/ui/tile_bar.rs
T
2026-09-24 22:05:24 -07:00

282 lines
9.6 KiB
Rust

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<String> = tiles
.iter()
.map(|tile| {
let (axis_symbol, _) = TileBar::axis_display(tile.axis);
format!(" [{} {axis_symbol}] ", tile.category)
})
.collect();
let widths: Vec<u16> = 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::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.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:?}"
);
}
}
}