Files
improvise/src/ui/category_panel.rs
T
Edward Langley dd1194ef45 fix: one display-width-safe truncator, and the two panels that measured in bytes (improvise-8l0c)
`src/ui/formula_panel.rs` cut a formula by bytes and subtracted 1 from an
unchecked width, so `|é| = 1` in a panel of outer width 7 panicked on a char
boundary and an inner width of 0 panicked on the subtraction. Formula
identifiers are pipe-quoted and may carry any character, so an ordinary resize
crashed the TUI.

The grid's existing width-measured `truncate` moves to `src/ui/text.rs` and the
formula panel calls it, rather than becoming a third copy. The wizard's
byte-measured one stays as `context/code/tui.md` argues. `prefix_by_width`
replaces the old `chars().take` tail, which over-ran by a cell for a wide
character at width 1.

Searching for the class rather than the line found two siblings: the category
and view panels placed a second string at `inner.x + name.len()`, a byte count
used as a column. Neither panics, so the symptom is a gap that widens with every
non-ASCII character in the name.

Two rival fixes were injected and watched fail: a char-count truncator (passes
both panic tests, over-runs the width on `日本語`), and repairing only the
subtraction (still panics on the char boundary, and at width 1).

Evidence: context/test-evidence/tui.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:28:51 -07:00

147 lines
4.2 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
};
use unicode_width::UnicodeWidthStr;
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
use crate::ui::app::Panel as PanelKind;
use crate::ui::panel::PanelContent;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (String, Color) {
(axis.to_string(), crate::draw::axis_fg(axis.color()))
}
/// The category panel draws one flattened tree and nothing else: the axis
/// travels on the entry (improvise-edp), so there is no `View` here.
pub struct CategoryContent {
tree: Vec<CatTreeEntry>,
}
impl CategoryContent {
pub fn new(
categories: &CategoryTreeCache,
expanded: &std::collections::HashSet<String>,
) -> Self {
Self {
tree: flatten_cat_tree(categories, expanded),
}
}
}
impl PanelContent for CategoryContent {
fn panel(&self) -> PanelKind {
PanelKind::Category
}
fn active_color(&self) -> Color {
Color::Cyan
}
fn title(&self) -> &str {
" Categories "
}
fn item_count(&self) -> usize {
self.tree.len()
}
fn empty_message(&self) -> &str {
"(no categories — use :add-cat <name>)"
}
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
let y = inner.y + index as u16;
let base_style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
if is_selected {
let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, y, &fill, base_style);
}
match &self.tree[index] {
CatTreeEntry::Category {
name,
item_count,
expanded,
axis,
} => {
let indicator = if *expanded { "▼" } else { "▶" };
let (axis_str, axis_color) = axis_display(*axis);
let name_part = format!("{indicator} {name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style);
if name_part.width() + axis_part.width() < inner.width as usize {
buf.set_string(
inner.x + name_part.width() as u16,
y,
&axis_part,
if is_selected {
base_style
} else {
Style::default().fg(axis_color)
},
);
}
}
CatTreeEntry::Item { item_name, .. } => {
let label = format!(" · {item_name}");
buf.set_string(inner.x, y, &label, base_style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::CategoryRow;
use crate::ui::app::AppMode;
use crate::ui::panel::Panel;
use ratatui::{layout::Rect, widgets::Widget};
/// improvise-8l0c — `context/test-evidence/tui.md`.
fn drawn_row(name: &str) -> String {
let cache = CategoryTreeCache {
categories: vec![CategoryRow {
name: name.to_string(),
axis: Axis::Row,
items: vec!["x".to_string()],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(
CategoryContent::new(&cache, &Default::default()),
&AppMode::Normal,
0,
)
.render(area, &mut buf);
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
let ascii = drawn_row("Cafe");
let wide = drawn_row("Café");
assert_eq!(
ascii.replace("Cafe", "Café"),
wide,
"the axis tag moved because the name was measured in bytes"
);
}
}