Files
improvise/src/ui/tile_bar.rs
T
Edward Langley e8e7a7f1fe refactor(panels, examples): cap the remaining runs
The panel migration tests (improvise-35e) keep their claim and lose their
history; tile_bar's scroll loop keeps its algorithm note. gen-grammar's
usage block is trimmed to the one line a reader runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:51:38 -07:00

222 lines
7.1 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use unicode_width::UnicodeWidthStr;
use crate::protocol::TileBarCache;
use crate::ui::app::AppMode;
use crate::view::Axis;
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, Color) {
(axis.short_label(), crate::draw::axis_fg(axis.color()))
}
}
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 = " Tiles: ";
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();
// Available space for tiles (after prefix)
let avail = area.width.saturating_sub(prefix_w);
// Scroll by whole tiles: the smallest start index at which the
// selected tile still fits in `avail`.
let sel = selected_cat_idx.unwrap_or(0);
let mut start = 0;
loop {
// Check if selected tile is visible when starting from `start`
let mut used: u16 = 0;
let mut sel_visible = false;
for (i, w) in widths.iter().enumerate().take(labels.len()).skip(start) {
if used + *w > avail {
break;
}
used += *w;
if i == sel {
sel_visible = true;
}
}
if sel_visible || start >= sel {
break;
}
start += 1;
}
// Draw an overflow indicator if we scrolled past the beginning
let mut x = area.x + prefix_w + 1;
if start > 0 {
buf.set_string(
area.x + prefix_w,
area.y,
"◀",
Style::default().fg(Color::DarkGray),
);
x += 1;
}
// 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_color) = 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 {
Style::default().fg(axis_color)
};
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));
x += 1;
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}
}
}
#[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]"
);
}
}