fix(web): the tile bar, the view panel and the formula panel are drawn
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.
Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.
So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.
The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).
Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.
Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -800,3 +800,56 @@ always open passes any check that only ever opens it.
|
|||||||
It runs against the **assembled** site like every other test here, so it
|
It runs against the **assembled** site like every other test here, so it
|
||||||
covers `render.rs`, the serialized model, `web/app.js` and the CSS together.
|
covers `render.rs`, the serialized model, `web/app.js` and the CSS together.
|
||||||
|
|
||||||
|
|
||||||
|
#### harness/tests/widgets.spec.js — the file's own scope
|
||||||
|
|
||||||
|
A user reported three things as broken: "I can't expand categories, and the
|
||||||
|
Tile + View UIs don't work." None of the three was a regression. Driving the
|
||||||
|
assembled site under Chromium separated them, and the separation is the point:
|
||||||
|
two of the three had *every* layer but the last one, and the layer they were
|
||||||
|
missing was not the same in each case.
|
||||||
|
|
||||||
|
**Category expand already worked, by keyboard.** `Shift-C`, `j`, `Enter` folds
|
||||||
|
`Command::ToggleCatExpand` client-side and the items appear. What does not work
|
||||||
|
is the *mouse*: `categoryList` builds plain `<li>`s with no listener, so a
|
||||||
|
click on the triangle does nothing at all. That half is improvise-7ybz, and it
|
||||||
|
is a product decision about the whole page's pointer model rather than a defect
|
||||||
|
in an implemented feature — `#grid` offers a click only on an editable cell, so
|
||||||
|
there is no click-to-select either.
|
||||||
|
|
||||||
|
**The tile bar and the view panel had the cache, the keymap, the resolver and
|
||||||
|
the host round trip, and no DOM.** Before the fix, `render_state()` in the
|
||||||
|
browser carried five `TileBarCache` entries and both `ViewListCache` views;
|
||||||
|
`Shift-T` entered `TileSelect` and `ArrowRight` moved `tile_cat_idx`; `r`/`c`/`p`
|
||||||
|
reached the host and really did move a category between axes. `RenderModel`
|
||||||
|
simply had no field for either, and `web/index.html` had no element. So the
|
||||||
|
keys worked and the user was aiming them at nothing — which reads exactly like
|
||||||
|
"the Tile UI doesn't work". The formula panel was the third of `Panel::ALL` in
|
||||||
|
the same state and is fixed by the same mechanism.
|
||||||
|
|
||||||
|
**The fault injection was the unfixed bundle itself.** Run against the tree at
|
||||||
|
`a3bef6e`, the category test passes and the other four fail — `#tiles`,
|
||||||
|
`#views` and `#formulas` resolve to zero elements. That is the evidence the
|
||||||
|
four tests are discriminating, and it is why the category test stays: it is the
|
||||||
|
control that says the harness really is driving the keyboard.
|
||||||
|
|
||||||
|
Two assertions in the first draft were wrong, and both corrections are worth
|
||||||
|
keeping. `Axis::short_label()` is **three columns**, so a `Page` tile reads
|
||||||
|
`Quarter Pag`, not `Quarter Page` — a test asserting the long spelling would
|
||||||
|
have forced the page to invent a label instead of echoing `Axis`'s own. And
|
||||||
|
switching views leaves the panel **open**: `switch-view-at-cursor` returns to
|
||||||
|
Normal and never closes the panel, exactly as `src/draw.rs` keeps drawing it
|
||||||
|
off `view_panel_open` alone. Asserting it hidden would have pinned the browser
|
||||||
|
to behaviour the TUI does not have.
|
||||||
|
|
||||||
|
The gating rule itself — an empty list is what says a panel is closed, the same
|
||||||
|
rule `help` follows — is asserted in Rust as well, by
|
||||||
|
`a_closed_panel_projects_no_rows_and_an_open_one_does` and
|
||||||
|
`the_tile_bar_is_always_present_and_only_its_cursor_is_modal`, because a browser
|
||||||
|
test cannot distinguish "closed" from "the cache is empty" and those two tests
|
||||||
|
fill the caches first.
|
||||||
|
|
||||||
|
What these tests do **not** cover: the pointer (improvise-7ybz), and the
|
||||||
|
inconsistency that `#views`/`#formulas` honour their open flags while
|
||||||
|
`#categories` is drawn unconditionally because `app.js` has never read
|
||||||
|
`category_panel_open` (improvise-wsaw).
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub use render::{
|
|||||||
WizardStepKind, header_level_widths,
|
WizardStepKind, header_level_widths,
|
||||||
};
|
};
|
||||||
pub use resolve::{KeyOutcome, resolve, typed_cell_command};
|
pub use resolve::{KeyOutcome, resolve, typed_cell_command};
|
||||||
pub use viewmodel::{CatTreeEntry, flatten_cat_tree};
|
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
|
||||||
// `improvise-core`'s, because the index that resolves one lives in
|
// `improvise-core`'s, because the index that resolves one lives in
|
||||||
// `DataStore`. Re-exported so a client depends on the protocol alone.
|
// `DataStore`. Re-exported so a client depends on the protocol alone.
|
||||||
pub use improvise_core::model::record::{AggregateHash, RecordHash};
|
pub use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use improvise_core::view::Axis;
|
use improvise_core::view::Axis;
|
||||||
|
|
||||||
use crate::render::CategoryTreeCache;
|
use crate::render::{CategoryTreeCache, ViewSummary};
|
||||||
|
|
||||||
/// A flattened entry in the category panel tree. Carries the axis outright,
|
/// A flattened entry in the category panel tree. Carries the axis outright,
|
||||||
/// so the entry list *is* what the panel draws.
|
/// so the entry list *is* what the panel draws.
|
||||||
@@ -59,6 +59,24 @@ pub fn flatten_cat_tree(
|
|||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A view's axes as one line, e.g. `R:Region C:Product P:Time`. Virtual
|
||||||
|
/// categories are left out: they are not what tells two views apart.
|
||||||
|
pub fn view_axis_summary(summary: &ViewSummary) -> String {
|
||||||
|
[Axis::Row, Axis::Column, Axis::Page]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|axis| {
|
||||||
|
let cats: Vec<&str> = summary
|
||||||
|
.axes
|
||||||
|
.iter()
|
||||||
|
.filter(|(cat, a)| *a == axis && !cat.starts_with('_'))
|
||||||
|
.map(|(cat, _)| cat.as_str())
|
||||||
|
.collect();
|
||||||
|
(!cats.is_empty()).then(|| format!("{}:{}", axis.initial(), cats.join(",")))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ pub struct RenderModel {
|
|||||||
pub categories: Vec<RenderCatRow>,
|
pub categories: Vec<RenderCatRow>,
|
||||||
/// Whether the panel is open, so the page does not decide.
|
/// Whether the panel is open, so the page does not decide.
|
||||||
pub category_panel_open: bool,
|
pub category_panel_open: bool,
|
||||||
|
/// The view panel's rows, **empty unless it is open** — the same rule the
|
||||||
|
/// help overlay follows, so the page asks no question about the mode.
|
||||||
|
pub views: Vec<RenderViewRow>,
|
||||||
|
/// The formula panel's rows, empty unless it is open.
|
||||||
|
pub formulas: 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
|
/// 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.
|
/// TUI's footer draws, so the page spells no keys of its own.
|
||||||
pub hint: String,
|
pub hint: String,
|
||||||
@@ -134,6 +142,35 @@ pub struct RenderCatRow {
|
|||||||
pub selected: bool,
|
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 own
|
||||||
|
/// short label (`"·"` for None is an answer, not a truncation — gotcha 26).
|
||||||
|
#[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.
|
/// Build the page's frame from the caches and the view slice.
|
||||||
pub fn render_model(state: &ClientState) -> RenderModel {
|
pub fn render_model(state: &ClientState) -> RenderModel {
|
||||||
let cursor = state.view.cursor();
|
let cursor = state.view.cursor();
|
||||||
@@ -194,6 +231,9 @@ pub fn render_model(state: &ClientState) -> RenderModel {
|
|||||||
.map(|(i, entry)| RenderCatRow::of(entry, i == state.view.cat_panel_cursor))
|
.map(|(i, entry)| RenderCatRow::of(entry, i == state.view.cat_panel_cursor))
|
||||||
.collect(),
|
.collect(),
|
||||||
category_panel_open: state.view.category_panel_open,
|
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),
|
hint: improvise_protocol::hint_line(&state.view.mode),
|
||||||
help: help_rows(&state.view),
|
help: help_rows(&state.view),
|
||||||
help_page: state.view.help_page,
|
help_page: state.view.help_page,
|
||||||
@@ -206,6 +246,60 @@ pub fn render_model(state: &ClientState) -> RenderModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The view panel's rows while it is open. The axis line is
|
||||||
|
/// `viewmodel::view_axis_summary`, which the TUI's own panel calls too.
|
||||||
|
fn view_rows(state: &ClientState) -> Vec<RenderViewRow> {
|
||||||
|
if !state.view.view_panel_open {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
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 while it is open.
|
||||||
|
fn formula_rows(state: &ClientState) -> Vec<RenderFormulaRow> {
|
||||||
|
if !state.view.formula_panel_open {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
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.short_label(),
|
||||||
|
selected: selecting && i == state.view.tile_cat_idx,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// One help-overlay row, already resolved: the keys (empty for prose), the
|
/// One help-overlay row, already resolved: the keys (empty for prose), the
|
||||||
/// description, and which of the shared line kinds it is.
|
/// description, and which of the shared line kinds it is.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||||
@@ -362,7 +456,7 @@ mod tests {
|
|||||||
use improvise_core::view::Axis;
|
use improvise_core::view::Axis;
|
||||||
use improvise_protocol::{
|
use improvise_protocol::{
|
||||||
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, Projection, RecordCell, Snapshot,
|
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, Projection, RecordCell, Snapshot,
|
||||||
ViewState,
|
TileEntry, ViewState, ViewSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn surrogate() -> improvise_protocol::RecordHash {
|
fn surrogate() -> improvise_protocol::RecordHash {
|
||||||
@@ -563,6 +657,84 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty list is what says a panel is closed — the same rule the help
|
||||||
|
/// overlay follows, 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_empty(), "the cache is full but the panel is shut");
|
||||||
|
assert!(closed.formulas.is_empty());
|
||||||
|
|
||||||
|
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();
|
||||||
|
assert_eq!(open.views.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
open.views[0].axes, "R:Region",
|
||||||
|
"the axis line is the shared derivation, and _Dim is not part of it"
|
||||||
|
);
|
||||||
|
assert!(open.views[0].active, "active is ViewState's, not the cache's");
|
||||||
|
assert!(open.views[1].selected, "and selected is the panel cursor's");
|
||||||
|
assert_eq!(open.formulas[0].text, "Profit = Revenue - Cost");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 is an answer, not a truncation");
|
||||||
|
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.
|
/// The mode's label is `AppMode`'s own, so a page never spells one.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_mode_label_comes_off_the_mode() {
|
fn the_mode_label_comes_off_the_mode() {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// The three widgets a user reported as "not working": the category panel's
|
||||||
|
// expand, the tile bar and the view list. History: context/test-evidence/clients.md.
|
||||||
|
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/// Open the demo document and put the keyboard where the page listens.
|
||||||
|
async function openApp(page) {
|
||||||
|
await page.goto("/app/index.html");
|
||||||
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||||
|
await page.locator("#grid").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("the category panel", () => {
|
||||||
|
// Expanding is resolved client-side and has always worked by KEYBOARD; the
|
||||||
|
// panel is nevertheless the one of the three that has DOM, so this pins it.
|
||||||
|
test("Enter on a category header reveals its items", async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
const rows = page.locator("#categories li");
|
||||||
|
|
||||||
|
await page.keyboard.press("Shift+C");
|
||||||
|
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||||
|
// web/demo.improv: _Index, _Dim, _Measure, Product, Quarter. Product is
|
||||||
|
// the first with items a user would recognise.
|
||||||
|
for (let i = 0; i < 3; i++) await page.keyboard.press("j");
|
||||||
|
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▸ Product \(4\)/);
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▾ Product \(4\)/);
|
||||||
|
await expect(page.locator("#categories li.item")).toHaveText([
|
||||||
|
"Espresso",
|
||||||
|
"Latte",
|
||||||
|
"Cold Brew",
|
||||||
|
"Pastry",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.locator("#categories li.item")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("the tile bar", () => {
|
||||||
|
// Nothing was DRAWN: TileBarCache was folded, `T` entered the mode and the
|
||||||
|
// axis keys reached the host, with no tile bar on screen to aim them at.
|
||||||
|
test("the tiles are drawn, and tile-select moves a visible cursor", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openApp(page);
|
||||||
|
const tiles = page.locator("#tiles .tile");
|
||||||
|
await expect(tiles).toHaveCount(5);
|
||||||
|
// Axis labels are `Axis::short_label`: three columns, so Page is "Pag",
|
||||||
|
// and None is "·" — an answer rather than a truncation (gotcha 26).
|
||||||
|
await expect(tiles.filter({ hasText: "Product" })).toHaveText("Product Row");
|
||||||
|
await expect(tiles.filter({ hasText: "Quarter" })).toHaveText("Quarter Pag");
|
||||||
|
await expect(tiles.filter({ hasText: "_Index" })).toHaveText("_Index ·");
|
||||||
|
|
||||||
|
// Out of tile select nothing is highlighted, as the TUI's widget decides.
|
||||||
|
await expect(page.locator("#tiles .tile.selected")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.keyboard.press("Shift+T");
|
||||||
|
await expect(page.locator("#mode")).toHaveText("TILES");
|
||||||
|
await expect(tiles.nth(0)).toHaveClass(/selected/);
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(tiles.nth(1)).toHaveClass(/selected/);
|
||||||
|
await expect(page.locator("#tiles .tile.selected")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The axis change was reaching the host all along; what it could not do was
|
||||||
|
// show the result. Setting Product to Column must redraw the tile AND the grid.
|
||||||
|
test("setting a tile's axis is visible in the bar it was aimed at", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openApp(page);
|
||||||
|
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||||
|
await expect(product).toHaveText("Product Row");
|
||||||
|
|
||||||
|
await page.keyboard.press("Shift+T");
|
||||||
|
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(product).toHaveClass(/selected/);
|
||||||
|
await page.keyboard.press("c");
|
||||||
|
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("the view panel", () => {
|
||||||
|
// Nothing was DRAWN: ViewListCache held both views and `V` entered ViewPanel
|
||||||
|
// mode, so Enter switched views off a cursor the user could not see.
|
||||||
|
test("V lists the views, marks the active one, and Enter switches", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openApp(page);
|
||||||
|
await expect(page.locator("#views")).toBeHidden();
|
||||||
|
await expect(page.locator("#view-name")).toHaveText("Plan");
|
||||||
|
|
||||||
|
await page.keyboard.press("Shift+V");
|
||||||
|
await expect(page.locator("#mode")).toHaveText("VIEWS");
|
||||||
|
await expect(page.locator("#views")).toBeVisible();
|
||||||
|
// web/demo.improv carries two views; the axis line is the shared
|
||||||
|
// `view_axis_summary`, so its absence would be a second derivation.
|
||||||
|
await expect(page.locator("#views li")).toHaveCount(2);
|
||||||
|
await expect(page.locator("#views li").nth(0)).toContainText("Default");
|
||||||
|
await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText(
|
||||||
|
"R:Product C:Quarter",
|
||||||
|
);
|
||||||
|
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||||
|
await expect(page.locator("#views li.selected")).toContainText("Default");
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.locator("#view-name")).toHaveText("Default", { timeout: 10_000 });
|
||||||
|
// Switching returns to Normal but leaves the panel OPEN, exactly as
|
||||||
|
// `src/draw.rs` keeps drawing it off `view_panel_open` alone. The active
|
||||||
|
// marker is what must move, and it is the fact the list exists to carry.
|
||||||
|
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||||
|
await expect(page.locator("#views")).toBeVisible();
|
||||||
|
await expect(page.locator("#views li.active")).toContainText("Default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("the formula panel", () => {
|
||||||
|
// The third of `Panel::ALL`, equally undrawn and fixed by the same mechanism.
|
||||||
|
test("F lists the document's formulas", async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
await expect(page.locator("#formulas")).toBeHidden();
|
||||||
|
|
||||||
|
await page.keyboard.press("Shift+F");
|
||||||
|
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||||
|
await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]);
|
||||||
|
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-22
@@ -4,10 +4,9 @@ use ratatui::{
|
|||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::protocol::{ViewListCache, ViewSummary};
|
use crate::protocol::{ViewListCache, view_axis_summary};
|
||||||
use crate::ui::app::Panel as PanelKind;
|
use crate::ui::app::Panel as PanelKind;
|
||||||
use crate::ui::panel::PanelContent;
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
|
||||||
|
|
||||||
pub struct ViewContent<'a> {
|
pub struct ViewContent<'a> {
|
||||||
views: &'a ViewListCache,
|
views: &'a ViewListCache,
|
||||||
@@ -21,23 +20,6 @@ impl<'a> ViewContent<'a> {
|
|||||||
Self { views, active_view }
|
Self { views, active_view }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
|
|
||||||
fn axis_summary(summary: &ViewSummary) -> String {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
for axis in [Axis::Row, Axis::Column, Axis::Page] {
|
|
||||||
// Filter out virtual categories
|
|
||||||
let cats: Vec<&str> = summary
|
|
||||||
.axes
|
|
||||||
.iter()
|
|
||||||
.filter(|(cat, a)| *a == axis && !cat.starts_with('_'))
|
|
||||||
.map(|(cat, _)| cat.as_str())
|
|
||||||
.collect();
|
|
||||||
if !cats.is_empty() {
|
|
||||||
parts.push(format!("{}:{}", axis.initial(), cats.join(",")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.join(" ")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PanelContent for ViewContent<'_> {
|
impl PanelContent for ViewContent<'_> {
|
||||||
@@ -84,7 +66,7 @@ impl PanelContent for ViewContent<'_> {
|
|||||||
buf.set_string(inner.x, y, &name_text, style);
|
buf.set_string(inner.x, y, &name_text, style);
|
||||||
|
|
||||||
// Axis summary after the name, in dim text
|
// Axis summary after the name, in dim text
|
||||||
let summary_text = Self::axis_summary(summary);
|
let summary_text = view_axis_summary(summary);
|
||||||
if !summary_text.is_empty() {
|
if !summary_text.is_empty() {
|
||||||
let summary_x = inner.x + name_text.len() as u16 + 1;
|
let summary_x = inner.x + name_text.len() as u16 + 1;
|
||||||
if summary_x < inner.x + inner.width {
|
if summary_x < inner.x + inner.width {
|
||||||
@@ -103,6 +85,7 @@ impl PanelContent for ViewContent<'_> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ui::app::App;
|
use crate::ui::app::App;
|
||||||
|
use crate::view::Axis;
|
||||||
use crate::workbook::Workbook;
|
use crate::workbook::Workbook;
|
||||||
use improvise_command::ui::project;
|
use improvise_command::ui::project;
|
||||||
|
|
||||||
@@ -151,7 +134,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
assert_eq!(was, ViewContent::axis_summary(summary), "{}", summary.name);
|
assert_eq!(was, view_axis_summary(summary), "{}", summary.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +149,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|v| v.name == app.view_state.active_view)
|
.find(|v| v.name == app.view_state.active_view)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ViewContent::axis_summary(active), "R:Region C:Product");
|
assert_eq!(view_axis_summary(active), "R:Region C:Product");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -29,6 +29,9 @@ const COL_PX = 110;
|
|||||||
const el = {
|
const el = {
|
||||||
grid: document.getElementById("grid"),
|
grid: document.getElementById("grid"),
|
||||||
categories: document.getElementById("categories"),
|
categories: document.getElementById("categories"),
|
||||||
|
formulas: document.getElementById("formulas"),
|
||||||
|
views: document.getElementById("views"),
|
||||||
|
tiles: document.getElementById("tiles"),
|
||||||
mode: document.getElementById("mode"),
|
mode: document.getElementById("mode"),
|
||||||
hint: document.getElementById("hint"),
|
hint: document.getElementById("hint"),
|
||||||
help: document.getElementById("help"),
|
help: document.getElementById("help"),
|
||||||
@@ -187,6 +190,9 @@ function repaint() {
|
|||||||
el.viewName.textContent = model.view_name;
|
el.viewName.textContent = model.view_name;
|
||||||
el.grid.replaceChildren(gridTable(model));
|
el.grid.replaceChildren(gridTable(model));
|
||||||
el.categories.replaceChildren(categoryList(model));
|
el.categories.replaceChildren(categoryList(model));
|
||||||
|
paintTiles(model);
|
||||||
|
paintPanel(el.views, model.views, viewRow);
|
||||||
|
paintPanel(el.formulas, model.formulas, formulaRow);
|
||||||
|
|
||||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||||
@@ -375,6 +381,55 @@ function edit(td, row, col, seed) {
|
|||||||
input.addEventListener("blur", () => close(false));
|
input.addEventListener("blur", () => close(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A side panel. An empty row list is what says it is closed — the page asks
|
||||||
|
* no question about the mode, exactly as `paintHelp` does not. */
|
||||||
|
function paintPanel(host, rows, build) {
|
||||||
|
host.hidden = rows.length === 0;
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
for (const row of rows) list.appendChild(build(row));
|
||||||
|
host.replaceChildren(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewRow(row) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.classList.toggle("selected", row.selected);
|
||||||
|
li.classList.toggle("active", row.active);
|
||||||
|
// The active marker is the TUI's, and the axis line is the shared
|
||||||
|
// derivation's — the page spells neither the summary nor the axis letters.
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "name";
|
||||||
|
name.textContent = (row.active ? "▶ " : " ") + row.name;
|
||||||
|
li.append(name);
|
||||||
|
if (row.axes) {
|
||||||
|
const axes = document.createElement("span");
|
||||||
|
axes.className = "axes";
|
||||||
|
axes.textContent = row.axes;
|
||||||
|
li.append(axes);
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formulaRow(row) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.classList.toggle("selected", row.selected);
|
||||||
|
li.textContent = row.text;
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile bar. `selected` is only ever true in tile-select mode, which is
|
||||||
|
* decided in Rust; an empty tile list draws nothing at all. */
|
||||||
|
function paintTiles(model) {
|
||||||
|
el.tiles.hidden = model.tiles.length === 0;
|
||||||
|
const tiles = model.tiles.map((tile) => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "tile";
|
||||||
|
span.classList.toggle("selected", tile.selected);
|
||||||
|
span.textContent = `${tile.category} ${tile.axis}`;
|
||||||
|
return span;
|
||||||
|
});
|
||||||
|
el.tiles.replaceChildren(...tiles);
|
||||||
|
}
|
||||||
|
|
||||||
function categoryList(model) {
|
function categoryList(model) {
|
||||||
const list = document.createElement("ul");
|
const list = document.createElement("ul");
|
||||||
for (const row of model.categories) {
|
for (const row of model.categories) {
|
||||||
|
|||||||
@@ -74,8 +74,12 @@
|
|||||||
the server is for — not running one is what everything above is for.
|
the server is for — not running one is what everything above is for.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div id="tiles" aria-label="Tiles"></div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<aside id="categories" aria-label="Categories"></aside>
|
<aside id="categories" aria-label="Categories"></aside>
|
||||||
|
<aside id="formulas" aria-label="Formulas" hidden></aside>
|
||||||
|
<aside id="views" aria-label="Views" hidden></aside>
|
||||||
<div id="grid" tabindex="0" aria-label="Grid"></div>
|
<div id="grid" tabindex="0" aria-label="Grid"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -72,18 +72,59 @@ main {
|
|||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
#categories ul {
|
#formulas,
|
||||||
|
#views {
|
||||||
|
width: 18rem;
|
||||||
|
flex: none;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#categories ul,
|
||||||
|
#formulas ul,
|
||||||
|
#views ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#views .axes {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#views .active .name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tile bar. One line above the grid, as the TUI draws it. */
|
||||||
|
#tiles {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tiles .tile {
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
#categories .item {
|
#categories .item {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
#categories .selected,
|
#categories .selected,
|
||||||
|
#formulas .selected,
|
||||||
|
#views .selected,
|
||||||
|
#tiles .selected,
|
||||||
td.selected {
|
td.selected {
|
||||||
background: var(--selected);
|
background: var(--selected);
|
||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user