e8e7a7f1fe
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>
222 lines
7.1 KiB
Rust
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]"
|
|
);
|
|
}
|
|
}
|