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).