dd1194ef45
`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
147 lines
4.2 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|