Files
improvise/crates/improvise-wasm-client/src/render.rs
T
Claude (agent-adr007) 3116d524fb 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
2026-09-25 12:12:59 -07:00

1293 lines
48 KiB
Rust

//! What the page paints: the client's state with every rendering decision
//! already made. The four silent ones are in `context/code/clients.md`.
use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry, resolve};
use serde::Serialize;
use crate::{Client, ClientState};
/// One frame's worth of everything the page draws.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderModel {
/// The revision this model was built at. A page that stores it can skip
/// a repaint without re-serializing.
pub revision: u64,
/// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String,
pub status: String,
/// Whether `status` belongs to the editor still open, so the page must not
/// expire it on a timer (improvise-1x9h).
pub status_stands: bool,
/// The document's active view, as this client knows it (gotcha 29).
pub view_name: String,
/// The title bar: the document's name, whether it has unsaved changes, and
/// what its host calls it — never a path (improvise-z754).
pub doc_name: String,
pub doc_dirty: bool,
pub doc_stored_as: Option<String>,
/// The column header, one entry per level, `""` where a label repeats.
/// Level-major because that is how it is drawn: one line per level.
pub col_levels: Vec<Vec<String>>,
/// The runs of columns that share a group, in column order. Empty when the
/// axis has no groups at all — a fact, not a gap.
pub col_groups: Vec<RenderColGroup>,
/// How many row-header levels every [`RenderRow::Data`] carries.
pub row_levels: usize,
pub rows: Vec<RenderRow>,
/// The column totals strip, **empty where this grid has none** — the same
/// fact `GridCache::totals` carries, never a sum taken here (gotcha 44).
pub totals: Vec<RenderCell>,
/// The page bar: one entry per category fixing which records are visible.
/// Page **and** Filter; `cycle` is what separates them (gotcha 27).
pub page_coords: Vec<RenderPageAxis>,
/// The category panel, flattened by the one flattening.
pub categories: Vec<RenderCatRow>,
/// Whether the panel is open, so the page does not decide.
pub category_panel_open: bool,
/// The view panel's rows, **`None` when it is closed** and possibly empty
/// when open: an absence is not an empty list (improvise-df2w).
pub views: Option<Vec<RenderViewRow>>,
/// The formula panel's rows, on the same terms as `views`.
pub formulas: Option<Vec<RenderFormulaRow>>,
/// The tile bar, drawn on every frame as the TUI draws it. Which tile is
/// `selected` is the mode's answer, not the page's.
pub tiles: Vec<RenderTile>,
/// The keys this mode offers, off `help::hint_line` — the same string the
/// TUI's footer draws, so the page spells no keys of its own.
pub hint: String,
/// The help overlay's rows, **empty unless it is open**. Shared content
/// (improvise-ktv1); the page does the painting and nothing else.
pub help: Vec<RenderHelpRow>,
pub help_page: usize,
pub help_titles: Vec<String>,
/// What the user has typed so far while a keyboard edit is open on the
/// selected cell. The decision is made here — `context/code/clients.md`.
pub editing: Option<String>,
/// The bottom-bar prompt while ANY text-entry mode is open, cell edits
/// included. `None` elsewhere, so the page asks nothing about the mode.
pub minibuffer: Option<RenderMinibuffer>,
}
/// The prompt, what has been typed into it, and the colour. WHICH buffer is
/// the mode's own answer (gotcha 11) — never a name this module spells.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RenderMinibuffer {
pub prompt: String,
pub value: String,
/// A colour, not a role: the TUI now draws a face per text-entry mode, and
/// improvise-nn4s.4 moves the page onto the same faces (ADR 004).
pub color: improvise_protocol::MinibufColor,
}
/// A grid row: either a group header spanning the row, or data. An enum so a
/// header cannot carry cells and a data row cannot carry a collapse state.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum RenderRow {
Group {
label: String,
collapsed: bool,
},
Data {
/// One label per row-header level, already blanked where it repeats
/// the row above — the page never decides suppression.
labels: Vec<String>,
cells: Vec<RenderCell>,
},
}
impl RenderRow {
/// The cells of a data row; the empty slice for a group header, which is
/// a reasonable question about a row the caller did not choose.
pub fn cells(&self) -> &[RenderCell] {
match self {
RenderRow::Data { cells, .. } => cells,
RenderRow::Group { .. } => &[],
}
}
/// A data row's header labels, one per level; **empty for a group header**,
/// which carries its own label. See `context/code/clients.md`.
pub fn labels(&self) -> &[String] {
match self {
RenderRow::Data { labels, .. } => labels,
RenderRow::Group { .. } => &[],
}
}
}
/// A run of columns under one group header. Why `label` is an `Option` and not
/// an empty string (Copilot, PR #23): `context/code/clients.md`.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderColGroup {
pub label: Option<String>,
pub collapsed: bool,
pub span: usize,
}
/// One cell, with every question already answered.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCell {
pub text: String,
/// Right-align. The server decided this; it is echoed, not re-derived.
pub numeric: bool,
pub error: bool,
/// May this client compose a write for it? `CellId::Record` alone.
pub editable: bool,
pub selected: bool,
}
impl RenderCell {
fn of(cell: &CellDisplay, selected: bool) -> Self {
Self {
text: cell.text.clone(),
numeric: cell.numeric,
error: cell.error,
editable: match cell.id {
Some(CellId::Record(_)) => true,
Some(
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate { .. }
| CellId::NonItem { .. },
)
| None => false,
},
selected,
}
}
}
/// One category fixing what the grid shows, and what it is fixed to. The page
/// spells neither the split nor the cycle — `context/code/clients.md`.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderPageAxis {
pub cat: String,
pub selected: String,
/// Where `selected` sits in what `[`/`]` cycle. **`None` for a Filter
/// axis**, which constrains without being navigable (gotcha 27).
pub cycle: Option<RenderPageCycle>,
}
/// A position in a navigable page axis, and what it may be moved to. `items`
/// is RELAYED, never derived here — `context/code/clients.md`.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderPageCycle {
pub at: usize,
pub count: usize,
/// Every item this axis can be fixed to, in the document's own order, so
/// a page may offer them. `count` is `items.len()` and stays for `[`/`]`.
pub items: Vec<String>,
}
/// One row of the category panel. `item_count` and `expanded` are a category
/// header's own facts; an item row carries neither, hence `Option`.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCatRow {
pub label: String,
/// True for a category header, false for an item under one.
pub is_category: bool,
/// The axis initial for a category header (`""` for `Axis::None`, which
/// is a real answer and not a truncation — gotcha 26).
pub axis: String,
pub item_count: Option<usize>,
pub expanded: Option<bool>,
/// Under `ViewState::cat_panel_cursor`, which indexes this very list.
pub selected: bool,
}
/// One row of the view panel. `active` is which view this client is on, off
/// `ViewState` and never off the cache (improvise-8uz).
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderViewRow {
pub name: String,
/// The axis line, e.g. `R:Region C:Product` — the shared derivation both
/// front ends call, never a second walk of `ViewSummary::axes`.
pub axes: String,
pub active: bool,
pub selected: bool,
}
/// One row of the formula panel: the formula's own text, already rendered by
/// the host, plus whether the panel cursor is on it.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderFormulaRow {
pub text: String,
pub selected: bool,
}
/// One tile: a category the view may move between axes, with `Axis`'s PLAIN
/// label — the three-column one is the TUI's (improvise-771e, clients.md).
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderTile {
pub category: String,
pub axis: &'static str,
pub selected: bool,
}
/// Build the page's frame from the caches and the view slice.
pub fn render_model(state: &ClientState) -> RenderModel {
let cursor = state.view.cursor();
let (sel_row, sel_col) = cursor.selected;
let entries = flatten_cat_tree(&state.categories, &state.view.expanded_cats);
let rows = &state.grid.row_frame;
let cols = &state.grid.col_frame;
// Built once for the whole frame: the per-position accessors scan,
// so asking them inside the row loop is quadratic.
let row_labels = rows.header_labels(0);
RenderModel {
revision: state.revision,
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(),
doc_name: state.document.name.clone(),
doc_dirty: state.document.dirty,
doc_stored_as: state.document.stored_as.clone(),
col_levels: col_levels(cols),
col_groups: col_groups(cols),
row_levels: rows.levels(),
rows: rows
.body_from(0)
.iter()
.scan(0usize, |data_idx, entry| {
// `GroupEnd` is a boundary, not a row: it draws nothing and
// consumes no data index (improvise-gd83).
let row = match entry {
FrameEntry::GroupEnd { .. } => return Some(None),
FrameEntry::Group(group) => RenderRow::Group {
label: group.name.clone(),
collapsed: group.collapsed,
},
FrameEntry::Item { .. } => {
let r = *data_idx;
*data_idx += 1;
RenderRow::Data {
labels: row_labels[r].clone(),
cells: (0..cols.len())
.map(|c| {
// The one conversion: viewport pos +
// offset is the absolute position.
let selected = r + cursor.row_offset == sel_row
&& c + cursor.col_offset == sel_col;
match state.grid.cell(r, c) {
Some(cell) => RenderCell::of(cell, selected),
None => RenderCell {
selected,
..RenderCell::default()
},
}
})
.collect(),
}
}
};
Some(Some(row))
})
.flatten()
.collect(),
totals: totals_strip(state),
page_coords: page_axes(state),
categories: entries
.iter()
.enumerate()
.map(|(i, entry)| RenderCatRow::of(entry, i == state.view.cat_panel_cursor))
.collect(),
category_panel_open: state.view.category_panel_open,
views: view_rows(state),
formulas: formula_rows(state),
tiles: tile_row(state),
hint: improvise_protocol::hint_line(&state.view.mode),
help: help_rows(&state.view),
help_page: state.view.help_page,
help_titles: help_titles(&state.view),
// The buffer Enter commits, never a second derivation of it: the fold
// seeds an absent one (improvise-xg1z, improvise-ktlk).
editing: state
.view
.mode
.is_editing()
.then(|| improvise_protocol::read_buffer(&state.view, "edit")),
minibuffer: state.view.mode.minibuffer().map(|mb| RenderMinibuffer {
prompt: mb.prompt.clone(),
value: improvise_protocol::read_buffer(&state.view, &mb.buffer_key),
color: mb.color,
}),
}
}
/// The column totals, relayed. **Never summed here**: the total runs over
/// every row, including the ones this viewport scrolled past (gotcha 44).
fn totals_strip(state: &ClientState) -> Vec<RenderCell> {
// `RenderCell::of` is the one conversion, and its answers are honest for a
// total: `id` is `None`, so nothing offers it as editable.
state
.grid
.totals
.iter()
.map(|cell| RenderCell::of(cell, false))
.collect()
}
/// The page bar. `PageAxis::cycle` is the one home of the Page/Filter split,
/// so this asks it rather than testing `items` itself (gotcha 27).
fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
state
.grid
.page_coords
.iter()
.map(|axis| RenderPageAxis {
cat: axis.cat.clone(),
selected: axis.selected.clone(),
cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
at,
count: items.len(),
items: items.to_vec(),
}),
})
.collect()
}
/// The view panel's rows, or `None` while it is closed. The axis line is
/// `viewmodel::view_axis_summary`, which the TUI's own panel calls too.
fn view_rows(state: &ClientState) -> Option<Vec<RenderViewRow>> {
state.view.view_panel_open.then(|| {
state
.views
.views
.iter()
.enumerate()
.map(|(i, summary)| RenderViewRow {
name: summary.display.clone(),
axes: improvise_protocol::viewmodel::view_axis_summary(summary),
active: summary.name == state.view.active_view,
selected: i == state.view.view_panel_cursor,
})
.collect()
})
}
/// The formula panel's rows, or `None` while it is closed.
fn formula_rows(state: &ClientState) -> Option<Vec<RenderFormulaRow>> {
state.view.formula_panel_open.then(|| {
state
.formulas
.formulas
.iter()
.enumerate()
.map(|(i, entry)| RenderFormulaRow {
text: entry.raw.clone(),
selected: i == state.view.formula_cursor,
})
.collect()
})
}
/// The tile bar, always. Only the *selection* is conditional, exactly as
/// `src/ui/tile_bar.rs` makes it conditional on `AppMode::TileSelect`.
fn tile_row(state: &ClientState) -> Vec<RenderTile> {
let selecting = state.view.mode == improvise_protocol::AppMode::TileSelect;
state
.tiles
.tiles
.iter()
.enumerate()
.map(|(i, tile)| RenderTile {
category: tile.display.clone(),
axis: tile.axis.plain_label(),
selected: selecting && i == state.view.tile_cat_idx,
})
.collect()
}
/// One help-overlay row, already resolved: the keys (empty for prose), the
/// description, and which of the shared line kinds it is.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderHelpRow {
pub keys: String,
pub description: String,
/// `blank`, `banner`, `heading`, `text`, `accent`, `keys` or `dim` — the
/// page styles it; it decides nothing.
pub kind: &'static str,
}
impl RenderHelpRow {
fn of(line: &improvise_protocol::HelpLine) -> Self {
use improvise_protocol::HelpLine as L;
let prose = |kind, text: &str| Self {
keys: text.to_string(),
description: String::new(),
kind,
};
match line {
L::Blank => prose("blank", ""),
L::Banner(t) => prose("banner", t),
L::Heading(t) => prose("heading", t),
L::Text(t) => prose("text", t),
L::Accent(t) => prose("accent", t),
L::Keys(row) => Self::row(
row.keys().join(" "),
row.description(),
!resolve::needs_the_host(row.command()),
false,
),
L::Literal {
keys,
description,
dim,
} => Self::row(
(*keys).to_string(),
description,
resolve::runs_the_command_line(),
*dim,
),
}
}
/// A keys row. One this client cannot run is drawn dim and says so, since a
/// dead key followed by live ones edits the grid (improvise-uln5, koh6).
fn row(keys: String, description: &str, runs: bool, dim: bool) -> Self {
Self {
keys,
description: match runs {
true => description.to_string(),
false => resolve::not_here(description),
},
kind: match dim || !runs {
true => "dim",
false => "keys",
},
}
}
}
impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry {
CatTreeEntry::Category {
display,
item_count,
expanded,
axis,
..
} => Self {
label: display.clone(),
is_category: true,
axis: axis.initial().to_string(),
item_count: Some(*item_count),
expanded: Some(*expanded),
selected,
},
CatTreeEntry::Item { item_name, .. } => Self {
label: item_name.clone(),
is_category: false,
axis: String::new(),
item_count: None,
expanded: None,
selected,
},
}
}
}
/// The tab bar's labels, on the same terms as the rows: building five pages
/// on every frame to fill a list the page would not draw is not free.
fn help_titles(view: &improvise_protocol::ViewState) -> Vec<String> {
match view.mode == improvise_protocol::AppMode::Help {
false => Vec::new(),
true => improvise_protocol::help::pages()
.into_iter()
.map(|page| page.title.to_string())
.collect(),
}
}
/// The overlay's rows, and **only while it is open**: an empty list is what
/// tells the page not to draw one. Shared content, per-front-end painting.
fn help_rows(view: &improvise_protocol::ViewState) -> Vec<RenderHelpRow> {
if view.mode != improvise_protocol::AppMode::Help {
return Vec::new();
}
let pages = improvise_protocol::help::pages();
let page = pages.get(view.help_page).unwrap_or(&pages[0]);
page.lines.iter().map(RenderHelpRow::of).collect()
}
/// The column header lines: one per level, blanked where a label repeats.
/// Exactly `AxisFrame::shows_level`; the suppression rule is not restated.
fn col_levels(cols: &AxisFrame) -> Vec<Vec<String>> {
// `header_labels` answers per column; the page draws per *line*, so this
// is a transpose and nothing more.
let per_column = cols.header_labels(0);
(0..cols.levels())
.map(|level| {
per_column
.iter()
.map(|levels| levels[level].clone())
.collect()
})
.collect()
}
/// The column groups as runs, coalescing equal answers from
/// `AxisFrame::group_of`. Nothing at all when no column is in a group.
fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
// The coalescing rule is `AxisFrame::group_runs`, which the TUI draws
// from too — one home, not two agreeing copies (improvise-8qlr).
let runs = cols.group_runs();
let any = runs.iter().any(|(group, _)| group.is_some());
match any {
false => Vec::new(),
true => runs
.into_iter()
.map(|(group, span)| RenderColGroup {
label: group.map(|g| g.name.clone()),
collapsed: group.is_some_and(|g| g.collapsed),
span,
})
.collect(),
}
}
impl Client {
pub fn model(&self) -> RenderModel {
render_model(self.state())
}
pub fn model_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.model())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init;
use improvise_core::view::Axis;
use improvise_protocol::{
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, PageAxis, Projection, RecordCell,
Snapshot, TileEntry, ViewState, ViewSummary,
};
fn surrogate() -> improvise_protocol::RecordHash {
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("hex")
}
fn aggregate() -> improvise_protocol::AggregateHash {
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex")
}
fn record_id() -> CellId {
CellId::Record(RecordCell {
record: surrogate(),
measure: Some("Revenue".into()),
})
}
/// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so
/// one fixture answers every editability question.
fn group(name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
level: 0,
depth: 0,
name: name.into(),
collapsed,
})
}
fn item(label: &str) -> FrameEntry {
FrameEntry::item(vec![label.into()])
}
fn help_page(page: usize) -> Vec<RenderHelpRow> {
let view = ViewState {
mode: improvise_protocol::AppMode::Help,
help_page: page,
..ViewState::default()
};
help_rows(&view)
}
/// improvise-uln5: a row whose key this client sends to a host that answers
/// nothing is drawn marked. Evidence: `context/test-evidence/clients.md`.
#[test]
fn a_help_row_the_browser_cannot_run_is_drawn_marked() {
let rows = help_page(1);
let row = |prefix: &str| {
let found = rows.iter().find(|r| r.description.starts_with(prefix));
found.unwrap_or_else(|| panic!("no Navigation row reads {prefix:?}"))
};
let search = row("Exit search mode");
assert_eq!(search.kind, "dim", "{search:?}");
assert!(
search.description.contains("not in the browser"),
"{search:?}"
);
let control = row("Jump to last row");
assert_eq!(
control.kind, "keys",
"a row the browser runs is drawn plainly"
);
assert_eq!(control.description, "Jump to last row");
let commands = help_page(4);
let ex: Vec<_> = commands
.iter()
.filter(|r| r.keys.trim_start().starts_with(':'))
.collect();
assert!(
!ex.is_empty(),
"precondition: the Commands page lists ex-commands"
);
for row in ex {
assert_eq!(row.kind, "dim", "{row:?}");
assert!(row.description.contains("not in the browser"), "{row:?}");
}
}
/// A run is opened by a HEADER, not by a name — improvise-h0iv, and both
/// ends now ask the one rule. See the evidence file.
#[test]
fn column_groups_coalesce_per_header_as_the_tui_does() {
let frame = AxisFrame {
first: 0,
cats: vec!["Month".into()],
entries: vec![
group("Q1", false),
item("Jan"),
group("Q1", true),
item("Feb"),
group("Q2", false),
item("Apr"),
],
};
assert_eq!(
col_groups(&frame)
.iter()
.map(|g| (g.label.as_deref(), g.span, g.collapsed))
.collect::<Vec<_>>(),
vec![
(Some("Q1"), 1, false),
(Some("Q1"), 1, true),
(Some("Q2"), 1, false)
],
"merging the two Q1 headers drew the SECOND one with the first's \
collapse indicator, so a collapsed group looked expanded"
);
}
fn client_with_grid(view: ViewState) -> Client {
let mut client = init();
client.bootstrap(Snapshot {
view,
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
"Revenue", "Total", "Profit",
]))),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), CellDisplay::number("1200").with_id(record_id())),
(
(0, 1),
CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())),
),
(
(0, 2),
CellDisplay::number("700").with_id(CellId::Derived {
target: "Profit".into(),
}),
),
((1, 0), CellDisplay::text("pending")),
(
(1, 2),
CellDisplay::text("North").with_id(CellId::Coordinate {
record: surrogate(),
category: "Region".into(),
}),
),
])),
],
});
client
}
/// ADR 002's permission, as the page sees it: four different facts, and
/// only the first is a cell a user may type into.
#[test]
fn only_a_record_cell_renders_as_editable() {
let model = client_with_grid(ViewState::default()).model();
assert!(
model.rows[0].cells()[0].editable,
"a stored cell is editable"
);
assert!(
!model.rows[0].cells()[1].editable,
"an aggregate is a drill target, and Drill is not on the wire yet"
);
assert!(
!model.rows[0].cells()[2].editable,
"a formula owns the cell, so a write to it would be shadowed on recompute"
);
assert!(
!model.rows[1].cells()[0].editable,
"id: None is not 'aggregate' — it is 'no projected cell', and not writable either"
);
assert!(
!model.rows[1].cells()[1].editable,
"and neither is a slot fit_to_labels padded"
);
assert!(
!model.rows[1].cells()[2].editable,
"renaming a coordinate moves the whole row, which is not a cell write"
);
}
/// The numeric/error flags are the server's answers, echoed — so the test
/// asserts the echo rather than a parse.
#[test]
fn the_servers_formatting_facts_are_echoed_not_re_derived() {
let model = client_with_grid(ViewState::default()).model();
assert!(model.rows[0].cells()[0].numeric);
assert!(!model.rows[1].cells()[0].numeric);
assert_eq!(model.rows[0].cells()[0].text, "1200");
assert_eq!(model.col_levels[0], vec!["Revenue", "Total", "Profit"]);
assert_eq!(model.rows[1].labels(), ["South"]);
}
/// The conversion that looks right until the first scroll: the grid is
/// viewport-indexed, the cursor is not. Offsets are nonzero and unequal.
#[test]
fn the_selection_is_placed_by_subtracting_the_scroll_offset() {
let mut view = ViewState::default();
view.cursor_mut().selected = (5, 1);
view.cursor_mut().row_offset = 4;
view.cursor_mut().col_offset = 1;
let model = client_with_grid(view).model();
assert!(
model.rows[1].cells()[0].selected,
"absolute (5,1) under offsets (4,1) is render position (1,0)"
);
let elsewhere: usize = model
.rows
.iter()
.flat_map(|r| r.cells())
.filter(|c| c.selected)
.count();
assert_eq!(elsewhere, 1, "exactly one cell is selected");
}
/// A cursor scrolled off the projected window selects nothing, rather
/// than wrapping onto whatever cell sits at that index.
#[test]
fn a_selection_outside_the_window_highlights_nothing() {
let mut view = ViewState::default();
view.cursor_mut().selected = (40, 0);
let model = client_with_grid(view).model();
assert!(
!model
.rows
.iter()
.flat_map(|r| r.cells())
.any(|c| c.selected),
"a cursor the server has not projected a row for highlights nothing"
);
}
/// The panel comes from `flatten_cat_tree` and honours this client's own
/// `expanded_cats`, which is why it is derived rather than sent.
#[test]
fn the_category_panel_is_the_shared_flattening() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![
CategoryRow {
name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
CategoryRow {
name: "Measure".into(),
display: "Measure".into(),
items: vec!["Revenue".into()],
axis: Axis::Column,
},
]),
)));
let collapsed = client.model().categories;
assert_eq!(collapsed.len(), 2, "collapsed categories show headers only");
assert_eq!(collapsed[0].axis, "R");
assert_eq!(collapsed[0].item_count, Some(2));
assert_eq!(collapsed[0].expanded, Some(false));
client
.state_mut()
.view
.expanded_cats
.insert("Region".into());
client.state_mut().view.cat_panel_cursor = 2;
let expanded = client.model().categories;
assert_eq!(expanded.len(), 4, "Region's two items appear under it");
assert!(!expanded[1].is_category);
assert_eq!(expanded[1].label, "North");
assert!(
expanded[2].selected,
"cat_panel_cursor indexes the flattened list, items included"
);
assert_eq!(
expanded[3].label, "Measure",
"and the next category follows its items"
);
}
#[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 {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::ViewList(vec![
ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
},
ViewSummary {
name: "Plan".into(),
display: "Plan".into(),
axes: vec![],
},
])));
client
.state_mut()
.fold(&Command::Project(Projection::Formulas(vec![
"Profit = Revenue - Cost".into(),
])));
client
.state_mut()
.fold(&Command::Project(Projection::TileBar(vec![
TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Row,
kind: improvise_protocol::CategoryKind::Regular,
},
TileEntry {
category: "Product".into(),
display: "Product".into(),
axis: Axis::None,
kind: improvise_protocol::CategoryKind::Regular,
},
])));
client
}
/// `None` is what says a panel is closed, so the page asks no question
/// about the mode.
#[test]
fn a_closed_panel_projects_no_rows_and_an_open_one_does() {
let mut client = client_with_panels();
let closed = client.model();
assert!(
closed.views.is_none(),
"the cache is full but the panel is shut"
);
assert!(closed.formulas.is_none());
client.state_mut().view.view_panel_open = true;
client.state_mut().view.formula_panel_open = true;
client.state_mut().view.view_panel_cursor = 1;
client.state_mut().view.active_view = "Default".into();
let open = client.model();
let (views, formulas) = (open.views.unwrap(), open.formulas.unwrap());
assert_eq!(views.len(), 2);
assert_eq!(
views[0].axes, "R:Region",
"the axis line is the shared derivation, and _Dim is not part of it"
);
assert!(views[0].active, "active is ViewState's, not the cache's");
assert!(views[1].selected, "and selected is the panel cursor's");
assert_eq!(formulas[0].text, "Profit = Revenue - Cost");
}
/// Open with nothing in it is not closed: the page must be able to tell
/// them apart from the model alone (improvise-df2w).
#[test]
fn an_open_empty_panel_is_not_a_closed_one() {
let mut client = init();
let closed = client.model();
client.state_mut().view.formula_panel_open = true;
let formulas_open = client.model();
assert_ne!(formulas_open, closed, "an open, empty formula panel");
client.state_mut().view.formula_panel_open = false;
client.state_mut().view.view_panel_open = true;
let views_open = client.model();
assert_ne!(views_open, closed, "an open, empty view panel");
}
/// The tile bar is drawn always; only the *selection* waits for the mode,
/// exactly as `src/ui/tile_bar.rs` makes it conditional.
#[test]
fn the_tile_bar_is_always_present_and_only_its_cursor_is_modal() {
let mut client = client_with_panels();
let normal = client.model();
assert_eq!(normal.tiles.len(), 2);
assert_eq!(
normal.tiles[1].axis, "None",
"the browser has no column to fit, so the word is the answer"
);
assert!(
!normal.tiles.iter().any(|t| t.selected),
"out of tile select nothing is highlighted"
);
client.state_mut().view.mode = improvise_protocol::AppMode::TileSelect;
client.state_mut().view.tile_cat_idx = 1;
let selecting = client.model();
assert!(selecting.tiles[1].selected);
assert_eq!(selecting.tiles.iter().filter(|t| t.selected).count(), 1);
}
/// The mode's label is `AppMode`'s own, so a page never spells one.
#[test]
fn the_mode_label_comes_off_the_mode() {
let mut client = init();
client.state_mut().fold(&Command::SetStatus("saved".into()));
let model = client.model();
assert_eq!(
model.mode,
improvise_protocol::mode_label(&improvise_protocol::AppMode::Normal)
);
assert_eq!(model.status, "saved");
assert!(
model.hint.contains("i:edit"),
"the page draws the mode's own hint rather than spelling keys: {}",
model.hint
);
assert_eq!(model.revision, client.revision());
}
/// A keyboard edit has to be *visible*, and that is decided here rather
/// than in the page. See `context/test-evidence/clients.md`.
#[test]
fn a_keyboard_edit_in_progress_is_part_of_the_render_model() {
let mut client = client_with_grid(ViewState::default());
assert_eq!(
client.model().editing,
None,
"nothing is being edited in Normal"
);
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.state_mut().fold(&Command::SetBuffer {
name: "edit".into(),
value: "12".into(),
});
assert_eq!(client.model().editing.as_deref(), Some("12"));
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::Normal));
assert_eq!(
client.model().editing,
None,
"leaving the mode closes the editor even with the buffer still set"
);
}
/// improvise-xg1z. An overlay drawn over a cell it does not hold is a
/// keystroke away from clearing it. See `context/test-evidence/clients.md`.
#[test]
fn an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank() {
let mut client = client_with_grid(ViewState::default());
// The mode alone, with no `SetBuffer` in front of it: the pair this
// client's own resolver emits, arriving incomplete.
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
assert_eq!(
client.state().view.buffers.get("edit").map(String::as_str),
Some("1200"),
"the fold seeds the missing buffer, so the overlay and a commit \
read one thing (improvise-ktlk)"
);
assert_eq!(
client.model().rows[0].cells()[0].text,
"1200",
"and the cell under the cursor really holds something to lose"
);
assert_eq!(
client.model().editing.as_deref(),
Some("1200"),
"a blank overlay over a real value is one Enter away from clearing it"
);
}
/// The other half: an edit buffer the user emptied must stay empty.
/// See `context/test-evidence/clients.md`.
#[test]
fn an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell() {
let mut client = client_with_grid(ViewState::default());
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.state_mut().fold(&Command::SetBuffer {
name: "edit".into(),
value: String::new(),
});
assert_eq!(
client.model().editing.as_deref(),
Some(""),
"deleting every character is a state the user typed their way \
into; refilling it would fight the backspace key"
);
}
/// improvise-fdt6. A room the browser could enter, typing into a buffer
/// nothing painted. See `context/test-evidence/clients.md`.
#[test]
fn every_minibuffer_mode_paints_what_is_being_typed() {
for mode in [
improvise_protocol::AppMode::formula_edit(),
improvise_protocol::AppMode::category_add(),
improvise_protocol::AppMode::item_add("Region".into()),
improvise_protocol::AppMode::editing(),
] {
let config = mode.minibuffer().expect("a text-entry mode").clone();
let mut client = client_with_grid(ViewState::default());
assert_eq!(
client.model().minibuffer,
None,
"Normal has no minibuffer to paint"
);
client.state_mut().fold(&Command::ChangeMode(mode.clone()));
client.state_mut().fold(&Command::SetBuffer {
name: config.buffer_key.clone(),
value: "Profit = Revenue - Cost".into(),
});
let painted = client
.model()
.minibuffer
.unwrap_or_else(|| panic!("{:?} must paint its minibuffer", config.buffer_key));
assert_eq!(painted.prompt, config.prompt);
assert_eq!(
painted.value, "Profit = Revenue - Cost",
"the page must draw what the user typed, not an empty line"
);
}
}
/// The whole model crosses as JSON, so the page needs no bindings.
#[test]
fn the_render_model_crosses_as_json() {
let json = client_with_grid(ViewState::default())
.model_json()
.expect("serializes");
let value: serde_json::Value = serde_json::from_str(&json).expect("is JSON");
assert_eq!(value["rows"][0]["cells"][0]["editable"], true);
assert_eq!(value["rows"][0]["cells"][1]["editable"], false);
assert_eq!(value["col_levels"][0][0], "Revenue");
assert_eq!(
value["rows"][0]["kind"], "data",
"a row says which of the two it is; the page does not guess"
);
assert_eq!(value["rows"][0]["labels"][0], "North");
}
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
PageAxis {
cat: cat.into(),
selected: selected.into(),
items: items.map(|i| i.into_iter().map(String::from).collect()),
}
}
/// improvise-p7lh: the page bar reached the client's cache and stopped
/// there, so a grid filtered to Q1 said nothing at all. Evidence file.
#[test]
fn the_page_bar_names_every_fixed_category_and_what_it_is_fixed_to() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
vec![page_axis("Quarter", "Q2", Some(vec!["Q1", "Q2", "Q3"]))],
))));
let bar = client.model().page_coords;
assert_eq!(bar.len(), 1, "one fixed category, one entry");
assert_eq!(bar[0].cat, "Quarter");
assert_eq!(
bar[0].selected, "Q2",
"WHICH item the grid is filtered to is the whole point of the bar"
);
}
/// Gotcha 27: Page is navigable and Filter is not, and `PageAxis::cycle`
/// is the one home of that split — `items.is_some()` is not it.
#[test]
fn only_a_navigable_page_axis_offers_a_cycle() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
vec![
page_axis("Quarter", "Q3", Some(vec!["Q1", "Q2", "Q3"])),
page_axis("Region", "North", None),
page_axis("Empty", "x", Some(vec![])),
],
))));
let bar = client.model().page_coords;
assert_eq!(
bar[0].cycle,
Some(RenderPageCycle {
at: 2,
count: 3,
items: vec!["Q1".into(), "Q2".into(), "Q3".into()],
}),
"a Page axis says where `[`/`]` currently sit"
);
assert_eq!(
bar[1].cycle, None,
"a Filter axis constrains without being navigable: nothing to cycle"
);
assert_eq!(
bar[2].cycle, None,
"and an EMPTY item list is no cycle either — `items.is_some()` says it is"
);
}
/// improvise-rw6l: the names were projected and the browser dropped them,
/// so the bar could say WHERE the cycle sits and not WHAT it may move to.
#[test]
fn a_navigable_page_axis_relays_the_items_it_may_be_fixed_to() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
vec![
page_axis("Quarter", "Q3", Some(vec!["Q1", "Q2", "Q3"])),
page_axis("Region", "North", None),
],
))));
let bar = client.model().page_coords;
let cycle = bar[0].cycle.as_ref().expect("a Page axis cycles");
assert_eq!(
cycle.items,
vec!["Q1", "Q2", "Q3"],
"the document's own order, relayed — a menu offers these and the page derives none of them"
);
assert_eq!(cycle.at, 2, "and which of them it is fixed to now");
assert!(
bar[1].cycle.is_none(),
"a Filter axis offers NO items: there is nothing a menu could pick (gotcha 27)"
);
}
/// improvise-771e: `short_label` is three columns so the TUI's tile bar
/// aligns; a browser has no column to fit and read "Pag" as a bug.
#[test]
fn a_page_tile_reads_as_the_whole_word() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::TileBar(vec![
TileEntry {
category: "Quarter".into(),
display: "Quarter".into(),
axis: Axis::Page,
kind: improvise_protocol::CategoryKind::Regular,
},
TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Column,
kind: improvise_protocol::CategoryKind::Regular,
},
])));
let tiles = client.model().tiles;
assert_eq!(
tiles[0].axis, "Page",
"the ONE label the two vocabularies disagree on among the visible axes"
);
assert_eq!(
tiles[1].axis, "Col",
"and the ones they agree on are unchanged, so this is a choice of vocabulary and not a rewrite"
);
}
/// The totals strip is relayed, never summed here: the total runs over
/// rows this viewport scrolled past (gotcha 44). improvise-p7lh.
#[test]
fn the_totals_strip_is_relayed_and_an_empty_one_stays_empty() {
let bare = client_with_grid(ViewState::default()).model();
assert!(
bare.totals.is_empty(),
"an empty strip means this grid has NO totals, not a row of blanks"
);
let mut client = client_with_grid(ViewState::default());
client
.state_mut()
.fold(&Command::Project(Projection::Grid(GridUpdate::Totals(
vec![
CellDisplay::number("9,001"),
CellDisplay::number("2"),
CellDisplay::number("3"),
],
))));
let totals = client.model().totals;
assert_eq!(totals.len(), 3, "one total per projected column");
assert_eq!(
totals[0].text, "9,001",
"the host's formatted sum, not a number this client added up"
);
assert!(
totals[0].numeric,
"a total right-aligns like the cells above it"
);
assert!(
!totals[0].editable && !totals[0].selected,
"a total is not a cell anyone may type into or select"
);
}
}