f7d2730736
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
282 lines
9.6 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|