feat(web): a click is resolved in Rust, and the sidebar obeys its flag

The user's rulings on the two beads PR #55 filed.

improvise-7ybz: the browser was keyboard-only by omission. Nothing outside an
editable grid cell had a click listener, so clicking the collapse triangle on a
category did nothing — which is what "I can't expand categories" meant, since
the keyboard path worked all along.

`improvise_protocol::resolve::click` now answers all three widgets under one
rule: **go there, then do what Enter does there.** A category row focuses the
panel, moves its cursor and activates (expand on a header, filter on an item);
a tile selects and cycles its axis; a cell moves the selection, with the page's
inline editor as the pointer's `i`. One rule rather than three inventions, and
it is the keyboard's own definition rather than a second one — `cat_tree_entry`,
`toggle_cat_expand`, `filter_to_item` and `tile_axis` each gained an `_at` twin
and the cursor-reading form is now a one-line call of it.

It lives in the protocol crate, not the wasm client, for the `flatten_cat_tree`
reason: a third front end must call this, not derive it. `Client::click_category`
/ `click_tile` / `click_cell` are thin wasm-bindgen wrappers that fold and
serialize exactly as `key` does, so `app.js` still composes no `Command`.

The subtlety worth knowing: a click's three intents are resolved in one pure
call, so the activation must be asked about the row *clicked*, never about
`cat_panel_cursor` — the `SetPanelCursor` ahead of it has not been folded yet.
Injecting that exact fault turns `ToggleCatExpand("Product")` into
`ToggleCatExpand("Region")`, which is the silent half and is now pinned.

improvise-wsaw: `RenderModel::category_panel_open` existed from the start and
`app.js` never read it, so the sidebar was drawn unconditionally and `C` moved
only the mode label. It now hides on load and toggles with `C`, matching
`src/draw.rs:322` and the two panels PR #55 added. A first-time visitor sees
one fewer thing; the user chose front-end consistency, and the grid is still
populated.

Tests: harness/tests/pointer.spec.js, six assertions, **all six proved by
running them against the bundle built immediately before this commit** — where
the sidebar is wrongly visible and every click lands on an element with no
listener. Plus four Rust tests on the resolver, one of them proved by the
injection above.

Note this branch is based on gh/main but MERGES fix/web-ui-correctness (PR #55):
click_tile needs a tile bar to click, and that is #55's. Rebase or re-merge once
#55 lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 12:35:47 -07:00
parent ff39121853
commit 959f249c4b
6 changed files with 483 additions and 13 deletions
+74
View File
@@ -922,3 +922,77 @@ from a `tbody` row header to a `thead` column header. `.first()` is needed there
because Product then shares the column axis with `_Measure`, so the label
appears once per header level — which is itself a small confirmation that
gotcha 44's multi-level frame reaches the browser.
### a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row
improvise-7ybz's whole difficulty in one assertion, and the reason the click
resolvers take an **index** rather than reading the cursor.
A click means three things at once — focus the panel, move its cursor to the row
under the pointer, activate that row — and the third is resolved in the *same
pure call* as the first two, before any of them has been folded. So
`filter_to_item_at(state, index)` must be asked about `index`; asking
`filter_to_item(state)` reads `view.cat_panel_cursor`, which is still wherever
the keyboard last left it. That is why `cat_tree_entry`, `toggle_cat_expand`,
`filter_to_item` and `tile_axis` each gained an `_at` twin and the cursor-reading
form became a one-line call of it: one definition, two entry points.
**Injected fault**, run: `commands.extend(filter_to_item_at(state, index)…)` →
`filter_to_item(state)`. The test fails with
`ToggleCatExpand("Region")` against `ToggleCatExpand("Product")` — the panel
cursor lands on the row clicked while the *expansion* happens to the row that
was selected before, which is the silent half. The fixture therefore has two
categories and clicks the **second**: with one category, or with a click on the
row already under the cursor, the bug is invisible.
The sibling `a_click_in_an_already_focused_panel_only_moves_and_activates` pins
the other half: `focus` emits only what is not already true, so a click inside
the focused panel is not a mode change on every click. Without it, "always emit
`SetPanelOpen` + `ChangeMode`" passes the first test.
### a_cell_click_is_absolute_even_though_the_page_paints_a_window
The pointer's version of `the_selection_is_placed_by_subtracting_the_scroll_offset`,
and it fails the same way: `GridCache` is viewport-indexed, `ViewState::selected`
is absolute, so a click at painted `(2,1)` under offsets `(12,3)` is `(14,4)`.
Offsets are **nonzero and unequal** for the same reason as its sibling — equal
offsets cannot separate "reconciles" from "swaps the two". The second half
asserts a click past the grid's extent resolves to nothing, which is what stops
a click on the padding below the last row selecting a cell that does not exist.
### a_click_past_the_last_row_resolves_to_nothing
`doctrine(check_the_checker)` for the two above: the empty-answer path. Without
it, a `click` that returned `Vec::new()` unconditionally would pass every
"resolves to nothing" expectation while failing silently everywhere else — so
this one is only meaningful *because* its neighbours assert non-empty answers.
#### harness/tests/pointer.spec.js — the file's own scope
The user's ruling on improvise-7ybz ("build it — one entry point per widget")
and improvise-wsaw ("honour the flag"), executed in a real browser.
Six tests, and **all six fail against the bundle built immediately before them**
— the tree that already had the three widgets drawn (PR #55) but no click
handlers and no `category_panel_open` read. That is the injection: the sidebar
is visible when it should be hidden, and every `click()` resolves to a `<li>`
that has no listener, so nothing moves. The category test is the sharpest,
because the element it clicks *exists and looks interactive* in the unfixed
tree — which is exactly what the user hit.
Two properties here that the Rust tests structurally cannot reach. **The
pointer and the keyboard are one cursor**: `the keyboard continues from where
the pointer left the cursor` clicks a cell, closes the editor, presses
ArrowLeft, and requires the move to start from the clicked cell — if the click
had only opened an editor (the pre-fix behaviour) the arrow would move from the
origin instead. And **the tile click is asserted against the grid**, not only
against its own label: `Espresso` must appear as a `thead` header, because a
tile bar reporting an axis the document did not take is the failure shape this
repo keeps finding.
One fixture note worth keeping: `openPanelThenLeave` opens the panel with
`Shift-C` and then presses `Escape`, which returns to Normal **without closing
the panel** (`PanelKeymap`'s Esc is `enter-mode normal`, nothing more). That is
the only state in which the "focus" third of the click is observable — from a
freshly opened panel the mode is already `CategoryPanel` and the assertion
would pass without the focus commands existing at all.
+1 -1
View File
@@ -29,7 +29,7 @@ pub use render::{
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
WizardProposal, WizardStepKind, header_level_widths,
};
pub use resolve::{KeyOutcome, resolve, typed_cell_command};
pub use resolve::{Click, KeyOutcome, click, resolve, typed_cell_command};
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
// `improvise-core`'s, because the index that resolves one lives in
// `DataStore`. Re-exported so a client depends on the protocol alone.
+206 -6
View File
@@ -66,6 +66,87 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
}
}
/// What a pointer did, resolved here for the same reason a keypress is: a
/// front end paints and wires, it does not decide. `context/code/clients.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Click {
/// A row of the category panel, indexing the flattened tree.
CategoryRow(usize),
/// A tile, indexing `TileBarCache::tiles`.
Tile(usize),
/// A grid cell, in **viewport** coordinates as the page painted it.
Cell { row: usize, col: usize },
}
/// One rule for all three widgets: **go there, then do what `Enter` does
/// there.** Why that rather than three answers: `context/code/clients.md`.
pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
match target {
Click::CategoryRow(index) => click_category_row(state, index),
Click::Tile(index) => click_tile(state, index),
Click::Cell { row, col } => click_cell(state, row, col),
}
}
/// Focus the panel, move its cursor, then activate — the three things the
/// keyboard needs three keystrokes for, and `filter-to-item` is the activation.
fn click_category_row(state: &ClientState, index: usize) -> Vec<Command> {
// A click past the last row is a click on the panel's empty space, which
// is not a row and must not move the cursor onto one.
if cat_tree_entry_at(state, index).is_none() {
return Vec::new();
}
let mut commands = focus(state, Panel::Category);
commands.push(Command::SetPanelCursor {
panel: Panel::Category,
cursor: index,
});
// Against `index`, never the cursor: the `SetPanelCursor` above is not
// folded yet, so the cursor still reads the old row. Evidence file.
commands.extend(filter_to_item_at(state, index).unwrap_or_default());
commands
}
/// Select the tile and cycle its axis — `TileSelect` plus what `Enter` does
/// in it. A tile the bar does not have is not a click.
fn click_tile(state: &ClientState, index: usize) -> Vec<Command> {
if state.tiles.tiles.get(index).is_none() {
return Vec::new();
}
let mut commands = vec![Command::SetTileCatIdx(index)];
if state.view.mode != AppMode::TileSelect {
commands.push(Command::ChangeMode(AppMode::TileSelect));
}
commands.extend(tile_axis_at(state, index, None).unwrap_or_default());
commands
}
/// Move the selection to a clicked cell. Activation is the page's inline
/// editor, which is the pointer's `i` — `context/code/clients.md`.
fn click_cell(state: &ClientState, row: usize, col: usize) -> Vec<Command> {
let cursor = state.view.cursor();
let GridExtent { rows, cols } = state.grid.extent;
// The page paints a window; `SetSelected` is absolute (gotcha 42).
let (absolute_row, absolute_col) = (row + cursor.row_offset, col + cursor.col_offset);
if absolute_row >= rows || absolute_col >= cols {
return Vec::new();
}
scrolled_to(state, absolute_row, absolute_col)
}
/// Open a panel and focus it, emitting only what is not already true — so a
/// click inside the focused panel is not a mode change every time.
fn focus(state: &ClientState, panel: Panel) -> Vec<Command> {
let mut commands = Vec::new();
if !panel_open(&state.view, panel) {
commands.push(Command::SetPanelOpen { panel, open: true });
}
if state.view.mode.panel() != Some(panel) {
commands.push(Command::ChangeMode(panel.mode()));
}
commands
}
/// Every step of a `Binding::Sequence`, or none of them.
fn sequence(
state: &ClientState,
@@ -649,9 +730,15 @@ fn cycle_panel_focus(view: &ViewState) -> Vec<Command> {
/// `CmdContext::cat_tree_entry`, over the cache rather than the `Model`. The
/// flattening is the one shared derivation (improvise-edp).
fn cat_tree_entry(state: &ClientState) -> Option<CatTreeEntry> {
cat_tree_entry_at(state, state.view.cat_panel_cursor)
}
/// The flattened entry at an arbitrary index. The keyboard asks for the
/// cursor's; a click asks for the row it landed on (improvise-7ybz).
fn cat_tree_entry_at(state: &ClientState, index: usize) -> Option<CatTreeEntry> {
flatten_cat_tree(&state.categories, &state.view.expanded_cats)
.into_iter()
.nth(state.view.cat_panel_cursor)
.nth(index)
}
fn cat_at_cursor(state: &ClientState) -> Option<String> {
@@ -661,8 +748,12 @@ fn cat_at_cursor(state: &ClientState) -> Option<String> {
/// `ToggleCatExpand::execute`. Reached by `filter-to-item` on a header row,
/// which is the only binding that leads here.
fn toggle_cat_expand(state: &ClientState) -> Vec<Command> {
match cat_at_cursor(state) {
Some(cat) => vec![Command::ToggleCatExpand(cat)],
toggle_cat_expand_at(state, state.view.cat_panel_cursor)
}
fn toggle_cat_expand_at(state: &ClientState, index: usize) -> Vec<Command> {
match cat_tree_entry_at(state, index) {
Some(entry) => vec![Command::ToggleCatExpand(entry.cat_name().to_string())],
None => Vec::new(),
}
}
@@ -670,7 +761,11 @@ fn toggle_cat_expand(state: &ClientState) -> Vec<Command> {
/// `FilterToItem::execute`: an item row filters, a header row expands, and
/// nothing at all is the third arm rather than a status.
fn filter_to_item(state: &ClientState) -> Option<Vec<Command>> {
match cat_tree_entry(state) {
filter_to_item_at(state, state.view.cat_panel_cursor)
}
fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
match cat_tree_entry_at(state, index) {
Some(CatTreeEntry::Item {
cat_name,
item_name,
@@ -691,7 +786,7 @@ fn filter_to_item(state: &ClientState) -> Option<Vec<Command>> {
)?,
Command::SetStatus(format!("Filter: {cat_name} = {item_name}")),
]),
Some(CatTreeEntry::Category { .. }) => Some(toggle_cat_expand(state)),
Some(CatTreeEntry::Category { .. }) => Some(toggle_cat_expand_at(state, index)),
None => Some(Vec::new()),
}
}
@@ -752,7 +847,11 @@ fn delete_view_at_cursor(state: &ClientState) -> Option<Vec<Command>> {
/// `TileAxisOp::execute` for both of its names: `None` cycles, `Some` sets.
/// The status is the *new* axis either way, which is why it is computed here.
fn tile_axis(state: &ClientState, axis: Option<Axis>) -> Option<Vec<Command>> {
let Some(tile) = state.tiles.tiles.get(state.view.tile_cat_idx) else {
tile_axis_at(state, state.view.tile_cat_idx, axis)
}
fn tile_axis_at(state: &ClientState, index: usize, axis: Option<Axis>) -> Option<Vec<Command>> {
let Some(tile) = state.tiles.tiles.get(index) else {
return Some(Vec::new());
};
let new_axis = axis.unwrap_or_else(|| tile.axis.next());
@@ -900,6 +999,107 @@ mod tests {
}
}
// ── the pointer (improvise-7ybz) ────────────────────────────────
fn with_categories() -> ClientState {
let mut state = ClientState::default();
state.fold(&Command::Project(Projection::CategoryTree(
crate::CategoryTreeUpdate::Categories(vec![
crate::CategoryRow {
name: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
crate::CategoryRow {
name: "Product".into(),
items: vec!["Shirts".into()],
axis: Axis::Column,
},
]),
)));
state
}
/// A click is three intents in one, and the third must be resolved
/// against the row clicked — see `context/test-evidence/clients.md`.
#[test]
fn a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row() {
let state = with_categories();
assert_eq!(
state.view.cat_panel_cursor, 0,
"the cursor starts elsewhere"
);
let commands = click(&state, Click::CategoryRow(1));
assert_eq!(
commands,
vec![
Command::SetPanelOpen {
panel: Panel::Category,
open: true
},
Command::ChangeMode(AppMode::CategoryPanel),
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 1
},
// `Product`, the row clicked — NOT `Region`, which is where
// the cursor still is when this is resolved.
Command::ToggleCatExpand("Product".into()),
]
);
}
/// The focus half emits only what is not already true, so a click inside
/// the focused panel is not a mode change every time.
#[test]
fn a_click_in_an_already_focused_panel_only_moves_and_activates() {
let mut state = with_categories();
state.view.category_panel_open = true;
state.view.mode = AppMode::CategoryPanel;
assert_eq!(
click(&state, Click::CategoryRow(0)),
vec![
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 0
},
Command::ToggleCatExpand("Region".into()),
]
);
}
/// A click on the panel's empty space is not a click on a row, and must
/// not drag the cursor onto one.
#[test]
fn a_click_past_the_last_row_resolves_to_nothing() {
assert!(click(&with_categories(), Click::CategoryRow(9)).is_empty());
assert!(click(&ClientState::default(), Click::Tile(0)).is_empty());
}
/// The pointer and the keyboard are one cursor: a click resolves to the
/// same `SetSelected` a movement key would, offsets reconciled.
#[test]
fn a_cell_click_is_absolute_even_though_the_page_paints_a_window() {
let mut state = ClientState::default();
state.fold(&Command::Project(Projection::Grid(GridUpdate::Extent(
crate::GridExtent { rows: 40, cols: 9 },
))));
state.window = (10, 4);
state.view.cursor_mut().row_offset = 12;
state.view.cursor_mut().col_offset = 3;
assert_eq!(
click(&state, Click::Cell { row: 2, col: 1 }),
vec![Command::SetSelected { row: 14, col: 4 }],
"viewport (2,1) under offsets (12,3) is absolute (14,4)"
);
assert!(
click(&state, Click::Cell { row: 39, col: 0 }).is_empty(),
"a click below the grid names no cell"
);
}
// ── text entry ──────────────────────────────────────────────────
#[test]
+38 -1
View File
@@ -5,7 +5,7 @@ pub mod keys;
pub mod render;
pub mod socket;
pub use improvise_protocol::{ClientState, KeyOutcome, resolve};
pub use improvise_protocol::{Click, ClientState, KeyOutcome, resolve};
pub use keys::BrowserKey;
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
@@ -110,6 +110,26 @@ impl Client {
pub fn typed_cell(&self, row: usize, col: usize, text: &str) -> Option<String> {
serde_json::to_string(&self.typed_cell_frame(row, col, text)?).ok()
}
/// The user clicked row `index` of the category panel. Resolved whole —
/// focus, cursor and activation — and folded here (improvise-7ybz).
#[wasm_bindgen]
pub fn click_category(&mut self, index: usize) -> Option<String> {
self.clicked(Click::CategoryRow(index))
}
/// The user clicked tile `index`: select it and cycle its axis.
#[wasm_bindgen]
pub fn click_tile(&mut self, index: usize) -> Option<String> {
self.clicked(Click::Tile(index))
}
/// The user clicked a grid cell at a **viewport** position: move the
/// selection there. Opening an editor is the page's own affair.
#[wasm_bindgen]
pub fn click_cell(&mut self, row: usize, col: usize) -> Option<String> {
self.clicked(Click::Cell { row, col })
}
}
/// The same surface in plain Rust, for tests and for any host that is not JS.
@@ -142,6 +162,23 @@ impl Client {
self.state.bootstrap(snapshot);
}
/// Resolve a click, fold it, and hand back the frame to send. The twin of
/// [`Client::key`]'s `Local` arm: a click is never the host's to resolve.
pub fn click_frame(&mut self, target: Click) -> Option<ClientFrame> {
let cmds = improvise_protocol::click(&self.state, target);
if cmds.is_empty() {
return None;
}
for cmd in &cmds {
self.state.fold(cmd);
}
Some(ClientFrame::Commands(cmds))
}
fn clicked(&mut self, target: Click) -> Option<String> {
serde_json::to_string(&self.click_frame(target)?).ok()
}
/// Classify a keypress, folding it locally when this client can answer it.
pub fn key(&mut self, browser: &BrowserKey) -> Option<ClientFrame> {
let event = browser.to_key_event()?;
+140
View File
@@ -0,0 +1,140 @@
// The pointer: improvise-7ybz (a click resolves in Rust) and improvise-wsaw
// (the sidebar obeys its flag). History: context/test-evidence/clients.md.
import { test, expect } from "@playwright/test";
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();
}
/// Open the category panel the way a user does, and leave the keyboard in
/// Normal — so a later click has a focus change to make.
async function openPanelThenLeave(page) {
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("NORMAL");
await expect(page.locator("#categories")).toBeVisible();
}
test.describe("the category sidebar obeys category_panel_open", () => {
// improvise-wsaw. The flag existed from the start and app.js never read it,
// so the sidebar was drawn always and `C` changed only the mode label.
test("the sidebar is hidden until C, and C hides it again", async ({ page }) => {
await openApp(page);
await expect(page.locator("#categories")).toBeHidden();
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await expect(page.locator("#categories li").first()).toBeVisible();
// The same key closes it, because `toggle-panel-and-focus` closes only
// when the panel is already both open AND focused.
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeHidden();
await expect(page.locator("#mode")).toHaveText("NORMAL");
});
});
test.describe("clicking the category panel", () => {
// improvise-7ybz. One click means three things — focus the panel, move its
// cursor, activate the row — and all three are resolved in Rust.
test("a click focuses the panel, moves the cursor and expands the row", async ({
page,
}) => {
await openApp(page);
await openPanelThenLeave(page);
const product = page.locator("#categories li").filter({ hasText: "Product" });
await expect(product).toHaveText(/▸ Product \(4\)/);
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
await product.click();
// All three, and the third is what the user reported as impossible.
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
await expect(product).toHaveText(/▾ Product \(4\)/);
await expect(page.locator("#categories li.item")).toHaveText([
"Espresso",
"Latte",
"Cold Brew",
"Pastry",
]);
// Clicking it again collapses: the click mirrors Enter, which is a toggle.
await product.click();
await expect(page.locator("#categories li.item")).toHaveCount(0);
});
// The other arm of `filter-to-item`: on an ITEM row the same click filters,
// because the rule is "do what Enter does here" rather than "expand".
test("a click on an item row filters, as Enter does", async ({ page }) => {
await openApp(page);
await openPanelThenLeave(page);
await page.locator("#categories li").filter({ hasText: "Product" }).click();
await page.locator("#categories li.item").filter({ hasText: "Latte" }).click();
await expect(page.locator("#status")).toContainText("Filter: Product = Latte");
await expect(page.locator("#categories li.selected")).toHaveText("Latte");
});
});
test.describe("clicking a tile", () => {
// The tile bar is keyboard-reachable only through `T` plus arrows; a click
// is the whole interaction, so it selects AND cycles.
test("a click selects the tile and cycles its axis", async ({ page }) => {
await openApp(page);
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
await expect(product).toHaveText("Product Row");
await product.click();
await expect(page.locator("#mode")).toHaveText("TILES");
await expect(product).toHaveClass(/selected/);
// Row → Column is `Axis::next`; the page does not spell the rotation.
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
// And the grid moved with it, not just the label.
await expect(
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
).toBeVisible();
});
});
test.describe("clicking a grid cell", () => {
// Before improvise-7ybz a click reached only `edit()`, so the selection —
// which every keyboard command reads — stayed wherever it had been.
test("a click moves the selection, not only the editor", async ({ page }) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
// The cursor starts at the origin, so any other cell is a real move.
await expect(cells.nth(0)).toHaveClass(/selected/);
await cells.nth(4).click();
// demo.improv is all Records, so the click also opened an editor; close it
// without committing and the selection must have followed the pointer.
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// The selection a click leaves is the one the keyboard then moves from —
// the property that makes the two input paths one cursor rather than two.
test("the keyboard continues from where the pointer left the cursor", async ({
page,
}) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
await cells.nth(4).click();
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await page.keyboard.press("ArrowLeft");
await expect(cells.nth(3)).toHaveClass(/selected/);
});
});
+24 -5
View File
@@ -189,6 +189,9 @@ function repaint() {
paintStatus();
el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model));
// The flag is the client's answer, not the page's: `C` toggles it and the
// TUI draws the panel on exactly the same condition (improvise-wsaw).
el.categories.hidden = !model.category_panel_open;
el.categories.replaceChildren(categoryList(model));
paintTiles(model);
paintPanel(el.views, model.views, viewRow);
@@ -326,8 +329,15 @@ function gridTable(model) {
// `editable` is the server's answer relayed through Rust: a stored cell.
// Drill is not on the wire yet, so an aggregate is not offered.
td.classList.toggle("editable", cell.editable);
if (cell.editable)
td.addEventListener("click", () => edit(td, dataRow, c, cell.edit_text));
// Every cell moves the selection; an editable one also opens its editor,
// which is the pointer's `i`. Not repainted in that branch: `edit` has
// set `editing`, and a repaint would take the <input> away.
td.addEventListener("click", () => {
const frame = client.click_cell(dataRow, c);
if (frame) transport.send(frame);
if (cell.editable) edit(td, dataRow, c, cell.edit_text);
else repaint();
});
});
}
return table;
@@ -420,11 +430,12 @@ function formulaRow(row) {
* 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 tiles = model.tiles.map((tile, index) => {
const span = document.createElement("span");
span.className = "tile";
span.classList.toggle("selected", tile.selected);
span.textContent = `${tile.category} ${tile.axis}`;
span.addEventListener("click", () => send(client.click_tile(index)));
return span;
});
el.tiles.replaceChildren(...tiles);
@@ -432,18 +443,26 @@ function paintTiles(model) {
function categoryList(model) {
const list = document.createElement("ul");
for (const row of model.categories) {
model.categories.forEach((row, index) => {
const li = document.createElement("li");
li.className = row.is_category ? "category" : "item";
li.classList.toggle("selected", row.selected);
li.textContent = row.is_category
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
: row.label;
li.addEventListener("click", () => send(client.click_category(index)));
list.appendChild(li);
}
});
return list;
}
/** Send what a `click_*` call returned, and repaint. `null` means the click
* resolved to nothing, which is an answer and not a failure. */
function send(frame) {
if (frame) transport.send(frame);
repaint();
}
// The keyboard. `Client::on_key_event` folds locally where it can; what it
// cannot answer is sent and does nothing, by design (context/code/clients.md).