feat(core,protocol): a display beside the key, painted by the three panels (improvise-cq1q.3)

- `Category`, `Item` and `View` each gain a private `display` that starts equal
  to the key and changes only through `set_display` / `set_item_display`.
- `CategoryRow`, `TileEntry` and `ViewSummary` carry `display` beside their key
  field. The key stays what commands are composed from.
- The category-panel headers, the tile bar and the view list paint the display
  in the TUI and the browser. The active-view marker still compares keys.
- No production code sets a display yet, so behaviour is unchanged.
- This is the first slice of ADR 007 step 2. The grid, page bar, records labels,
  item rows and CSV export are improvise-cq1q.12; messages and the view title
  are improvise-cq1q.13.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-adr007)
2026-09-25 12:12:59 -07:00
committed by Edward Langley
parent 6da6a0401d
commit 3116d524fb
15 changed files with 314 additions and 12 deletions
+25
View File
@@ -1739,3 +1739,28 @@ must name both lines of a duplicate; `ClaimError` carries only the id, so the
loader keeps the line numbers. The virtual views `_records` and `_drill` are loader keeps the line numbers. The virtual views `_records` and `_drill` are
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
with the ADR text fix (improvise-cq1q.1) rather than ahead of it. with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3)
`Category`, `Item` and `View` each carry a private `display`. It starts equal to
the key and changes only through `set_display` / `set_item_display`, which move
no key. Until the re-key (improvise-cq1q.4), nothing in production calls those
setters, so `display == name` everywhere and behaviour does not change. The
split exists so that the re-key changes what goes *into* the key, and nothing a
person sees moves with it.
**The key travels beside the display, never in place of it.** `CategoryRow`,
`TileEntry` and `ViewSummary` each gain a `display` field. The key field
(`name`, `category`) stays exactly what it was, because both front ends compose
commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of
those strings. Carrying the display *in* the key field would make every
command name a key that does not exist once the two differ. That is the gap
improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The
active-view marker in both view panels still compares keys for the same reason.
**What this first slice covers:** the category panel's headers, the tile bar and
the view list, in both front ends. Item rows, the grid frame, the page bar,
records-mode labels, CSV export headers, status messages and the view title
still show keys. They follow in later slices of this step, because the grid's
label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their
display has to be added at projection time rather than in place.
+26
View File
@@ -1998,3 +1998,29 @@ Rival, from the same review: strip the leading `_` before the space step, so
that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does
`generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test, `generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test,
the proptest was the only thing that saw the rival. the proptest was the only thing that saw the rival.
## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice)
Each renderer test below builds a projection whose display differs from its key.
The test-only way to get one is `set_display`, since no production path makes
them differ yet. Every test failed before the change, with the key drawn where
the display should be: `left: "Region"` against `right: "Sales Area"` in the
projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI
buffers.
### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name
It finds the row by `name == "Region"` before asserting on the display. A rival
that writes the display into `name` fails the lookup, which covers the half that
keeps commands working. `_Measure` is checked to fall back to its own key.
### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key
Rival: the active marker compares `summary.display == active_view`. This test
fails on that rival (no `▶ Overview`), because the active view is a key
(`ViewState::active_view`) and its display differs from it.
### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names
The browser half of the same three panels. It also asserts that the view row is
still `active` when found by key.
@@ -137,6 +137,7 @@ mod tests {
fn cat_name_works_for_both_variants() { fn cat_name_works_for_both_variants() {
let header = CatTreeEntry::Category { let header = CatTreeEntry::Category {
name: "Region".into(), name: "Region".into(),
display: "Region".into(),
item_count: 2, item_count: 2,
expanded: false, expanded: false,
axis: crate::view::Axis::None, axis: crate::view::Axis::None,
@@ -426,6 +426,11 @@ fn non_item(category: &str, item: &str) -> CellId {
} }
} }
/// What a person sees for a category key; the key itself when no category holds it.
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
model.category(name).map_or(name, |c| c.display())
}
/// The categories the category panel draws: **every** category, virtual ones /// The categories the category panel draws: **every** category, virtual ones
/// included, with their items and their axis (improvise-vjms, gotcha 7). /// included, with their items and their axis (improvise-vjms, gotcha 7).
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> { pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
@@ -434,6 +439,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
.into_iter() .into_iter()
.map(|name| CategoryRow { .map(|name| CategoryRow {
name: name.to_string(), name: name.to_string(),
display: category_display(model, name).to_string(),
items: model.effective_item_names(name), items: model.effective_item_names(name),
// `try_axis_of`, not `axis_of`: a view that has never seen a category // `try_axis_of`, not `axis_of`: a view that has never seen a category
// has no entry and `axis_of` panics. Unseen shows as `None`. // has no entry and `axis_of` panics. Unseen shows as `None`.
@@ -463,6 +469,7 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
.iter() .iter()
.map(|(name, view)| ViewSummary { .map(|(name, view)| ViewSummary {
name: name.clone(), name: name.clone(),
display: view.display().to_string(),
axes: view axes: view
.category_axes .category_axes
.iter() .iter()
@@ -484,6 +491,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
.into_iter() .into_iter()
.map(|cat| TileEntry { .map(|cat| TileEntry {
category: cat.to_string(), category: cat.to_string(),
display: category_display(model, cat).to_string(),
axis: view.try_axis_of(cat).unwrap_or(Axis::None), axis: view.try_axis_of(cat).unwrap_or(Axis::None),
kind: model kind: model
.category(cat) .category(cat)
@@ -1793,6 +1801,46 @@ mod tests {
app app
} }
/// One category and the active view each shown under a display that differs from their key.
fn app_with_displays_unlike_their_keys() -> App {
let mut app = app_with_data();
let wb = &mut app.model_state.workbook;
wb.model
.category_mut("Region")
.unwrap()
.set_display("Sales Area");
wb.active_view_mut().set_display("Overview");
app
}
#[test]
fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let rows = category_rows(&app.model_state.workbook.model, app.view());
let region = rows.iter().find(|r| r.name == "Region").unwrap();
assert_eq!(region.display, "Sales Area");
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
assert_eq!(measure.display, "_Measure");
}
#[test]
fn a_tile_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let tiles = tiles_of(&app.model_state.workbook.model, app.view());
let region = tiles.iter().find(|t| t.category == "Region").unwrap();
assert_eq!(region.display, "Sales Area");
}
#[test]
fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let active = app.model_state.workbook.active_view.clone();
let summaries = view_summaries(&app);
let summary = summaries.iter().find(|s| s.name == active).unwrap();
assert_eq!(summary.display, "Overview");
assert_ne!(summary.name, "Overview");
}
/// A pivot-shaped default view plus a records-shaped second one, so a /// A pivot-shaped default view plus a records-shaped second one, so a
/// switch between them crosses the boundary `settled_mode` watches. /// switch between them crosses the boundary `settled_mode` watches.
fn app_with_a_records_shaped_second_view() -> App { fn app_with_a_records_shaped_second_view() -> App {
+45 -2
View File
@@ -8,19 +8,27 @@ pub type ItemId = usize;
pub struct Item { pub struct Item {
pub id: ItemId, pub id: ItemId,
pub name: String, pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Parent group name, if any /// Parent group name, if any
pub group: Option<String>, pub group: Option<String>,
} }
impl Item { impl Item {
pub fn new(id: ItemId, name: impl Into<String>) -> Self { pub fn new(id: ItemId, name: impl Into<String>) -> Self {
let name = name.into();
Self { Self {
id, id,
name: name.into(), display: name.clone(),
name,
group: None, group: None,
} }
} }
pub fn display(&self) -> &str {
&self.display
}
pub fn with_group(mut self, group: impl Into<String>) -> Self { pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into()); self.group = Some(group.into());
self self
@@ -129,6 +137,8 @@ impl CategoryKind {
pub struct Category { pub struct Category {
pub id: CategoryId, pub id: CategoryId,
pub name: String, pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Items in insertion order /// Items in insertion order
pub items: IndexMap<String, Item>, pub items: IndexMap<String, Item>,
/// Named groups (hierarchy nodes) /// Named groups (hierarchy nodes)
@@ -141,9 +151,11 @@ pub struct Category {
impl Category { impl Category {
pub fn new(id: CategoryId, name: impl Into<String>) -> Self { pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
let name = name.into();
Self { Self {
id, id,
name: name.into(), display: name.clone(),
name,
items: IndexMap::new(), items: IndexMap::new(),
groups: Vec::new(), groups: Vec::new(),
next_item_id: 0, next_item_id: 0,
@@ -151,6 +163,23 @@ impl Category {
} }
} }
pub fn display(&self) -> &str {
&self.display
}
/// Changes what a person sees and nothing else: the key does not move.
pub fn set_display(&mut self, display: impl Into<String>) {
self.display = display.into();
}
/// Changes an item's display; false when the category has no such item.
pub fn set_item_display(&mut self, item: &str, display: impl Into<String>) -> bool {
self.items
.get_mut(item)
.map(|i| i.display = display.into())
.is_some()
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self { pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind; self.kind = kind;
self self
@@ -264,6 +293,20 @@ mod tests {
Category::new(0, "Region") Category::new(0, "Region")
} }
#[test]
fn a_display_starts_as_the_key_and_changing_it_moves_no_key() {
let mut c = cat();
c.add_item("East");
assert_eq!(c.display(), "Region");
assert_eq!(c.items["East"].display(), "East");
c.set_display("Sales Area");
assert!(c.set_item_display("East", "Eastern"));
assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area"));
assert_eq!(c.items["East"].display(), "Eastern");
assert_eq!(c.items["East"].name, "East");
assert!(!c.set_item_display("West", "Western"), "no such item");
}
/// improvise-2lh: call sites ask `of_name` rather than compare literals, /// improvise-2lh: call sites ask `of_name` rather than compare literals,
/// so both directions of the mapping must agree. /// so both directions of the mapping must agree.
#[test] #[test]
+24 -1
View File
@@ -41,6 +41,8 @@ pub enum GroupFold {
#[derive(Clone)] #[derive(Clone)]
pub struct View { pub struct View {
pub name: String, pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Axis assignment for each category /// Axis assignment for each category
pub category_axes: IndexMap<String, Axis>, pub category_axes: IndexMap<String, Axis>,
/// For page axis: selected item per category /// For page axis: selected item per category
@@ -65,6 +67,7 @@ impl std::fmt::Debug for View {
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
let View { let View {
name, name,
display,
category_axes, category_axes,
page_selections, page_selections,
hidden_items, hidden_items,
@@ -86,6 +89,7 @@ impl std::fmt::Debug for View {
.collect(); .collect();
f.debug_struct("View") f.debug_struct("View")
.field("name", name) .field("name", name)
.field("display", display)
.field("category_axes", category_axes) .field("category_axes", category_axes)
.field("page_selections", &pages) .field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items)) .field("hidden_items", &sorted(hidden_items))
@@ -103,9 +107,20 @@ impl View {
VIRTUAL_VIEW_NAMES.contains(&self.name.as_str()) VIRTUAL_VIEW_NAMES.contains(&self.name.as_str())
} }
pub fn display(&self) -> &str {
&self.display
}
/// Changes what a person sees and nothing else: the key does not move.
pub fn set_display(&mut self, display: impl Into<String>) {
self.display = display.into();
}
pub fn new(name: impl Into<String>) -> Self { pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self { Self {
name: name.into(), display: name.clone(),
name,
category_axes: IndexMap::new(), category_axes: IndexMap::new(),
page_selections: HashMap::new(), page_selections: HashMap::new(),
hidden_items: HashMap::new(), hidden_items: HashMap::new(),
@@ -375,6 +390,14 @@ mod tests {
use crate::view::Axis; use crate::view::Axis;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
#[test]
fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() {
let mut v = View::new("Default");
assert_eq!(v.display(), "Default");
v.set_display("Overview");
assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview"));
}
/// improvise-vw47: the count is the starting guess, never the answer. /// improvise-vw47: the count is the starting guess, never the answer.
/// What a taken name costs: `context/test-evidence/core.md`. /// What a taken name costs: `context/test-evidence/core.md`.
#[test] #[test]
+3
View File
@@ -514,6 +514,7 @@ mod tests {
live.fold(&Command::Project(Projection::CategoryTree( live.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![CategoryRow { CategoryTreeUpdate::Categories(vec![CategoryRow {
name: "Region".into(), name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()], items: vec!["North".into(), "South".into()],
axis: Axis::Row, axis: Axis::Row,
}]), }]),
@@ -523,11 +524,13 @@ mod tests {
]))); ])));
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry { live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
axis: Axis::Row, axis: Axis::Row,
}]))); }])));
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)], axes: vec![("Region".into(), Axis::Row)],
}]))); }])));
live.fold(&Command::SetSelected { row: 1, col: 1 }); live.fold(&Command::SetSelected { row: 1, col: 1 });
+2
View File
@@ -556,10 +556,12 @@ mod tests {
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])), Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
Command::Project(Projection::ViewList(vec![ViewSummary { Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)], axes: vec![("Region".into(), Axis::Row)],
}])), }])),
Command::Project(Projection::TileBar(vec![TileEntry { Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
axis: Axis::Page, axis: Axis::Page,
}])), }])),
+13
View File
@@ -759,6 +759,8 @@ impl WidgetCache for GridCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CategoryRow { pub struct CategoryRow {
pub name: String, pub name: String,
/// What the panel shows; `name` stays what a command names (ADR 007).
pub display: String,
pub items: Vec<String>, pub items: Vec<String>,
pub axis: Axis, pub axis: Axis,
} }
@@ -857,6 +859,8 @@ impl WidgetCache for FormulaCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewSummary { pub struct ViewSummary {
pub name: String, pub name: String,
/// What the view panel shows; `name` stays what a command names.
pub display: String,
/// `(category, axis)` for the categories this view displays. /// `(category, axis)` for the categories this view displays.
pub axes: Vec<(String, Axis)>, pub axes: Vec<(String, Axis)>,
} }
@@ -881,6 +885,8 @@ impl WidgetCache for ViewListCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileEntry { pub struct TileEntry {
pub category: String, pub category: String,
/// What the tile shows; `category` stays what a command names.
pub display: String,
pub axis: Axis, pub axis: Axis,
pub kind: CategoryKind, pub kind: CategoryKind,
} }
@@ -1072,6 +1078,7 @@ mod tests {
fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry { fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry {
TileEntry { TileEntry {
category: category.to_string(), category: category.to_string(),
display: category.to_string(),
axis, axis,
kind, kind,
} }
@@ -1156,6 +1163,7 @@ mod tests {
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow { fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
CategoryRow { CategoryRow {
name: name.into(), name: name.into(),
display: name.into(),
items: items.iter().map(|s| (*s).to_string()).collect(), items: items.iter().map(|s| (*s).to_string()).collect(),
axis, axis,
} }
@@ -1801,12 +1809,14 @@ mod tests {
let views = ViewListCache::folded(&[vec![ViewSummary { let views = ViewListCache::folded(&[vec![ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)], axes: vec![("Region".into(), Axis::Row)],
}]]); }]]);
assert_eq!(views.views[0].name, "Default"); assert_eq!(views.views[0].name, "Default");
let tiles = TileBarCache::folded(&[vec![TileEntry { let tiles = TileBarCache::folded(&[vec![TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Page, axis: Axis::Page,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}]]); }]]);
@@ -2201,10 +2211,12 @@ mod tests {
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
Projection::ViewList(vec![ViewSummary { Projection::ViewList(vec![ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::None)], axes: vec![("Region".into(), Axis::None)],
}]), }]),
Projection::TileBar(vec![TileEntry { Projection::TileBar(vec![TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Page, axis: Axis::Page,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}]), }]),
@@ -2242,6 +2254,7 @@ mod tests {
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
Projection::TileBar(vec![TileEntry { Projection::TileBar(vec![TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Page, axis: Axis::Page,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}]), }]),
+10
View File
@@ -1892,11 +1892,13 @@ mod tests {
crate::CategoryTreeUpdate::Categories(vec![ crate::CategoryTreeUpdate::Categories(vec![
crate::CategoryRow { crate::CategoryRow {
name: "Region".into(), name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()], items: vec!["North".into(), "South".into()],
axis: Axis::Row, axis: Axis::Row,
}, },
crate::CategoryRow { crate::CategoryRow {
name: "Product".into(), name: "Product".into(),
display: "Product".into(),
items: vec!["Shirts".into()], items: vec!["Shirts".into()],
axis: Axis::Column, axis: Axis::Column,
}, },
@@ -1975,11 +1977,13 @@ mod tests {
state.tiles.tiles = vec![ state.tiles.tiles = vec![
crate::TileEntry { crate::TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Row, axis: Axis::Row,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}, },
crate::TileEntry { crate::TileEntry {
category: "Product".into(), category: "Product".into(),
display: "Product".into(),
axis: Axis::Column, axis: Axis::Column,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}, },
@@ -2334,10 +2338,12 @@ mod tests {
state.fold(&Command::Project(Projection::ViewList(vec![ state.fold(&Command::Project(Projection::ViewList(vec![
crate::ViewSummary { crate::ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![], axes: vec![],
}, },
crate::ViewSummary { crate::ViewSummary {
name: "Plan".into(), name: "Plan".into(),
display: "Plan".into(),
axes: vec![], axes: vec![],
}, },
]))); ])));
@@ -2617,6 +2623,7 @@ mod tests {
.into_iter() .into_iter()
.map(|name| crate::CategoryRow { .map(|name| crate::CategoryRow {
name: name.into(), name: name.into(),
display: name.into(),
items: vec!["North".into(), "South".into()], items: vec!["North".into(), "South".into()],
axis: Axis::Row, axis: Axis::Row,
}) })
@@ -2635,6 +2642,7 @@ mod tests {
.into_iter() .into_iter()
.map(|name| crate::ViewSummary { .map(|name| crate::ViewSummary {
name: name.into(), name: name.into(),
display: name.into(),
axes: Vec::new(), axes: Vec::new(),
}) })
.collect(); .collect();
@@ -2642,6 +2650,7 @@ mod tests {
.into_iter() .into_iter()
.map(|category| crate::TileEntry { .map(|category| crate::TileEntry {
category: category.into(), category: category.into(),
display: category.into(),
axis: Axis::Row, axis: Axis::Row,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}) })
@@ -3747,6 +3756,7 @@ mod tests {
let mut tiles = in_mode(AppMode::TileSelect); let mut tiles = in_mode(AppMode::TileSelect);
tiles.tiles.tiles = vec![crate::TileEntry { tiles.tiles.tiles = vec![crate::TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: improvise_core::view::Axis::Row, axis: improvise_core::view::Axis::Row,
kind: crate::CategoryKind::Regular, kind: crate::CategoryKind::Regular,
}]; }];
+5 -1
View File
@@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary};
/// so the entry list *is* what the panel draws. /// so the entry list *is* what the panel draws.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatTreeEntry { pub enum CatTreeEntry {
/// Category header row: name, item count, expanded? /// Category header row: name (the key), what it shows, item count, expanded?
Category { Category {
name: String, name: String,
display: String,
item_count: usize, item_count: usize,
expanded: bool, expanded: bool,
axis: Axis, axis: Axis,
@@ -49,6 +50,7 @@ pub fn flatten_cat_tree(
let is_expanded = expanded.contains(&row.name); let is_expanded = expanded.contains(&row.name);
entries.push(CatTreeEntry::Category { entries.push(CatTreeEntry::Category {
name: row.name.clone(), name: row.name.clone(),
display: row.display.clone(),
item_count: row.items.len(), item_count: row.items.len(),
expanded: is_expanded, expanded: is_expanded,
axis: row.axis, axis: row.axis,
@@ -94,6 +96,7 @@ mod tests {
.iter() .iter()
.map(|(name, items)| CategoryRow { .map(|(name, items)| CategoryRow {
name: (*name).to_string(), name: (*name).to_string(),
display: (*name).to_string(),
items: items.iter().map(|i| i.to_string()).collect(), items: items.iter().map(|i| i.to_string()).collect(),
axis: Axis::None, axis: Axis::None,
}) })
@@ -112,6 +115,7 @@ mod tests {
tree, tree,
vec![CatTreeEntry::Category { vec![CatTreeEntry::Category {
name: "Region".into(), name: "Region".into(),
display: "Region".into(),
item_count: 2, item_count: 2,
expanded: false, expanded: false,
axis: Axis::None, axis: Axis::None,
+46 -4
View File
@@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option<Vec<RenderViewRow>> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, summary)| RenderViewRow { .map(|(i, summary)| RenderViewRow {
name: summary.name.clone(), name: summary.display.clone(),
axes: improvise_protocol::viewmodel::view_axis_summary(summary), axes: improvise_protocol::viewmodel::view_axis_summary(summary),
active: summary.name == state.view.active_view, active: summary.name == state.view.active_view,
selected: i == state.view.view_panel_cursor, selected: i == state.view.view_panel_cursor,
@@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, tile)| RenderTile { .map(|(i, tile)| RenderTile {
category: tile.category.clone(), category: tile.display.clone(),
axis: tile.axis.plain_label(), axis: tile.axis.plain_label(),
selected: selecting && i == state.view.tile_cat_idx, selected: selecting && i == state.view.tile_cat_idx,
}) })
@@ -468,12 +468,13 @@ impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self { fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry { match entry {
CatTreeEntry::Category { CatTreeEntry::Category {
name, display,
item_count, item_count,
expanded, expanded,
axis, axis,
..
} => Self { } => Self {
label: name.clone(), label: display.clone(),
is_category: true, is_category: true,
axis: axis.initial().to_string(), axis: axis.initial().to_string(),
item_count: Some(*item_count), item_count: Some(*item_count),
@@ -806,11 +807,13 @@ mod tests {
CategoryTreeUpdate::Categories(vec![ CategoryTreeUpdate::Categories(vec![
CategoryRow { CategoryRow {
name: "Region".into(), name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()], items: vec!["North".into(), "South".into()],
axis: Axis::Row, axis: Axis::Row,
}, },
CategoryRow { CategoryRow {
name: "Measure".into(), name: "Measure".into(),
display: "Measure".into(),
items: vec!["Revenue".into()], items: vec!["Revenue".into()],
axis: Axis::Column, axis: Axis::Column,
}, },
@@ -843,6 +846,39 @@ mod tests {
); );
} }
#[test]
fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() {
let mut client = init();
let state = client.state_mut();
state.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![CategoryRow {
name: "Region".into(),
display: "Sales Area".into(),
items: vec!["North".into()],
axis: Axis::Row,
}]),
)));
state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Overview".into(),
axes: vec![],
}])));
state.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Sales Area".into(),
axis: Axis::Row,
kind: improvise_protocol::CategoryKind::Regular,
}])));
state.view.view_panel_open = true;
state.view.active_view = "Default".into();
let model = client.model();
assert_eq!(model.categories[0].label, "Sales Area");
assert_eq!(model.tiles[0].category, "Sales Area");
let views = model.views.expect("the view panel is open");
assert_eq!(views[0].name, "Overview");
assert!(views[0].active, "the active view is still found by its key");
}
fn client_with_panels() -> Client { fn client_with_panels() -> Client {
let mut client = init(); let mut client = init();
client client
@@ -850,10 +886,12 @@ mod tests {
.fold(&Command::Project(Projection::ViewList(vec![ .fold(&Command::Project(Projection::ViewList(vec![
ViewSummary { ViewSummary {
name: "Default".into(), name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)], axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
}, },
ViewSummary { ViewSummary {
name: "Plan".into(), name: "Plan".into(),
display: "Plan".into(),
axes: vec![], axes: vec![],
}, },
]))); ])));
@@ -867,11 +905,13 @@ mod tests {
.fold(&Command::Project(Projection::TileBar(vec![ .fold(&Command::Project(Projection::TileBar(vec![
TileEntry { TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Row, axis: Axis::Row,
kind: improvise_protocol::CategoryKind::Regular, kind: improvise_protocol::CategoryKind::Regular,
}, },
TileEntry { TileEntry {
category: "Product".into(), category: "Product".into(),
display: "Product".into(),
axis: Axis::None, axis: Axis::None,
kind: improvise_protocol::CategoryKind::Regular, kind: improvise_protocol::CategoryKind::Regular,
}, },
@@ -1192,11 +1232,13 @@ mod tests {
.fold(&Command::Project(Projection::TileBar(vec![ .fold(&Command::Project(Projection::TileBar(vec![
TileEntry { TileEntry {
category: "Quarter".into(), category: "Quarter".into(),
display: "Quarter".into(),
axis: Axis::Page, axis: Axis::Page,
kind: improvise_protocol::CategoryKind::Regular, kind: improvise_protocol::CategoryKind::Regular,
}, },
TileEntry { TileEntry {
category: "Region".into(), category: "Region".into(),
display: "Region".into(),
axis: Axis::Column, axis: Axis::Column,
kind: improvise_protocol::CategoryKind::Regular, kind: improvise_protocol::CategoryKind::Regular,
}, },
+27 -2
View File
@@ -73,14 +73,15 @@ impl PanelContent for CategoryContent {
match &self.tree[index] { match &self.tree[index] {
CatTreeEntry::Category { CatTreeEntry::Category {
name, display,
item_count, item_count,
expanded, expanded,
axis, axis,
..
} => { } => {
let indicator = if *expanded { "▼" } else { "▶" }; let indicator = if *expanded { "▼" } else { "▶" };
let (axis_str, axis_style) = axis_display(*axis); let (axis_str, axis_style) = axis_display(*axis);
let name_part = format!("{indicator} {name} ({item_count})"); let name_part = format!("{indicator} {display} ({item_count})");
let axis_part = format!(" [{axis_str}]"); let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style); buf.set_string(inner.x, y, &name_part, base_style);
@@ -114,6 +115,7 @@ mod tests {
let cache = CategoryTreeCache { let cache = CategoryTreeCache {
categories: vec![CategoryRow { categories: vec![CategoryRow {
name: name.to_string(), name: name.to_string(),
display: name.to_string(),
axis: Axis::Row, axis: Axis::Row,
items: vec!["x".to_string()], items: vec!["x".to_string()],
}], }],
@@ -129,6 +131,29 @@ mod tests {
buf.content()[31..59].iter().map(|c| c.symbol()).collect() buf.content()[31..59].iter().map(|c| c.symbol()).collect()
} }
#[test]
fn a_category_header_draws_its_display_not_its_key() {
let cache = CategoryTreeCache {
categories: vec![CategoryRow {
name: "Region".to_string(),
display: "Sales Area".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);
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
assert!(row.contains("Sales Area"), "{row}");
assert!(!row.contains("Region"), "{row}");
}
/// improvise-8l0c — `context/test-evidence/tui.md`. /// improvise-8l0c — `context/test-evidence/tui.md`.
#[test] #[test]
fn the_axis_tag_sits_beside_a_multibyte_category_name() { fn the_axis_tag_sits_beside_a_multibyte_category_name() {
+18 -1
View File
@@ -79,7 +79,7 @@ impl<'a> Widget for TileBar<'a> {
.iter() .iter()
.map(|tile| { .map(|tile| {
let (axis_symbol, _) = TileBar::axis_display(tile.axis); let (axis_symbol, _) = TileBar::axis_display(tile.axis);
format!(" [{} {axis_symbol}] ", tile.category) format!(" [{} {axis_symbol}] ", tile.display)
}) })
.collect(); .collect();
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect(); let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
@@ -190,6 +190,23 @@ mod tests {
assert_eq!(was, now); assert_eq!(was, now);
} }
#[test]
fn a_tile_draws_its_display_not_its_key() {
let mut app = app_with_two_categories();
let wb = &mut app.model_state.workbook;
wb.model
.category_mut("Region")
.unwrap()
.set_display("Sales Area");
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!(text.contains("Sales Area"), "{text}");
assert!(!text.contains("Region"), "{text}");
}
/// Pins the rendered line. The equivalence test above says the *inputs* /// Pins the rendered line. The equivalence test above says the *inputs*
/// are unchanged by the migration; this says the drawing of them is. /// are unchanged by the migration; this says the drawing of them is.
#[test] #[test]
+21 -1
View File
@@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> {
}; };
let prefix = if is_active_view { "▶ " } else { " " }; let prefix = if is_active_view { "▶ " } else { " " };
let name_text = format!("{prefix}{}", summary.name); let name_text = format!("{prefix}{}", summary.display);
let y = inner.y + index as u16; let y = inner.y + index as u16;
buf.set_string(inner.x, y, &name_text, style); buf.set_string(inner.x, y, &name_text, style);
@@ -98,6 +98,7 @@ mod tests {
let cache = ViewListCache { let cache = ViewListCache {
views: vec![ViewSummary { views: vec![ViewSummary {
name: view_name.to_string(), name: view_name.to_string(),
display: view_name.to_string(),
axes: vec![("Region".to_string(), Axis::Row)], axes: vec![("Region".to_string(), Axis::Row)],
}], }],
}; };
@@ -119,6 +120,25 @@ mod tests {
); );
} }
#[test]
fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() {
use crate::protocol::ViewSummary;
use crate::ui::panel::Panel;
use ratatui::{buffer::Buffer, widgets::Widget};
let cache = ViewListCache {
views: vec![ViewSummary {
name: "Default".to_string(),
display: "Overview".to_string(),
axes: vec![],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf);
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
assert!(row.contains("▶ Overview"), "{row}");
}
fn app_with_two_views() -> App { fn app_with_two_views() -> App {
let mut wb = Workbook::new("Test"); let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap(); wb.add_category("Region").unwrap();