diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md
index 9d800bb1..d964defd 100644
--- a/context/test-evidence/clients.md
+++ b/context/test-evidence/clients.md
@@ -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 `
`
+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.
diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs
index 6c251377..2ccac1ef 100644
--- a/crates/improvise-protocol/src/lib.rs
+++ b/crates/improvise-protocol/src/lib.rs
@@ -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.
diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs
index 88f6649f..ce81ef27 100644
--- a/crates/improvise-protocol/src/resolve.rs
+++ b/crates/improvise-protocol/src/resolve.rs
@@ -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 {
+ 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
/// `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 {
+ 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 {
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 {
@@ -661,8 +748,12 @@ fn cat_at_cursor(state: &ClientState) -> Option {
/// `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 {
- 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 {
+ 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 {
/// `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> {
- 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> {
+ 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> {
)?,
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> {
/// `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) -> Option> {
- 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) -> Option> {
+ 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]
diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs
index 841af8df..90341a96 100644
--- a/crates/improvise-wasm-client/src/lib.rs
+++ b/crates/improvise-wasm-client/src/lib.rs
@@ -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 {
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
let event = browser.to_key_event()?;
diff --git a/harness/tests/pointer.spec.js b/harness/tests/pointer.spec.js
new file mode 100644
index 00000000..3406fae9
--- /dev/null
+++ b/harness/tests/pointer.spec.js
@@ -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/);
+ });
+});
diff --git a/web/app.js b/web/app.js
index d57cd33a..10793417 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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 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).