Merge pull request #339 from fiddlerwoaroof/adr007/display-2c

feat(display): status messages and the view title show displays (improvise-cq1q.13)
This commit is contained in:
Ed Langley
2026-10-10 11:16:05 -07:00
committed by GitHub
14 changed files with 586 additions and 48 deletions
+27 -5
View File
@@ -1824,11 +1824,33 @@ beside the key and never in its place:
- The browser's render model keeps its field names and carries the display in them
(`RenderPageAxis.cat`, `RenderCatRow.label`), so `web/` is unchanged.
Still keys: `GridLayout::row_label`/`col_label` (commands and the status line
match on them), `edit_text`, the active-view marker, and everything status
messages and the view title say (improvise-cq1q.13). A display change alone does
not move `layout_fingerprint`, so it resets no cursor; no production path changes
one yet.
Still keys: `GridLayout::row_label`/`col_label` (commands match on them),
`edit_text` and the active-view marker. A display change alone does not move
`layout_fingerprint`, so it resets no cursor; no production path changes one yet.
**What the third slice covers (improvise-cq1q.13):** the view title and every
message that names a thing which exists. The title is the active view's display:
the TUI reads `app.view().display()`, the browser `ViewListCache::display_of`
(key when the list lacks the view). The statuses are the axis change (panel and
tile bar), `Filter:`, `Deleted category/item`, the non-item
refusal, and the item-add prompt. Both ends say them through the same
`viewmodel` builders, so a wording is not spelled twice. The host reads
`Model::category_display`/`item_display`; the client reads
`CategoryTreeCache::category_display`/`item_display`. Each falls back to the key.
**A message that names something which does not exist says what was typed.**
`Unknown category 'x'`, `No view named "x"`, `View 'x' not found` and the
`workbook.rs` errors are lookup misses: there is no display to find, and the typed
text is what the person needs to correct. `Added category "x"` is optimistic and
names what was just typed, whose display equals its key at creation. A stray
coordinate in `non_item_refusal` is a key for the same reason; only the category
beside it is a display. **A display must never reach a lookup**: the item-add
mode keeps the category key (`AppMode::item_add_shown(key, shown)`) and the prompt
alone carries the display. A formula-result refusal still says the target's key
(improvise-cq1q.13 deferred it). A target in `_Measure` is implied by its formula
and is no item, so it has no display to read. One in a regular category is a real
item, but `CellId::Derived` carries only the target, so neither end can find its
category. Both need that surrogate to carry the category first.
`AxisFrame::cat_displays` is projected and validated, but nothing paints it yet
(the corner label has no consumer); that is a gap to close when one does, not a
+78
View File
@@ -2222,3 +2222,81 @@ The display "Eastern Seaboard" against the key "E" gives 17, not the 4 minimum.
Rival: `col_groups` labels a run with `g.name`. The browser test for group displays
covered row groups only; this one folds a column frame whose group "Q1" is shown as
"First quarter" and fails on the key.
### draw::content_tests::the_grid_title_names_the_views_display_not_its_key
improvise-cq1q.13, TUI half. The view is displayed "Quarterly overview of regional
sales" under the key "Default", and the whole frame is drawn through `draw`. Rival:
`draw_content` passes `app.view_state.active_view` to the widget; the title reads
`View: Default` and this test fails on both assertions.
### render::tests::the_title_names_the_active_views_display_not_its_key
The browser half. Rival: `render_model` takes `view_name` from
`state.view.active_view`; the test fails. The second half is the control: a view
the list does not hold falls back to its key rather than to an empty title.
### resolve::display_tests::a_panel_status_names_what_a_person_sees_not_the_keys
The browser resolver's panel statuses: axis cycle, filter, delete category, delete
item. Displays are 36 to 38 characters against keys of 4 to 6. Rivals, each run:
the cycle status passes the key; the filter composes `SetPageSelection` with the
item's display; the item removal names the display. Each fails here, the last two
on the `Region/North` and `RemoveItem` assertions that pin the lookup to keys.
### resolve::display_tests::a_tile_status_names_the_display
Rival: the tile route passes `tile.category`; fails on the key.
### resolve::display_tests::the_item_prompt_names_the_display_and_the_mode_keeps_the_key
Rival: the mode is built with the display as its category
(`item_add_shown(shown, ..)`); the prompt reads right and the commit would add to
a category that does not exist. The test fails on `category == "Region"`.
### resolve::display_tests::a_refused_cell_names_the_category_it_shows
Rival: the non-item refusal gets the category key. The stray coordinate stays a key
because it names no item and has no display.
### command::cmd::panel::tests::a_panel_status_names_what_a_person_sees_not_the_keys
The host half of the panel statuses and the item-add prompt. Rivals, each run: the
axis status passes `cat_name` (fails); the filter status passes the item key
(fails); the item-add mode is built from the display (fails on the
`category: "Type"` assertion).
Review follow-up: the same test now also pins the operands. Rivals: the host
`RemoveCategory` and `SetPageSelection` given the display fail on the
`RemoveCategory("Type")` and `item: "Food"` assertions. The client twin fails the
same way on `RemoveCategory`.
### command::cmd::tile::tests::a_tile_status_names_the_display_not_the_key
Rival: the status formats `name`. It fails on both the present display and the
absent key.
### ui::app::non_item_key_tests::a_non_item_refusal_names_the_categorys_display
Rival, run once per site: the refusal in `EditOrDrill` (the `i` key) and the one in
`CmdContext::value_cell_key` (the `p` key) each pass the key. Each fails here.
### ui::app::non_item_key_tests::a_commit_on_a_non_item_cell_names_the_categorys_display
The third site, `uneditable` in `cmd/core.rs`, which only a commit reaches once an
editor is open (`:enter-mode editing`). Rival: it passes the key; the two tests
above stay green and this one fails.
### ui::app::non_item_key_tests::a_refused_wire_write_names_the_category_it_shows
The wire's own refusal in `Grids::unwritable`. Rival: the category key; fails. The
formula arm (once a third copy of the derived wording, now `derived_refusal`) is
pinned to its wording so the consolidation cannot change what it says.
### resolve::display_tests::the_declines_an_advance_and_a_commit_reach_name_the_category_it_shows
From the Opus review of #339: the browser's `declined_at` non-item arm could pass the
key with the whole suite green. It is reached by `reopened_at` (an advance),
`refused_here` (a commit) and `settle_declined_editor`. Rival: the arm passes
`category`; the test fails on all three assertions, and the display is 36 characters
against the key "Region".
@@ -39,7 +39,9 @@ pub fn uneditable(model: &Model, layout: &GridLayout, row: usize, col: usize) ->
.to_string(),
),
Some(CellId::Derived { target }) => Some(derived_refusal(&target)),
Some(CellId::NonItem { category, item }) => Some(non_item_refusal(&category, &item)),
Some(CellId::NonItem { category, item }) => {
Some(non_item_refusal(model.category_display(&category), &item))
}
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate { .. }) | None => None,
}
}
@@ -133,7 +135,10 @@ impl<'a> CmdContext<'a> {
Some(CellId::Create(_) | CellId::Coordinate { .. }) => {
Err(NO_VALUE_REFUSAL.to_string())
}
Some(CellId::NonItem { category, item }) => Err(non_item_refusal(&category, &item)),
Some(CellId::NonItem { category, item }) => Err(non_item_refusal(
self.model.category_display(&category),
&item,
)),
None => Err(match self.cell_key() {
Some(_) => NO_VALUE_REFUSAL,
None => NO_CELL,
@@ -393,7 +393,8 @@ impl Cmd for EditOrDrill {
// refused later, under a minibuffer that hides why (improvise-yc9r).
if let Some(improvise_protocol::CellId::NonItem { category, item }) = ctx.cursor_cell_id() {
return vec![effect::refuse(improvise_protocol::non_item_refusal(
&category, &item,
ctx.model.category_display(&category),
&item,
))];
}
vec![
@@ -1,4 +1,6 @@
use improvise_protocol::viewmodel::axis_change_status;
use improvise_protocol::viewmodel::{
axis_change_status, deleted_category_status, deleted_item_status, filter_status,
};
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect, Panel, ViewTarget};
@@ -186,6 +188,57 @@ mod tests {
);
}
/// improvise-cq1q.13: evidence in `context/test-evidence/core.md`.
#[test]
fn a_panel_status_names_what_a_person_sees_not_the_keys() {
const TYPE: &str = "Kind of food and drink";
const FOOD: &str = "Foodstuffs of every sort";
let mut wb = two_cat_model();
let ty = wb.model.category_mut("Type").unwrap();
ty.set_display(TYPE);
ty.set_item_display("Food", FOOD);
let layout = make_layout(&wb);
let reg = make_registry();
let mut ctx = make_ctx(&wb, &layout, &reg);
let expanded = std::collections::HashSet::from(["Type".to_string()]);
ctx.expanded_cats = &expanded;
let said = |effects: &[Box<dyn Effect>]| format!("{:?}", effects.last().unwrap());
ctx.cat_panel_cursor = 3;
assert_eq!(ctx.cat_at_cursor().as_deref(), Some("Type"), "setup");
assert!(said(&CycleAxisAtCursor.execute(&ctx)).contains(&format!("{TYPE} → ")));
assert!(said(&DeleteCategoryAtCursor.execute(&ctx)).contains(&format!("'{TYPE}'")));
let removal = format!("{:?}", DeleteCategoryAtCursor.execute(&ctx));
assert!(removal.contains("RemoveCategory(\"Type\")"), "{removal}");
let prompt = format!("{:?}", OpenItemAddAtCursor.execute(&ctx));
assert!(
prompt.contains(&format!("add item to {TYPE}: ")),
"{prompt}"
);
assert!(
prompt.contains("category: \"Type\""),
"the key stays: {prompt}"
);
ctx.cat_panel_cursor = 4;
let filtering = format!("{:?}", FilterToItem.execute(&ctx));
assert!(filtering.contains("item: \"Food\""), "keys: {filtering}");
let filter = said(&FilterToItem.execute(&ctx));
assert!(
filter.contains(&format!("Filter: {TYPE} = {FOOD}")),
"{filter}"
);
let delete = format!("{:?}", DeleteCategoryAtCursor.execute(&ctx));
assert!(
delete.contains(&format!("Deleted item '{FOOD}' from '{TYPE}'")),
"{delete}"
);
assert!(
delete.contains("item: \"Food\""),
"the effect still names the key: {delete}"
);
}
#[test]
fn cycle_panel_focus_with_no_panels_open() {
let m = two_cat_model();
@@ -718,8 +771,12 @@ impl Cmd for CycleAxisAtCursor {
ViewTarget::Active,
cat_name.clone(),
))];
effects
.extend(cycled.map(|axis| effect::set_status(axis_change_status(&cat_name, axis))));
effects.extend(cycled.map(|axis| {
effect::set_status(axis_change_status(
ctx.model.category_display(&cat_name),
axis,
))
}));
effects
} else {
vec![effect::refuse(
@@ -738,7 +795,10 @@ impl Cmd for OpenItemAddAtCursor {
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if let Some(cat_name) = ctx.cat_at_cursor() {
vec![effect::change_mode(AppMode::item_add(cat_name))]
let shown = ctx.model.category_display(&cat_name).to_string();
vec![effect::change_mode(AppMode::item_add_shown(
cat_name, &shown,
))]
} else {
vec![effect::refuse(
"No category selected. Press n to add a category first.",
@@ -790,7 +850,10 @@ impl Cmd for FilterToItem {
category: cat_name.clone(),
item: item_name.clone(),
}),
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
effect::set_status(filter_status(
ctx.model.category_display(&cat_name),
ctx.model.item_display(&cat_name, &item_name),
)),
]
}
Some(CatTreeEntry::Category { .. }) => {
@@ -816,7 +879,7 @@ impl Cmd for DeleteCategoryAtCursor {
Some(CatTreeEntry::Category { name, .. }) => {
vec![
Box::new(effect::RemoveCategory(name.clone())),
effect::set_status(format!("Deleted category '{name}'")),
effect::set_status(deleted_category_status(ctx.model.category_display(&name))),
]
}
Some(CatTreeEntry::Item {
@@ -829,7 +892,10 @@ impl Cmd for DeleteCategoryAtCursor {
category: cat_name.clone(),
item: item_name.clone(),
}),
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
effect::set_status(deleted_item_status(
ctx.model.item_display(&cat_name, &item_name),
ctx.model.category_display(&cat_name),
)),
];
effects.extend(retreat_from_deleted_item(ctx));
effects
@@ -8,6 +8,28 @@ mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
#[test]
fn a_tile_status_names_the_display_not_the_key() {
let mut m = two_cat_model();
m.model
.category_mut("Type")
.unwrap()
.set_display("Kind of food and drink");
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.tile_cat_idx = ctx
.view
.tileable_cats(&ctx.model.category_names())
.iter()
.position(|c| *c == "Type")
.unwrap();
let effects = TileAxisOp { axis: None }.execute(&ctx);
let said = format!("{:?}", effects.last().unwrap());
assert!(said.contains("Kind of food and drink → "), "{said}");
assert!(!said.contains("Type →"), "the key was said: {said}");
}
#[test]
fn tile_axis_cycle_produces_cycle_effect() {
let m = two_cat_model();
@@ -161,7 +183,10 @@ impl Cmd for TileAxisOp {
}),
None => Box::new(effect::CycleAxis(ViewTarget::Active, name.to_string())),
};
let status = format!("{} → {}", name, new_axis.plain_label());
let status = improvise_protocol::viewmodel::axis_change_status(
ctx.model.category_display(name),
new_axis,
);
vec![axis_effect, effect::set_status(status)]
} else {
vec![]
+86 -9
View File
@@ -332,12 +332,9 @@ impl Grids<'_> {
pub fn unwritable(&self, view: &str, cell: &RecordCell) -> Option<String> {
let key = self.resolve_cell(view, cell)?;
let model = &self.app.model_state.workbook.model;
let derived = model.formula_for(&key).map(|f| {
format!(
"{} is computed by a formula — edit the formula, not the cell",
f.target
)
});
let derived = model
.formula_for(&key)
.map(|f| crate::command::cmd::core::derived_refusal(&f.target));
let refusal = self.app.non_item_refusal(&key).or(derived);
refusal.or_else(|| self.aggregate_refusal(view, cell.record, &key))
}
@@ -772,9 +769,9 @@ impl App {
/// (improvise-b114, improvise-nacd). See `context/code/command.md`.
fn non_item_refusal(&self, key: &CellKey) -> Option<String> {
let model = &self.model_state.workbook.model;
model
.first_non_item(key)
.map(|(cat, name)| improvise_protocol::non_item_refusal(cat, name))
model.first_non_item(key).map(|(cat, name)| {
improvise_protocol::non_item_refusal(model.category_display(cat), name)
})
}
/// Point **this client's** slice at `name`, if the document has such a view,
@@ -5620,6 +5617,71 @@ mod non_item_key_tests {
assert_eq!(app.view_state.status_msg, refusal("Mars"));
}
#[test]
fn a_refused_wire_write_names_the_category_it_shows() {
const REGION: &str = "Sales territory of the whole company";
let mut app = paged_app("East");
let stray = CellKey::new(vec![
("Month".into(), "Jan".into()),
("Region".into(), "Mars".into()),
("_Measure".into(), "Revenue".into()),
]);
let model = &mut app.model_state.workbook.model;
model.set_cell(stray.clone(), CellValue::Number(2.0));
model.category_mut("Region").unwrap().set_display(REGION);
model.add_formula(
crate::formula::parse_formula("Profit = Revenue - 1", "_Measure").unwrap(),
);
let refused = |key: &CellKey, measure: &str| {
let cell = RecordCell {
record: RecordHash::of(key),
measure: Some(measure.into()),
};
app.unwritable(app.view_name(), &cell)
};
let why = refused(&stray, "Revenue").expect("a non-item is refused");
assert!(why.contains(REGION), "{why}");
assert!(
why.starts_with("Mars "),
"the stray coordinate is a key: {why}"
);
let profit = CellKey::new(vec![
("Month".into(), "Jan".into()),
("Region".into(), "East".into()),
("_Measure".into(), "Profit".into()),
]);
let why = refused(&profit, "Profit").expect("a formula's result is refused");
assert!(why.starts_with("Profit is computed by a formula"), "{why}");
}
#[test]
fn a_non_item_refusal_names_the_categorys_display() {
let mut app = paged_app("Mars");
app.model_state
.workbook
.model
.category_mut("Region")
.unwrap()
.set_display("Sales territory of the whole company");
press(&mut app, "i");
assert_eq!(
app.view_state.status_msg,
improvise_protocol::non_item_refusal("Sales territory of the whole company", "Mars"),
);
app.view_state.yanked = Some(CellValue::Number(7.0));
press(&mut app, "p");
assert!(
app.view_state
.status_msg
.ends_with(&improvise_protocol::non_item_refusal(
"Sales territory of the whole company",
"Mars"
)),
"{}",
app.view_state.status_msg
);
}
/// improvise-yc9r: `p` on a non-item cell writes no orphan. Evidence file.
#[test]
fn p_on_a_non_item_cell_writes_nothing() {
@@ -5655,6 +5717,21 @@ mod non_item_key_tests {
assert!(status.ends_with(&refusal("Mars")), "{status}");
}
#[test]
fn a_commit_on_a_non_item_cell_names_the_categorys_display() {
const REGION: &str = "Sales territory of the whole company";
let mut app = paged_app("Mars");
let region = app.model_state.workbook.model.category_mut("Region");
region.unwrap().set_display(REGION);
press(&mut app, ":enter-mode editing\n");
press(&mut app, "5\n");
let said = &app.view_state.status_msg;
assert!(
said.ends_with(&improvise_protocol::non_item_refusal(REGION, "Mars")),
"{said}"
);
}
/// `uneditable` declines a non-item cell, so an advance re-opens no editor
/// on one (#265 meets #277). Evidence: `context/test-evidence/command.md`.
#[test]
+8 -1
View File
@@ -402,7 +402,14 @@ impl AppMode {
}
pub fn item_add(category: String) -> Self {
let prompt = format!("add item to {category}: ");
let shown = category.clone();
Self::item_add_shown(category, &shown)
}
/// The prompt names the category by what a person sees; `category` stays the
/// key the commit adds the item to (improvise-cq1q.13).
pub fn item_add_shown(category: String, shown: &str) -> Self {
let prompt = format!("add item to {shown}: ");
Self::ItemAdd {
category,
minibuf: MinibufferConfig {
+26 -2
View File
@@ -68,8 +68,8 @@ pub enum CellId {
NonItem { category: String, item: String },
}
/// Why a cell whose coordinate names no item takes no write. One wording for the
/// projection's decline and the host's refusal (improvise-gk51).
/// Why a cell naming no item takes no write (improvise-gk51). `category` is a
/// display; `item` is the stray key, which has none (`core.md` §21).
pub fn non_item_refusal(category: &str, item: &str) -> String {
format!("{item} is no item of {category}, so there is no one cell to write")
}
@@ -838,6 +838,19 @@ impl CategoryTreeCache {
pub fn category(&self, name: &str) -> Option<&CategoryRow> {
self.categories.iter().find(|c| c.name == name)
}
/// What a person sees for a category key; the key when the cache lacks it.
/// For a message only: never pass the result to a lookup (core.md §21).
pub fn category_display<'a>(&'a self, name: &'a str) -> &'a str {
self.category(name).map_or(name, |c| c.display.as_str())
}
/// What a person sees for an item of `cat`; the key when either is absent.
pub fn item_display<'a>(&'a self, cat: &str, item: &'a str) -> &'a str {
self.category(cat)
.and_then(|c| c.items.iter().find(|i| i.name == item))
.map_or(item, |i| i.display.as_str())
}
}
/// Three mutations, because the effects are three *shapes*. `Items` and
@@ -955,6 +968,17 @@ pub struct ViewListCache {
pub views: Vec<ViewSummary>,
}
impl ViewListCache {
/// What a person sees for a view key; the key when the list lacks it.
/// For a title or a message only, never a lookup (core.md §21).
pub fn display_of<'a>(&'a self, key: &'a str) -> &'a str {
self.views
.iter()
.find(|v| v.name == key)
.map_or(key, |v| v.display.as_str())
}
}
impl WidgetCache for ViewListCache {
type Update = Vec<ViewSummary>;
+187 -12
View File
@@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize};
use crate::client::{ClientState, Opened, OpenedCell, OpenedTarget};
use crate::frame::ClientFrame;
use crate::keymap::{Binding, Keymaps, ModeKey};
use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree};
use crate::viewmodel::{
CatTreeEntry, axis_change_status, deleted_category_status, deleted_item_status, filter_status,
flatten_cat_tree,
};
use crate::{
AppMode, CellId, CellValue, Command, CreateKind, FormulaId, FormulaSlot, GridExtent, HelpPage,
KeyCode, KeyEvent, ModelCommand, Panel, RECORD_COORDS_CANNOT_BE_EMPTY, RecordCell, ViewState,
@@ -579,8 +582,9 @@ fn composed(
category: cat.clone(),
},
)?];
let shown = state.categories.category_display(&cat);
commands
.extend(cycled.map(|axis| Command::SetStatus(axis_change_status(&cat, axis))));
.extend(cycled.map(|axis| Command::SetStatus(axis_change_status(shown, axis))));
commands
}
None => vec![Command::SetStatus(
@@ -592,7 +596,10 @@ fn composed(
"delete-formula-at-cursor" => delete_formula_at_cursor(state),
"open-item-add-at-cursor" => Some(match cat_at_cursor(state) {
Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))],
Some(cat) => {
let shown = state.categories.category_display(&cat).to_string();
vec![Command::ChangeMode(AppMode::item_add_shown(cat, &shown))]
}
None => vec![Command::SetStatus(
"No category selected. Press n to add a category first.".to_string(),
)],
@@ -961,7 +968,7 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
// them; declining rather than `None` keeps that a door, not a hole.
CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]),
CellId::NonItem { category, item } => Some(vec![Command::SetStatus(
crate::non_item_refusal(category, item),
crate::non_item_refusal(state.categories.category_display(category), item),
)]),
}
}
@@ -1400,7 +1407,10 @@ pub(crate) fn declined_at(state: &ClientState, row: usize, col: usize) -> Option
match projected_at(state, row, col)?.id.as_ref()? {
CellId::Aggregate(_) => Some(AGGREGATE_DECLINE.to_string()),
CellId::Derived { target } => Some(derived_decline(target)),
CellId::NonItem { category, item } => Some(crate::non_item_refusal(category, item)),
CellId::NonItem { category, item } => Some(crate::non_item_refusal(
state.categories.category_display(category),
item,
)),
CellId::Record(_) | CellId::Create(_) | CellId::Coordinate { .. } => None,
}
}
@@ -1613,7 +1623,7 @@ fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>>
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
display,
}) => Some(vec![
model(
state,
@@ -1631,7 +1641,10 @@ fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>>
item: item_name.clone(),
},
)?,
Command::SetStatus(format!("Filter: {cat_name} = {item_name}")),
Command::SetStatus(filter_status(
state.categories.category_display(&cat_name),
&display,
)),
]),
Some(CatTreeEntry::Category { .. }) => Some(toggle_cat_expand_at(state, index)),
None => Some(Vec::new()),
@@ -1642,14 +1655,14 @@ fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>>
/// row deletes the item, and an empty tree says so.
fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
match cat_tree_entry(state) {
Some(CatTreeEntry::Category { name, .. }) => Some(vec![
Some(CatTreeEntry::Category { name, display, .. }) => Some(vec![
model(state, ModelCommand::RemoveCategory(name.clone()))?,
Command::SetStatus(format!("Deleted category '{name}'")),
Command::SetStatus(deleted_category_status(&display)),
]),
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
display,
}) => {
let mut commands = vec![
model(
@@ -1659,7 +1672,10 @@ fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
item: item_name.clone(),
},
)?,
Command::SetStatus(format!("Deleted item '{item_name}' from '{cat_name}'")),
Command::SetStatus(deleted_item_status(
&display,
state.categories.category_display(&cat_name),
)),
];
commands.extend(retreat_from_deleted_item(state));
Some(commands)
@@ -1786,7 +1802,7 @@ fn tile_axis_at(state: &ClientState, index: usize, axis: Option<Axis>) -> Option
};
Some(vec![
model(state, cmd)?,
Command::SetStatus(axis_change_status(&tile.category, new_axis)),
Command::SetStatus(axis_change_status(&tile.display, new_axis)),
])
}
@@ -5014,3 +5030,162 @@ mod opened_editor_wire_tests {
);
}
}
#[cfg(test)]
mod display_tests {
use super::*;
use crate::{
AxisFrame, CategoryItem, CategoryRow, CategoryTreeUpdate, CellDisplay, GridExtent,
GridUpdate, KeyModifiers, Projection, Snapshot, TileEntry, ViewState,
};
const REGION: &str = "Sales territory of the whole company";
const NORTH: &str = "Northern reaches and the colder coasts";
fn ev(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn shown(name: &str, display: &str) -> CategoryItem {
CategoryItem {
name: name.into(),
display: display.into(),
}
}
/// Every key differs from, and is far shorter than, what a person sees.
fn displayed(cell: Option<CellId>) -> ClientState {
let mut state = ClientState::default();
let mut first = CellDisplay::number("1");
first.id = cell;
state.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::CategoryTree(CategoryTreeUpdate::Categories(vec![CategoryRow {
name: "Region".into(),
display: REGION.into(),
items: vec![shown("North", NORTH)],
axis: Axis::Row,
}])),
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: REGION.into(),
axis: Axis::Row,
kind: crate::CategoryKind::Regular,
}]),
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Total"]))),
Projection::Grid(GridUpdate::Cells(vec![((0, 0), first)])),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 1, cols: 1 })),
Projection::Basis(crate::basis::published(7, 3, Default::default())),
],
});
state.window = (1, 1);
state
}
fn last_status(commands: Vec<Command>) -> String {
match commands.last() {
Some(Command::SetStatus(said)) => said.clone(),
other => panic!("no status last in {commands:?}: {other:?}"),
}
}
fn local(state: &ClientState, code: KeyCode) -> Vec<Command> {
match resolve(state, ev(code)) {
KeyOutcome::Local(commands) => commands,
other => panic!("{code:?} did not resolve locally: {other:?}"),
}
}
#[test]
fn a_panel_status_names_what_a_person_sees_not_the_keys() {
let mut state = displayed(None);
state.view.mode = AppMode::CategoryPanel;
state.view.expanded_cats.insert("Region".into());
state.view.cat_panel_cursor = 0;
let cycled = last_status(local(&state, KeyCode::Char(' ')));
assert_eq!(cycled, format!("{REGION} → Col"));
let removal = delete_at_cat_cursor(&state).unwrap();
assert!(
removal.iter().any(|c| matches!(c, Command::Model {
cmd: ModelCommand::RemoveCategory(name), ..
} if name == "Region")),
"the removal names the key: {removal:?}"
);
let deleted = last_status(removal);
assert_eq!(deleted, format!("Deleted category '{REGION}'"));
state.view.cat_panel_cursor = 1;
let filtering = filter_to_item(&state).unwrap();
let named: Vec<String> = filtering
.iter()
.filter_map(|c| match c {
Command::Model {
cmd: ModelCommand::SetPageSelection { category, item, .. },
..
} => Some(format!("{category}/{item}")),
_ => None,
})
.collect();
assert_eq!(named, ["Region/North"], "the lookup names keys");
let filtered = last_status(filtering);
assert_eq!(filtered, format!("Filter: {REGION} = {NORTH}"));
let removal = delete_at_cat_cursor(&state).unwrap();
assert!(
removal.iter().any(|c| matches!(c, Command::Model {
cmd: ModelCommand::RemoveItem { category, item }, ..
} if category == "Region" && item == "North")),
"the removal names keys: {removal:?}"
);
let deleted = last_status(removal);
assert_eq!(deleted, format!("Deleted item '{NORTH}' from '{REGION}'"));
}
#[test]
fn a_tile_status_names_the_display() {
let mut state = displayed(None);
state.view.mode = AppMode::TileSelect;
let said = last_status(tile_axis(&state, None).unwrap());
assert_eq!(said, format!("{REGION} → Col"));
}
#[test]
fn the_item_prompt_names_the_display_and_the_mode_keeps_the_key() {
let mut state = displayed(None);
state.view.mode = AppMode::CategoryPanel;
state.view.cat_panel_cursor = 0;
let commands = local(&state, KeyCode::Char('a'));
let Some(Command::ChangeMode(AppMode::ItemAdd { category, minibuf })) = commands
.iter()
.find(|c| matches!(c, Command::ChangeMode(_)))
else {
panic!("no item-add mode in {commands:?}");
};
assert_eq!(category, "Region", "the commit adds to the KEY");
assert_eq!(minibuf.prompt, format!("add item to {REGION}: "));
}
#[test]
fn the_declines_an_advance_and_a_commit_reach_name_the_category_it_shows() {
let stray = displayed(Some(CellId::NonItem {
category: "Region".into(),
item: "Mars".into(),
}));
let want = crate::non_item_refusal(REGION, "Mars");
assert_eq!(declined_at(&stray, 0, 0), Some(want.clone()));
assert_eq!(refused_here(&stray, "commit-cell-edit"), Some(want.clone()));
let advanced = reopened_at(&stray, 0, 0, AppMode::Normal, false);
assert_eq!(advanced, vec![Command::SetStatus(want)]);
}
#[test]
fn a_refused_cell_names_the_category_it_shows() {
let stray = displayed(Some(CellId::NonItem {
category: "Region".into(),
item: "Mars".into(),
}));
let said = last_status(local(&stray, KeyCode::Char('i')));
assert!(said.contains(REGION) && said.starts_with("Mars "), "{said}");
}
}
+17 -2
View File
@@ -37,12 +37,27 @@ impl CatTreeEntry {
}
}
/// What a moved axis says on the status line. One home: the tile bar and the
/// category panel are two routes to one operation — improvise-25bf.
/// What a moved axis says on the status line: one home for the tile bar and the
/// panel (improvise-25bf). Takes displays; `core.md` §21.
pub fn axis_change_status(category: &str, axis: Axis) -> String {
format!("{category} → {}", axis.plain_label())
}
/// What `filter-to-item` says; both ends, so one wording (improvise-cq1q.13).
pub fn filter_status(category: &str, item: &str) -> String {
format!("Filter: {category} = {item}")
}
/// What deleting a category at the panel cursor says.
pub fn deleted_category_status(category: &str) -> String {
format!("Deleted category '{category}'")
}
/// What deleting an item at the panel cursor says.
pub fn deleted_item_status(item: &str, category: &str) -> String {
format!("Deleted item '{item}' from '{category}'")
}
/// Flatten the category panel: a header per category, then its items when
/// expanded. The result index is `ViewState::cat_panel_cursor`.
pub fn flatten_cat_tree(
+19 -1
View File
@@ -243,7 +243,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
status: state.view.status_msg.clone(),
status_stands: state.status_stands(),
view_name: state.view.active_view.clone(),
view_name: state.views.display_of(&state.view.active_view).to_string(),
doc_name: state.document.name.clone(),
doc_dirty: state.document.dirty,
doc_stored_as: state.document.stored_as.clone(),
@@ -883,6 +883,24 @@ mod tests {
assert!(views[0].active, "the active view is still found by its key");
}
#[test]
fn the_title_names_the_active_views_display_not_its_key() {
let mut client = init();
let state = client.state_mut();
state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Quarterly overview of regional sales".into(),
axes: vec![],
}])));
state.view.active_view = "Default".into();
assert_eq!(
client.model().view_name,
"Quarterly overview of regional sales"
);
client.state_mut().view.active_view = "Unlisted".into();
assert_eq!(client.model().view_name, "Unlisted", "no display: the key");
}
#[test]
fn the_grid_page_bar_and_item_rows_paint_displays() {
use improvise_protocol::{AxisFrame, CategoryItem, FrameGroup};
+25 -1
View File
@@ -409,7 +409,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
GridWidget::new(
&grid_cache,
app.cursor(),
&app.view_state.active_view,
app.view().display(),
&app.layout,
&app.view_state.mode,
&app.view_state.search_query,
@@ -1201,6 +1201,30 @@ mod content_tests {
.collect()
}
/// improvise-cq1q.13: evidence in `context/test-evidence/core.md`.
#[test]
fn the_grid_title_names_the_views_display_not_its_key() {
let mut wb = wide_grid();
wb.active_view_mut()
.set_display("Quarterly overview of regional sales");
let mut app = App::new(wb, None);
(app.term_width, app.term_height) = (100, 24);
let mut terminal = Terminal::new(TestBackend::new(100, 24)).expect("test backend");
terminal.draw(|f| draw(f, &app)).expect("draw");
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("View: Quarterly overview of regional sales"),
"the title drew something else"
);
assert!(!text.contains("View: Default"), "the key was drawn");
}
/// improvise-gtn7.1: the cursor went behind a side panel —
/// `context/test-evidence/tui.md`.
#[test]
+5 -4
View File
@@ -23,7 +23,8 @@ pub struct GridWidget<'a> {
/// This client's cursor in the active view (improvise-8uz). Passed in
/// rather than read off `View`, which no longer carries per-client state.
pub cursor: ViewCursor,
pub view_name: &'a str,
/// What the title shows: the view's display, never its key (improvise-cq1q.13).
pub view_display: &'a str,
pub layout: &'a GridLayout,
pub mode: &'a AppMode,
pub search_query: &'a str,
@@ -36,7 +37,7 @@ impl<'a> GridWidget<'a> {
pub fn new(
cache: &'a GridCache,
cursor: ViewCursor,
view_name: &'a str,
view_display: &'a str,
layout: &'a GridLayout,
mode: &'a AppMode,
search_query: &'a str,
@@ -46,7 +47,7 @@ impl<'a> GridWidget<'a> {
Self {
cache,
cursor,
view_name,
view_display,
layout,
mode,
search_query,
@@ -444,7 +445,7 @@ impl<'a> Widget for GridWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" View: {} ", self.view_name));
.title(format!(" View: {} ", self.view_display));
let inner = block.inner(area);
block.render(area, buf);