Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default

This commit is contained in:
Edward Langley
2026-09-21 23:50:06 -07:00
26 changed files with 1441 additions and 80 deletions
+140 -11
View File
@@ -17,7 +17,10 @@ import {
indexAction,
indicator,
isFatal,
MENU_ARROW,
menuKey,
NOTICE_KEY,
pageItemAction,
noticeOpen,
noticeState,
noticeText,
@@ -41,6 +44,7 @@ const el = {
views: document.getElementById("views"),
tiles: document.getElementById("tiles"),
pages: document.getElementById("pages"),
pageMenu: document.getElementById("page-menu"),
mode: document.getElementById("mode"),
minibuffer: document.getElementById("minibuffer"),
hint: document.getElementById("hint"),
@@ -233,6 +237,9 @@ function repaint() {
}
if (model.revision === painted) return;
painted = model.revision;
// The bar is about to be rebuilt, so the chip this menu is anchored to is
// gone. Closing is the honest answer; re-anchoring would be a guess.
closePageMenu();
el.mode.textContent = model.mode;
// The mode's own hint, off `help::hint_line`. The page spells no keys
@@ -328,22 +335,94 @@ function paintHelp(model) {
* (improvise-p7lh). An empty list is what says there are none. */
function paintPages(model) {
const entries = pageEntries(model);
pageAxes = entries;
el.pages.hidden = entries.length === 0;
el.pages.replaceChildren(
...entries.map((entry) => {
const span = document.createElement("span");
span.className = "page-axis";
// Only a Page axis is navigable; a Filter one constrains without being
// something `[`/`]` can move (gotcha 27).
span.classList.toggle("navigable", entry.navigable);
span.textContent = entry.position
? `${entry.text} (${entry.position})`
: entry.text;
return span;
el.pages.replaceChildren(...entries.map(pageChip));
}
/** One page chip. A navigable axis is a <button> carrying the arrow, because
* it opens a menu; a Filter axis is a <span> with neither (improvise-rw6l). */
function pageChip(entry) {
const label = entry.position
? `${entry.text} (${entry.position})`
: entry.text;
if (!entry.navigable) {
// Only a Page axis is navigable; a Filter one constrains without being
// something `[`/`]` or a menu can move (gotcha 27).
const span = document.createElement("span");
span.className = "page-axis";
span.textContent = label;
return span;
}
const button = document.createElement("button");
button.type = "button";
button.className = "page-axis navigable";
button.dataset.axis = String(entry.index);
button.setAttribute("aria-haspopup", "listbox");
const text = document.createElement("span");
text.textContent = label;
const arrow = document.createElement("span");
arrow.className = "page-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = MENU_ARROW;
button.append(text, arrow);
return button;
}
// The open page menu: which axis it belongs to and which row the keyboard is
// on. `null` is closed, exactly as `editing` is.
let pageAxes = [];
let pageMenu = null;
function openPageMenu(axis, anchor) {
const entry = pageAxes[axis];
// A Filter axis carries no items, so there is nothing to open — the chip is
// not a button either, and both answers come off the same `cycle`.
if (!entry || entry.items.length === 0) return;
const active = entry.items.findIndex((item) => item.selected);
pageMenu = { axis, active: active === -1 ? 0 : active };
const box = anchor.getBoundingClientRect();
el.pageMenu.style.left = `${Math.round(box.left)}px`;
el.pageMenu.style.top = `${Math.round(box.bottom + 2)}px`;
paintPageMenu();
}
function closePageMenu() {
pageMenu = null;
el.pageMenu.hidden = true;
el.pageMenu.replaceChildren();
}
function paintPageMenu() {
if (!pageMenu) return;
const entry = pageAxes[pageMenu.axis];
el.pageMenu.hidden = false;
el.pageMenu.replaceChildren(
...entry.items.map((item) => {
const row = document.createElement("button");
row.type = "button";
row.className = "page-item";
row.setAttribute("role", "option");
// `selected` is which item the DOCUMENT is fixed to; `active` is where
// the keyboard is. They start together and part on the first arrow key.
row.setAttribute("aria-selected", String(item.selected));
row.classList.toggle("selected", item.selected);
row.classList.toggle("active", item.item === pageMenu.active);
row.dataset.axis = String(entry.index);
row.dataset.item = String(item.item);
row.textContent = item.selected ? `✓ ${item.label}` : ` ${item.label}`;
return row;
}),
);
}
/** Pick an item and close. The value that comes back is the server's: nothing
* here moves the page bar, exactly as an editor does not move a cell. */
function pickPageItem(axis, item) {
closePageMenu();
send(client.click_page_item(axis, item));
}
/** The grid, painted from a plan in which every decision is already made
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
* which labels repeat and are therefore blank, how many header levels there
@@ -586,6 +665,31 @@ function delegateIndex(root, selector, act) {
});
}
// The page bar and its menu are two surfaces and ONE meaning: an action with
// an axis and maybe an item, decided in `shell.js` (improvise-rw6l, b8ot).
for (const root of [el.pages, el.pageMenu]) {
root.addEventListener("click", (event) => {
const node = event.target.closest("[data-axis]");
if (!node || !root.contains(node)) return;
const action = pageItemAction(node.dataset);
if (!action) return;
// A live editor closes first, before the click is composed — the order
// the grid's own handler keeps, and for the same reason.
if (editing) editing.close(false);
if (action.kind === "pick") pickPageItem(action.axis, action.item);
else if (pageMenu && pageMenu.axis === action.axis) closePageMenu();
else openPageMenu(action.axis, node);
});
}
// A click anywhere else dismisses it. `pointerdown` rather than `click`, so a
// press that lands on the grid closes this before the grid acts on it.
window.addEventListener("pointerdown", (event) => {
if (!pageMenu) return;
if (el.pages.contains(event.target) || el.pageMenu.contains(event.target)) return;
closePageMenu();
});
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
@@ -617,6 +721,25 @@ el.grid.addEventListener("mousedown", (event) => {
// 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).
/** The menu's own keys, decided in `shell.js`. Every one of them is claimed:
* an unhandled key must not reach the grid behind the menu. */
function pageMenuKeydown(e) {
// Ctrl and Alt combinations are the browser's, exactly as below, so the menu
// neither acts on one nor claims it — `Ctrl-j` is not `j` (improvise-rw6l).
if (e.ctrlKey || e.altKey) return;
const items = pageAxes[pageMenu.axis].items;
const action = menuKey(e.key, { count: items.length, active: pageMenu.active });
if (action.kind === "close") closePageMenu();
else if (action.kind === "pick") pickPageItem(pageMenu.axis, action.item);
else if (action.kind === "move") {
pageMenu.active = action.item;
paintPageMenu();
}
// Every unmodified key is CLAIMED, `ignore` included: the grid is behind
// the menu and must not also act on it.
e.preventDefault();
}
window.addEventListener("keydown", (e) => {
// The inline editor owns the keyboard while it is open. It stops
// propagation, so this is belt and braces for a focus we did not predict.
@@ -624,6 +747,12 @@ window.addEventListener("keydown", (e) => {
// BrowserKey drops anything held with the platform meta key precisely so
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
if (e.metaKey) return;
// …and the page menu owns the keyboard too, which is the third state of
// that kind: `hjkl` reaching the grid would scroll it out from under it.
if (pageMenu) {
pageMenuKeydown(e);
return;
}
// The notice's <summary> is a real control and owns Enter and Space while
// it has focus. Claiming them here would call `preventDefault` on the
// browser's own disclosure toggle, so a keyboard visitor could focus it and
+7
View File
@@ -108,6 +108,13 @@
-->
<div id="pages" aria-label="Page axes" hidden></div>
<!--
The page menu (improvise-rw6l). It lives OUTSIDE #pages because that
element's children are replaced on every repaint, and a menu rebuilt
under the pointer is a menu that closes itself.
-->
<div id="page-menu" role="listbox" aria-label="Page items" hidden></div>
<main>
<aside id="categories" aria-label="Categories"></aside>
<aside id="formulas" aria-label="Formulas" hidden></aside>
+54 -1
View File
@@ -88,7 +88,8 @@ export function frameCostOf(model) {
/** The page bar: one entry per category fixing which records are visible. The
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
export function pageEntries(model) {
return model.page_coords.map((axis) => ({
return model.page_coords.map((axis, index) => ({
index,
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
// exactly as `indicator`'s is.
text: `${axis.cat} = ${axis.selected}`,
@@ -96,9 +97,61 @@ export function pageEntries(model) {
// navigable: there is no position to show and no key that would move it.
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
navigable: Boolean(axis.cycle),
// The items a menu offers, RELAYED from `cycle` — the one home of the
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
items: axis.cycle
? axis.cycle.items.map((label, item) => ({
item,
label,
selected: item === axis.cycle.at,
}))
: [],
}));
}
/** The down-arrow. Present exactly where there is a menu to open, which is
* `navigable` and never "is this on the page bar" (gotcha 27). */
export const MENU_ARROW = "▾";
/** What a delegated click on the page bar or its menu MEANS. `null` where the
* node names neither, which is every gap between the chips. */
export function pageItemAction(dataset) {
const axis = whole(dataset.axis);
if (axis === null) return null;
const item = whole(dataset.item);
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
// both: it picks. One attribute apart, so nothing infers from structure.
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
}
/** What a key does while the page menu is open. The menu owns the keyboard
* exactly as the inline editor does — `context/code/clients.md`. */
export function menuKey(key, { count, active }) {
if (count === 0) return { kind: "close" };
switch (key) {
case "Escape":
case "Tab":
return { kind: "close" };
case "Enter":
case " ":
return { kind: "pick", item: active };
case "ArrowDown":
case "j":
return { kind: "move", item: (active + 1) % count };
case "ArrowUp":
case "k":
return { kind: "move", item: (active - 1 + count) % count };
case "Home":
return { kind: "move", item: 0 };
case "End":
return { kind: "move", item: count - 1 };
// Everything else is SWALLOWED rather than passed on: a menu that let
// `hjkl` reach the grid would scroll the thing it is anchored to.
default:
return { kind: "ignore" };
}
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive
+68 -2
View File
@@ -269,12 +269,78 @@ header h1 {
font-size: 0.85em;
}
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without
being navigable (gotcha 27), which is why only one of them lights up. */
/* A Page axis can be moved with `[`/`]` and picked from a menu; a Filter one
constrains without being navigable (gotcha 27), so only one of them lights
up and only one of them is a button at all. */
#pages .navigable {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
display: inline-flex;
gap: 0.3rem;
align-items: center;
font: inherit;
font-weight: 600;
cursor: pointer;
}
#pages .navigable:hover {
background: var(--accent-soft);
}
/* The arrow says a menu is there. It follows `navigable`, never "is this on
the page bar" — an arrow over nothing to pick is worse than none. */
#pages .page-arrow {
font-size: 1.15em;
line-height: 1;
margin-left: -0.05rem;
}
/* The menu itself. Absolutely positioned from the chip it was opened on, and
outside #pages so a repaint cannot rebuild it under the pointer. */
#page-menu[hidden] {
display: none;
}
#page-menu {
position: fixed;
z-index: 20;
min-width: 9rem;
max-height: 60vh;
overflow-y: auto;
padding: 0.2rem;
display: flex;
flex-direction: column;
background: var(--raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
scrollbar-width: thin;
}
#page-menu .page-item {
text-align: left;
padding: 0.2rem 0.5rem;
border: 0;
border-radius: 3px;
background: none;
color: var(--fg);
font: inherit;
font-size: 0.82rem;
white-space: pre;
cursor: pointer;
}
/* `selected` is what the document is fixed to; `active` is where the keyboard
is. They start together and part on the first arrow key. */
#page-menu .page-item.selected {
color: var(--accent);
font-weight: 600;
}
#page-menu .page-item.active,
#page-menu .page-item:hover {
background: var(--selected);
}
/* ── The frame: panels and grid ──────────────────────────────────────── */
+3
View File
@@ -75,6 +75,9 @@ test("the page still reaches the client through a handful of calls", () => {
"click_category",
"click_cell",
"click_formula",
// improvise-rw6l: picking an item off a page axis. The page holds no
// `Basis`, so composing `SetPageSelection` cannot happen above this.
"click_page_item",
"click_tile",
"click_view",
"on_frame",
+88 -2
View File
@@ -4,7 +4,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { cellAction, cellAttrs, gridPlan, indexAction, pageEntries } from "../shell.js";
import {
cellAction,
cellAttrs,
gridPlan,
indexAction,
menuKey,
pageEntries,
pageItemAction,
} from "../shell.js";
const cell = (text, extra = {}) => ({
text,
@@ -85,7 +93,13 @@ test("an index surface reads its row back, and nothing else", () => {
// moved it in silence. The bar is what says which quarter this is.
test("the page bar names the category and the item it is fixed to", () => {
const [entry] = pageEntries({
page_coords: [{ cat: "Quarter", selected: "Q2", cycle: { at: 1, count: 4 } }],
page_coords: [
{
cat: "Quarter",
selected: "Q2",
cycle: { at: 1, count: 4, items: ["Q1", "Q2", "Q3", "Q4"] },
},
],
});
assert.equal(entry.text, "Quarter = Q2");
assert.equal(entry.position, "2/4", "one-based, as a user counts");
@@ -106,3 +120,75 @@ test("a filter axis is shown, and is not offered as something to cycle", () => {
test("no fixed categories is an empty bar, not a blank one", () => {
assert.deepEqual(pageEntries({ page_coords: [] }), []);
});
// improvise-rw6l: the arrow and the menu follow `cycle`, which is the Page /
// Filter split decided in Rust — never "is there an entry on the bar".
test("a page axis offers its items, and a filter axis offers none", () => {
const [page, filter] = pageEntries({
page_coords: [
{
cat: "Quarter",
selected: "Q3",
cycle: { at: 2, count: 3, items: ["Q1", "Q2", "Q3"] },
},
{ cat: "Region", selected: "North", cycle: null },
],
});
assert.deepEqual(page.items, [
{ item: 0, label: "Q1", selected: false },
{ item: 1, label: "Q2", selected: false },
{ item: 2, label: "Q3", selected: true },
]);
assert.equal(page.index, 0, "the axis names itself by its place on the bar");
assert.deepEqual(
filter.items,
[],
"a Filter axis constrains without being navigable: nothing to pick",
);
assert.equal(filter.index, 1);
});
// One meaning across two surfaces (improvise-b8ot): a chip carries an axis, a
// menu row carries an axis AND an item. Nothing infers from structure.
test("a chip opens the menu and a menu row picks, one attribute apart", () => {
assert.deepEqual(pageItemAction({ axis: "1" }), { kind: "open", axis: 1 });
assert.deepEqual(pageItemAction({ axis: "1", item: "0" }), {
kind: "pick",
axis: 1,
item: 0,
});
assert.equal(pageItemAction({}), null, "the gaps between the chips are not a chip");
assert.equal(pageItemAction({ axis: "" }), null);
assert.equal(pageItemAction({ axis: "-1" }), null);
assert.deepEqual(
pageItemAction({ axis: "0", item: "" }),
{ kind: "open", axis: 0 },
"an empty item attribute is NO item, where Number('') would say 0",
);
});
// The menu owns the keyboard while it is open, so every key is answered and
// nothing falls through to the grid it is anchored to.
test("the menu answers its own keys and swallows the rest", () => {
const at = (active) => ({ count: 3, active });
assert.deepEqual(menuKey("ArrowDown", at(0)), { kind: "move", item: 1 });
assert.deepEqual(menuKey("j", at(2)), { kind: "move", item: 0 }, "and it wraps");
assert.deepEqual(menuKey("ArrowUp", at(0)), { kind: "move", item: 2 }, "both ways");
assert.deepEqual(menuKey("k", at(1)), { kind: "move", item: 0 });
assert.deepEqual(menuKey("Home", at(2)), { kind: "move", item: 0 });
assert.deepEqual(menuKey("End", at(0)), { kind: "move", item: 2 });
assert.deepEqual(menuKey("Enter", at(1)), { kind: "pick", item: 1 });
assert.deepEqual(menuKey(" ", at(1)), { kind: "pick", item: 1 });
assert.deepEqual(menuKey("Escape", at(1)), { kind: "close" });
assert.deepEqual(menuKey("Tab", at(1)), { kind: "close" });
assert.deepEqual(
menuKey("h", at(1)),
{ kind: "ignore" },
"a grid key is SWALLOWED, not passed on — the grid is behind the menu",
);
assert.deepEqual(
menuKey("ArrowDown", { count: 0, active: 0 }),
{ kind: "close" },
"a menu with nothing in it cannot be moved around",
);
});