8747074e52
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
1177 lines
44 KiB
Rust
1177 lines
44 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};
|
|
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,
|
|
/// 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,
|
|
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
|
|
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
|
|
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,
|
|
)
|
|
| 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(),
|
|
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.name.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, text)| RenderFormulaRow {
|
|
text: text.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.category.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 {
|
|
keys: row.keys().join(" "),
|
|
description: row.description().to_string(),
|
|
kind: "keys",
|
|
},
|
|
L::Literal {
|
|
keys,
|
|
description,
|
|
dim,
|
|
} => Self {
|
|
keys: (*keys).to_string(),
|
|
description: description.to_string(),
|
|
kind: match dim {
|
|
true => "dim",
|
|
false => "keys",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RenderCatRow {
|
|
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
|
|
match entry {
|
|
CatTreeEntry::Category {
|
|
name,
|
|
item_count,
|
|
expanded,
|
|
axis,
|
|
} => Self {
|
|
label: name.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 {
|
|
name: name.into(),
|
|
collapsed,
|
|
})
|
|
}
|
|
|
|
fn item(label: &str) -> FrameEntry {
|
|
FrameEntry::Item(vec![label.into()])
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
])),
|
|
],
|
|
});
|
|
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(),
|
|
items: vec!["North".into(), "South".into()],
|
|
axis: Axis::Row,
|
|
},
|
|
CategoryRow {
|
|
name: "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"
|
|
);
|
|
}
|
|
|
|
fn client_with_panels() -> Client {
|
|
let mut client = init();
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::Project(Projection::ViewList(vec![
|
|
ViewSummary {
|
|
name: "Default".into(),
|
|
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
|
|
},
|
|
ViewSummary {
|
|
name: "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(),
|
|
axis: Axis::Row,
|
|
},
|
|
TileEntry {
|
|
category: "Product".into(),
|
|
axis: Axis::None,
|
|
},
|
|
])));
|
|
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(),
|
|
axis: Axis::Page,
|
|
},
|
|
TileEntry {
|
|
category: "Region".into(),
|
|
axis: Axis::Column,
|
|
},
|
|
])));
|
|
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"
|
|
);
|
|
}
|
|
}
|