From 6c4878af7ab2ba3e32ddd97c3287e69e55c9ad26 Mon Sep 17 00:00:00 2001 From: Jono Date: Thu, 30 Jul 2026 23:24:08 -0700 Subject: [PATCH] feat(grid): ordered-list reflow with cell-size target (ADR-0010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove grid coordinates — widgets are an ordered list that reflows against viewport width. A single client preference (default 100px) replaces the min/max band. Shrink-to-fit is the default overflow so no partial rows. Schema: drop ; add span, , , field. Move transpose out of orientation.ts. Reflow: downward-scanning even-row preference (8->4x2, 7->4+3, 6->3x2) with dynamic w/3 cap. is pure-geometry, unit-tested. Layouts: all ~12 YAMLs migrated to ordered-list format. Musicca piano now uses gap markers. Closes #92. Follow-up: #97 (reflow fine-tuning). --- README.md | 11 +- client/src/App.tsx | 17 ++ client/src/ButtonGrid.stories.tsx | 72 +++--- client/src/ButtonGrid.tsx | 278 +++++++++++++----------- client/src/Gallery.tsx | 39 +++- client/src/MediaBrowserCell.stories.tsx | 1 - client/src/MediaBrowserCell.test.tsx | 1 - client/src/MediaCell.stories.tsx | 1 - client/src/MediaCell.test.tsx | 1 - client/src/MeterCell.test.tsx | 1 - client/src/Settings.stories.tsx | 2 + client/src/Settings.test.tsx | 2 + client/src/Settings.tsx | 30 ++- client/src/StatsCell.test.tsx | 1 - client/src/Surface.stories.tsx | 65 ++++-- client/src/demo.ts | 75 +++---- client/src/gallery.css | 35 +++ client/src/orientation.ts | 16 -- client/src/protocol.ts | 16 +- client/src/reflow.test.ts | 87 ++++++++ client/src/reflow.ts | 111 ++++++++++ client/src/settings-store.ts | 57 ++++- client/src/style.css | 45 ++-- daemon/deckd/diagnostics.py | 4 +- daemon/deckd/layouts.py | 50 ++++- daemon/deckd/mpris.py | 11 +- daemon/deckd/protocol.py | 4 + daemon/deckd/server.py | 2 + docs/adr/0010-grid-reflow.md | 6 +- layouts.macos/default.yaml | 8 - layouts.macos/firefox.yaml | 46 ++-- layouts/default.yaml | 56 ++--- layouts/firefox.yaml | 50 ++--- layouts/google-meet.yaml | 6 - layouts/mpris.yaml | 2 +- layouts/musicca.yaml | 27 ++- layouts/netflix.yaml | 6 - layouts/terminal.yaml | 4 - layouts/tilix.yaml | 6 - layouts/vlc.yaml | 2 +- layouts/youtube.yaml | 6 - tests/fixtures/layouts/default.yaml | 14 +- tests/fixtures/layouts/firefox.yaml | 1 - tests/test_actions.py | 9 - tests/test_chrome_media_websocket.py | 6 +- tests/test_diag_endpoints.py | 2 +- tests/test_diagnostics.py | 2 - tests/test_focus.py | 5 - tests/test_layouts.py | 92 +++++--- tests/test_macros.py | 6 - tests/test_media_art_route.py | 2 +- tests/test_media_websocket.py | 2 +- tests/test_mediabrowser.py | 42 ++-- tests/test_meters.py | 7 - tests/test_mpris_art_route.py | 2 +- tests/test_mpris_dbus.py | 6 +- tests/test_mpris_websocket.py | 10 +- tests/test_server.py | 9 - 58 files changed, 941 insertions(+), 536 deletions(-) create mode 100644 client/src/reflow.test.ts create mode 100644 client/src/reflow.ts diff --git a/README.md b/README.md index c76ad85..c8e9b8f 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ Every layout renders inside a persistent **chrome** shell that the daemon does n - **Bottom strip** (always visible): the current app badge (from `LayoutMessage.app` — optionally a branded icon + `display_name` + `theme` colour from the layout's YAML, see [Chrome app badge](#chrome-app-badge)), a connection dot (live / reconnecting / disconnected), a `manual control` button that swaps the main area for the combined trackpad + IME surface (see [Manual control mode](#manual-control-mode)), a `media browser` button (when enabled — see [MPRIS media browser](#mpris-media-browser); ADR-0008 records the chrome-view carve-out that lets the client pin a specific layout) that asks the daemon for the global MPRIS browser view, and a `settings` button (see [Client tuning](#client-tuning)). - **Right-side jogstrip** (always visible): a full-height scroll strip that works the same as the in-grid `jogstrip` widget. A layout can suppress it with `jogstrip: false` at the YAML top level — the daemon forwards this as `jogstrip_enabled` on every `LayoutMessage`. -Layout widget coordinates are relative to the chrome-excluded area; the client computes cell sizes from whatever space remains after the strips are subtracted. Layouts are authored in **landscape** orientation. When the viewport is portrait, the client automatically transposes each widget's grid (`[x, y, w, h] → [y, x, h, w]`) so a 4×2 landscape layout renders as 2×4 in portrait — same buttons, same relative arrangement, cells sized for the taller surface (ADR-0004). +Widgets in a layout's `widgets:` list are an **ordered list** that reflows against the viewport width (ADR-0010). There are no grid coordinates. The client packs widgets left-to-right and wraps down, computing the column count from the available width against a client-side cell-size band. A widget may carry a `size: [w, h]` span (default `[1, 1]`) for non-uniform cells; the list order is the only positional input. Portrait just fits fewer columns — no transpose, no orientation conventions. ### Manual control mode @@ -690,7 +690,7 @@ When the daemon runs with auth on, the control endpoints (`/reload`, `/layout`) ## Configuration -A directory of YAML files in `layouts/` — one per app, plus a `default.yaml` fallback. Shipped layouts today: `default`, `firefox`, terminals (`org.gnome.Console`, `foot`, `kitty`, `gnome-terminal`, `konsole`, `alacritty`), `com.gexperts.Tilix`. Each widget has an `id`, `kind` (`button` or `jogstrip` — the trackpad is a chrome mode, not a widget kind), a `grid: [x, y, w, h]` placement, an optional `label`, an optional `icon:` (a `{source, name}` pair — `source` names a client-side icon set, e.g. `lucide` or `simple-icons`, and `name` is the glyph within it; the daemon relays it opaquely), an optional `color:` (any CSS colour string — hex, `hsl(...)`, named — applied as the button background; buttons only, ignored on jogstrips), and an optional `action`. A layout's top-level `match:` list says which apps it covers (matched by `app_id` or `wm_class`); the layout with `match: [default]` is the fallback. A layout may set `jogstrip: false` at the top level to suppress the client's persistent right-side chrome jogstrip (defaults to `true`); the daemon echoes this to the client as `jogstrip_enabled` on every `LayoutMessage`. A layout may also set three optional top-level chrome-identity fields the daemon relays verbatim — `display_name` (human-readable app name shown in the bottom badge), `theme` (a CSS colour the badge + chrome accent is tinted with), and `icon` (a `{source, name}` pair rendered next to the app name) — see the [Chrome app badge](#chrome-app-badge) section and ADR-0007. Action primitives: +A directory of YAML files in `layouts/` — one per app, plus a `default.yaml` fallback. Shipped layouts today: `default`, `firefox`, terminals (`org.gnome.Console`, `foot`, `kitty`, `gnome-terminal`, `konsole`, `alacritty`), `com.gexperts.Tilix`. Each widget has an `id`, `kind` (`button` or `jogstrip` — the trackpad is a chrome mode, not a widget kind), an optional `size: [w, h]` span (default `[1, 1]`; for non-square widgets like wide meters), an optional `label`, an optional `icon:` (a `{source, name}` pair — `source` names a client-side icon set, e.g. `lucide` or `simple-icons`, and `name` is the glyph within it; the daemon relays it opaquely), an optional `color:` (any CSS colour string — hex, `hsl(...)`, named — applied as the button background; buttons only, ignored on jogstrips), and an optional `action`. Widgets pack in list order (ADR-0010); there are no grid coordinates. The special `kind: blank` skips a cell slot for visual gaps. A layout's top-level `match:` list says which apps it covers (matched by `app_id` or `wm_class`); the layout with `match: [default]` is the fallback. A layout may set `jogstrip: false` at the top level to suppress the client's persistent right-side chrome jogstrip (defaults to `true`); the daemon echoes this to the client as `jogstrip_enabled` on every `LayoutMessage`. A layout may also set three optional top-level chrome-identity fields the daemon relays verbatim — `display_name` (human-readable app name shown in the bottom badge), `theme` (a CSS colour the badge + chrome accent is tinted with), and `icon` (a `{source, name}` pair rendered next to the app name) — see the [Chrome app badge](#chrome-app-badge) section and ADR-0007. Action primitives: - `shell: "..."` — launch a command, fire-and-forget. The child is detached (its own session) and runs independently; stdin/stdout/stderr are discarded and the daemon does not wait for it or observe its exit code. This is the way to launch a program (`shell: firefox`, `shell: code`, `shell: "xdg-open https://…"`), including a specific terminal (`shell: tilix`). - `terminal: true` — open the auto-detected terminal emulator, resolved via `$TERMINAL` then a candidate list (`foot`, `kitty`, `gnome-terminal`, `konsole`, `alacritty`). This is the only accepted form: `terminal` takes no command string — for a specific program (terminal or otherwise) use `shell:`. A string value is rejected at layout-load time with a message pointing you at `shell:`. @@ -764,7 +764,6 @@ A layout can include widgets that display values pushed by the daemon in real ti source: cpu_percent # daemon-side sensor name min: 0 # bar's left edge (default 0) max: 100 # bar's right edge (default 100) - grid: [2, 2, 1, 1] ``` The daemon polls the bound sensor on a timer and pushes a `widget_update` WebSocket frame every time the value changes (or the source flips stale). The bar fills proportionally between `min` and `max` and is color-graded cool→hot so a glance tells you whether the number is OK before you read it. @@ -817,7 +816,7 @@ The `media` kind is a single responsive composite widget. It uses configured key ```yaml - id: vlc-media kind: media - grid: [0, 0, 4, 2] + size: [4, 2] controls: [play, volume, position] action: {key: space} volume_down_action: {key: volumedown} @@ -898,7 +897,7 @@ display_name: MPRIS widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] ``` The `match: [mpris]` token is a *synthetic* view name — no real @@ -1004,7 +1003,7 @@ The `mediabrowser` widget has one optional knob: ```yaml - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] empty_state: show # or "hide" ``` diff --git a/client/src/App.tsx b/client/src/App.tsx index e45841a..c400fc3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,8 @@ import { Settings } from "./Settings"; import { useMeterStore } from "./meter-store"; import { useMediaStore } from "./media-store"; import { + clampCellSize, + useCellSize, useBottomScale, useContentScale, useJogWidth, @@ -138,6 +140,16 @@ export function App() { const trackpad = useTrackpadSettings(); const wakeLock = useWakeLockSetting(); const contentScale = useContentScale(); + const cellSize = useCellSize(); + // In demo mode, allow the gallery (or any URL-driven caller) to override the + // cell size via query param so each frame can be tuned independently. + const effectiveCellSize = useMemo(() => { + if (!isDemo) return cellSize; + const p = new URLSearchParams(window.location.search); + const urlSize = p.get("cellSize"); + if (urlSize === null) return cellSize; + return { size: clampCellSize(Number(urlSize)), setSize: cellSize.setSize }; + }, [isDemo, cellSize]); const jogWidth = useJogWidth(); const bottomScale = useBottomScale(); const labelScale = useLabelScale(); @@ -402,6 +414,7 @@ export function App() { { "--content-scale": contentScale.scale, "--label-scale": labelScale.scale, + "--cell-size": `${effectiveCellSize.size}px`, } as CSSProperties } > @@ -452,6 +465,8 @@ export function App() { onWakeLockChange={wakeLock.setEnabled} contentScale={contentScale.scale} onContentScaleChange={contentScale.setScale} + cellSize={effectiveCellSize.size} + onCellSizeChange={effectiveCellSize.setSize} jogWidth={jogWidth.width} onJogWidthChange={jogWidth.setWidth} bottomScale={bottomScale.scale} @@ -481,6 +496,8 @@ export function App() { ) : layout ? ( {}; -type ContentScaleArgs = { contentScale: number }; +type Controls = { contentScale: number; cellSize: number }; -/** Mirror the app's content-scale setting (the settings-view slider) as a - * Ladle range control, so different button sizes can be compared without - * touching localStorage. Same min/max/step as the real setting. */ -const contentScaleControl = { - args: { contentScale: CONTENT_SCALE_DEFAULT }, +const controls = { + args: { contentScale: CONTENT_SCALE_DEFAULT, cellSize: CELL_SIZE_DEFAULT }, argTypes: { contentScale: { - control: { - type: "range" as const, - min: CONTENT_SCALE_MIN, - max: CONTENT_SCALE_MAX, - step: CONTENT_SCALE_STEP, - }, + control: { type: "range" as const, min: CONTENT_SCALE_MIN, max: CONTENT_SCALE_MAX, step: CONTENT_SCALE_STEP }, + }, + cellSize: { + control: { type: "range" as const, min: CELL_SIZE_MIN, max: CELL_SIZE_MAX, step: CELL_SIZE_STEP }, }, }, }; -// ButtonGrid fills its parent (height: 100%), so give stories a fixed frame. function Frame({ name, contentScale, + cellSize, showKeyHints, -}: { name: keyof typeof DEMO_LAYOUTS; showKeyHints?: boolean } & ContentScaleArgs) { +}: { name: keyof typeof DEMO_LAYOUTS; showKeyHints?: boolean } & Controls) { return (
); } -export const Firefox: Story = ({ contentScale }) => ( - -); -Firefox.args = contentScaleControl.args; -Firefox.argTypes = contentScaleControl.argTypes; +export const Firefox: Story = (args) => ; +Firefox.args = controls.args; +Firefox.argTypes = controls.argTypes; -export const Default: Story = ({ contentScale }) => ( - -); -Default.args = contentScaleControl.args; -Default.argTypes = contentScaleControl.argTypes; +export const Default: Story = (args) => ; +Default.args = controls.args; +Default.argTypes = controls.argTypes; -/** All icon sources + edge cases in one grid: Lucide glyphs, per-button - * colour, lazily-loaded Simple Icons brand logos, a no-icon button, and an - * intentionally-unknown icon (dashed placeholder). */ -export const Showcase: Story = ({ contentScale }) => ( - -); -Showcase.args = contentScaleControl.args; -Showcase.argTypes = contentScaleControl.argTypes; +export const Showcase: Story = (args) => ; +Showcase.args = controls.args; +Showcase.argTypes = controls.argTypes; -/** Key hints on: each button whose action is a key combo shows it as a small - * dimmed caption under the label (Firefox's combos map to real shortcuts). - * Buttons without a key action (e.g. showcase launchers) render no hint. */ -export const KeyHints: Story = ({ contentScale }) => ( - -); -KeyHints.args = contentScaleControl.args; -KeyHints.argTypes = contentScaleControl.argTypes; +export const KeyHints: Story = (args) => ; +KeyHints.args = controls.args; +KeyHints.argTypes = controls.argTypes; diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index 4b3be00..fe39072 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { Widget } from "./protocol"; import { Icon } from "./Icon"; @@ -7,10 +8,15 @@ import { StatsCell } from "./StatsCell"; import { MediaCell } from "./MediaCell"; import type { MediaReading } from "./media-store"; import type { MeterReading } from "./meter-store"; -import { transposeWidgets, useOrientation } from "./orientation"; -import type { Orientation } from "./orientation"; +import { computeReflow } from "./reflow"; +import type { OverflowMode } from "./reflow"; +import { CELL_SIZE_DEFAULT } from "./settings-store"; import { onActivate } from "./a11y"; +/** Gap between cells, in CSS pixels. Kept in sync with ``.grid { gap }`` so the + * reflow maths agrees with what the browser actually renders. */ +const GRID_GAP = 8; + type Props = { widgets: Widget[]; onPress: (id: string) => void; @@ -18,11 +24,15 @@ type Props = { onJogEnd: (id: string, velocity: number) => void; scrollScale: number; scrollInvert: boolean; - /** Override the auto-detected orientation. The live app leaves this unset - * (orientation follows the viewport); fixed-size harnesses like the Ladle - * device stories pass it so the transpose matches the container's shape - * rather than the window's. */ - orientation?: Orientation; + /** Overflow behaviour when widgets exceed the capacity the band yields at + * the current viewport (ADR-0010): ``clip`` (default) leaves trailing + * widgets off-surface; ``shrink-to-fit`` shrinks cells below the floor so + * every widget fits. Comes from the layout's ``overflow`` field. */ + overflow?: OverflowMode; + /** Cell size target (client-side device preference, ADR-0010). Columns are + * packed around this value; cells fill the width evenly. Defaults let + * harnesses that don't wire settings still render sensibly. */ + cellSize?: number; /** Latest reading per sensor source. Missing sources render with no * value (bar empty, "—" numeric). Stale readings show the bar at * its last position with a dimmed readout. */ @@ -37,8 +47,6 @@ type Props = { showKeyHints?: boolean; }; -const FALLBACK_DIM = 4; - /** Title-case each token of a key combo so ``ctrl+a`` reads as ``Ctrl+A``. * Purely presentational — the daemon-side combo string is untouched. */ function prettifyCombo(combo: string): string { @@ -60,21 +68,31 @@ function keyHint(w: Widget): string | null { return null; } -/** Derive grid dimensions from the layout's widget extents so cells fill the - * chrome-excluded area rather than leaving empty 1fr rows/columns when a - * layout doesn't use the full 4x4 space (ADR-0003: the client computes - * cell sizes from available screen space). Falls back to 4x4 when there - * are no widgets to size against. */ -function deriveDims(widgets: Widget[]): [number, number] { - if (widgets.length === 0) return [FALLBACK_DIM, FALLBACK_DIM]; - let cols = 0; - let rows = 0; - for (const w of widgets) { - const [x, y, wCols, wRows] = w.grid; - cols = Math.max(cols, x + wCols); - rows = Math.max(rows, y + wRows); - } - return [Math.max(cols, 1), Math.max(rows, 1)]; +/** A widget's ``[w, h]`` column/row span. ``full`` and absent both collapse to + * a single cell for span purposes (a ``full`` widget is placed separately). */ +function spanOf(w: Widget): [number, number] { + if (w.size == null || w.size === "full") return [1, 1]; + const [cw, ch] = w.size; + return [Math.max(1, cw), Math.max(1, ch)]; +} + +/** Measure the grid area so the reflow maths can compute the column count and + * cell size from live pixels (ADR-0003: the client sizes cells from available + * screen space). A ``ResizeObserver`` keeps it current as the window is + * dragged / the device rotates — no polling. */ +function useMeasuredSize(): [React.RefObject, { width: number; height: number }] { + const ref = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + useLayoutEffect(() => { + const el = ref.current; + if (!el || typeof ResizeObserver === "undefined") return; + const update = () => setSize({ width: el.clientWidth, height: el.clientHeight }); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return [ref, size]; } export function ButtonGrid({ @@ -84,117 +102,129 @@ export function ButtonGrid({ onJogEnd, scrollScale, scrollInvert, - orientation: orientationOverride, + overflow = "shrink-to-fit", + cellSize = CELL_SIZE_DEFAULT, meterReadings, labelScale, mediaStates, onMediaCommand, showKeyHints, }: Props) { - const autoOrientation = useOrientation(); - const orientation = orientationOverride ?? autoOrientation; - // In portrait, transpose so a landscape-authored grid keeps sensibly-sized - // cells (a 4x2 firefox layout becomes 2x4 with taller buttons). - const laid = orientation === "portrait" ? transposeWidgets(widgets) : widgets; - const [COLS, ROWS] = deriveDims(laid); - const filled = Array.from({ length: ROWS }, () => Array(COLS).fill(null) as (Widget | null)[]); - for (const w of laid) { - const [x, y, wCols, wRows] = w.grid; - for (let dy = 0; dy < wRows; dy++) { - for (let dx = 0; dx < wCols; dx++) { - if (y + dy < ROWS && x + dx < COLS) filled[y + dy][x + dx] = w; - } - } - } + const [gridRef, size] = useMeasuredSize(); + + // Cells occupied by flow widgets (spans counted), used by shrink-to-fit to + // estimate the row count. ``full`` widgets leave the flow, so they don't add. + const totalUnits = widgets.reduce((sum, w) => { + if (w.size === "full") return sum; + const [cw, ch] = spanOf(w); + return sum + cw * ch; + }, 0); + + const { cols, cellPx } = computeReflow({ + containerWidth: size.width, + containerHeight: size.height, + cellSize, + gap: GRID_GAP, + totalUnits, + mode: overflow, + }); + + // Square, fixed tracks: every column is ``cellPx`` wide and every implicit + // row is ``cellPx`` tall, so a cell is square and an ``[w, h]`` span is + // exactly ``w`` columns by ``h`` rows (gaps included). Leftover width is + // centered and leftover height sits below (both set in ``.grid`` CSS). + const gridStyle: CSSProperties = { + gridTemplateColumns: `repeat(${cols}, ${cellPx}px)`, + gridAutoRows: `${cellPx}px`, + }; return ( -
- {filled.flatMap((row, y) => - row.map((w, x) => { - // ``w.grid`` here already reflects any transpose applied above. - if (!w) return
; - const [gx, gy, gw, gh] = w.grid; - const isOrigin = gx === x && gy === y; - if (!isOrigin) return null; - const style: CSSProperties = { gridColumn: `span ${gw}`, gridRow: `span ${gh}` }; - if (w.kind === "jogstrip") { - return ( - - ); - } - if (w.kind === "meter") { - return ( - - ); - } - if (w.kind === "media") { - return ( - undefined)} - /> - ); - } - if (w.kind === "stats") { - return ( - - ); - } - const buttonStyle: CSSProperties = w.color - ? { ...style, backgroundColor: w.color } - : style; - const hint = showKeyHints ? keyHint(w) : null; +
+ {widgets.map((w) => { + const full = w.size === "full"; + const [cw, ch] = spanOf(w); + // Cap a span at the current column count so a too-wide widget doesn't + // force horizontal overflow; strict order (no dense) wraps it down. + const style: CSSProperties = full + ? { gridColumn: "1 / -1" } + : { gridColumn: `span ${Math.min(cw, cols)}`, gridRow: `span ${ch}` }; + + if (w.kind === "blank") { + // A deliberate gap in the flow: holds its span, renders nothing. + return ); } diff --git a/client/src/Gallery.tsx b/client/src/Gallery.tsx index 71ea1c4..70800df 100644 --- a/client/src/Gallery.tsx +++ b/client/src/Gallery.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; import { DEMO_NAMES } from "./demo"; +import { + CELL_SIZE_MIN, + CELL_SIZE_MAX, + CELL_SIZE_DEFAULT, + CELL_SIZE_STEP, +} from "./settings-store"; /** Dev-only responsive gallery. Renders the real client (via ``?demo=``) in * a set of device-sized iframes side by side, so a layout can be eyeballed @@ -28,15 +34,20 @@ function Frame({ demo, orientation, keyHints, + cellSize, }: { device: Device; demo: string; orientation: Orientation; keyHints: boolean; + cellSize: number; }) { const [w, h] = orientation === "landscape" ? [device.h, device.w] : [device.w, device.h]; const scale = Math.min(1, MAX_W / w, MAX_H / h); - const src = `${import.meta.env.BASE_URL}?demo=${demo}${keyHints ? "&showKeyHints=1" : ""}`; + const params = new URLSearchParams({ demo }); + if (keyHints) params.set("showKeyHints", "1"); + params.set("cellSize", String(cellSize)); + const src = `${import.meta.env.BASE_URL}?${params}`; return (
@@ -63,6 +74,7 @@ export function Gallery() { const [demo, setDemo] = useState(DEMO_NAMES[0] ?? "firefox"); const [orientation, setOrientation] = useState("landscape"); const [keyHints, setKeyHints] = useState(false); + const [cellSize, setCellSize] = useState(CELL_SIZE_DEFAULT); return (
@@ -99,9 +111,32 @@ export function Gallery() {
+
+
+ + setCellSize(Number(e.target.value))} + /> +
+
{DEVICES.map((d) => ( - + ))}
diff --git a/client/src/MediaBrowserCell.stories.tsx b/client/src/MediaBrowserCell.stories.tsx index 20d9df1..040864d 100644 --- a/client/src/MediaBrowserCell.stories.tsx +++ b/client/src/MediaBrowserCell.stories.tsx @@ -10,7 +10,6 @@ const noop = () => {}; const WIDGET: Widget = { id: "browser", kind: "mediabrowser", - grid: [0, 0, 4, 2], }; /** A playing-VLC / paused-Spotify / stopped-mystery trio. The diff --git a/client/src/MediaBrowserCell.test.tsx b/client/src/MediaBrowserCell.test.tsx index 52c0aaa..c7cfffb 100644 --- a/client/src/MediaBrowserCell.test.tsx +++ b/client/src/MediaBrowserCell.test.tsx @@ -38,7 +38,6 @@ import type { Widget } from "./protocol"; const WIDGET: Widget = { id: "browser", kind: "mediabrowser", - grid: [0, 0, 4, 2], empty_state: "show", }; diff --git a/client/src/MediaCell.stories.tsx b/client/src/MediaCell.stories.tsx index 567f000..2a48cef 100644 --- a/client/src/MediaCell.stories.tsx +++ b/client/src/MediaCell.stories.tsx @@ -11,7 +11,6 @@ const WIDGET: Widget = { id: "vlc-media", kind: "media", label: "VLC", - grid: [0, 0, 4, 2], controls: ["play", "previous", "next", "volume", "position", "speed"], media_http: {}, }; diff --git a/client/src/MediaCell.test.tsx b/client/src/MediaCell.test.tsx index 097a910..2385233 100644 --- a/client/src/MediaCell.test.tsx +++ b/client/src/MediaCell.test.tsx @@ -8,7 +8,6 @@ import type { Widget } from "./protocol"; const WIDGET: Widget = { id: "media", kind: "media", - grid: [0, 0, 4, 2], controls: ["play", "previous", "next", "volume", "position", "speed"], media_http: {}, }; diff --git a/client/src/MeterCell.test.tsx b/client/src/MeterCell.test.tsx index 5d3140c..2e0dd00 100644 --- a/client/src/MeterCell.test.tsx +++ b/client/src/MeterCell.test.tsx @@ -12,7 +12,6 @@ const CPU_WIDGET: Widget = { source: "cpu_percent", min: 0, max: 100, - grid: [0, 0, 1, 1], }; function renderMeter(widget: Widget, reading: MeterReading | null) { diff --git a/client/src/Settings.stories.tsx b/client/src/Settings.stories.tsx index 759ea14..23c3408 100644 --- a/client/src/Settings.stories.tsx +++ b/client/src/Settings.stories.tsx @@ -23,6 +23,8 @@ export const Default: Story = () => ( onWakeLockChange={noop} contentScale={1} onContentScaleChange={noop} + cellSize={100} + onCellSizeChange={noop} jogWidth={1} onJogWidthChange={noop} bottomScale={1} diff --git a/client/src/Settings.test.tsx b/client/src/Settings.test.tsx index d1e7ab3..0ce9f74 100644 --- a/client/src/Settings.test.tsx +++ b/client/src/Settings.test.tsx @@ -19,6 +19,8 @@ function renderSettings(overrides: Partial[0]> = {}) onWakeLockChange: () => {}, contentScale: 1, onContentScaleChange: () => {}, + cellSize: 100, + onCellSizeChange: () => {}, jogWidth: 1, onJogWidthChange: () => {}, bottomScale: 1, diff --git a/client/src/Settings.tsx b/client/src/Settings.tsx index b694442..62bcf53 100644 --- a/client/src/Settings.tsx +++ b/client/src/Settings.tsx @@ -4,6 +4,9 @@ import { BOTTOM_SCALE_MAX, BOTTOM_SCALE_MIN, BOTTOM_SCALE_STEP, + CELL_SIZE_MAX, + CELL_SIZE_MIN, + CELL_SIZE_STEP, CONTENT_SCALE_MAX, CONTENT_SCALE_MIN, CONTENT_SCALE_STEP, @@ -36,6 +39,8 @@ type Props = { onWakeLockChange: (v: boolean) => void; contentScale: number; onContentScaleChange: (n: number) => void; + cellSize: number; + onCellSizeChange: (n: number) => void; jogWidth: number; onJogWidthChange: (n: number) => void; bottomScale: number; @@ -78,6 +83,8 @@ export function Settings({ onWakeLockChange, contentScale, onContentScaleChange, + cellSize, + onCellSizeChange, jogWidth, onJogWidthChange, bottomScale, @@ -185,12 +192,31 @@ export function Settings({

Display

+ {/* Cell size target (ADR-0010): the square cell edge (CSS px) the grid + packs columns around. Cells fill the width evenly — more columns fit + as the viewport widens, keeping the result near the target. */}
- Content size + Cell size onCellSizeChange(Number(e.target.value))} + /> + + {cellSize}px + +
+
+ Content nudge + derived diff --git a/client/src/Surface.stories.tsx b/client/src/Surface.stories.tsx index 5ae3a95..6af6d58 100644 --- a/client/src/Surface.stories.tsx +++ b/client/src/Surface.stories.tsx @@ -1,27 +1,38 @@ import type { Story } from "@ladle/react"; import { ButtonGrid } from "./ButtonGrid"; import { DEMO_LAYOUTS } from "./demo"; -import type { Orientation } from "./orientation"; +import { + CELL_SIZE_MIN, + CELL_SIZE_MAX, + CELL_SIZE_DEFAULT, + CELL_SIZE_STEP, +} from "./settings-store"; export default { title: "Surface / device sizes" }; const noop = () => {}; -/** Render the grid inside a fixed device-sized frame to simulate a resolution - * right in Ladle (a parent with fixed dimensions, per the design-tooling - * discussion). ``orientation`` is passed explicitly because the auto-detect - * follows the window, which a fixed container can't change. */ +type Controls = { cellSize: number }; + +const controls = { + args: { cellSize: CELL_SIZE_DEFAULT }, + argTypes: { + cellSize: { + control: { type: "range" as const, min: CELL_SIZE_MIN, max: CELL_SIZE_MAX, step: CELL_SIZE_STEP }, + }, + }, +}; + function Device({ w, h, - orientation, layout = "firefox", + cellSize, }: { w: number; h: number; - orientation: Orientation; layout?: keyof typeof DEMO_LAYOUTS; -}) { +} & Controls) { return (
- {w}×{h} · {orientation} + {w}×{h}
); } -export const PhoneLandscape: Story = () => ; -export const PhonePortrait: Story = () => ; -export const Tablet7Landscape: Story = () => ; -export const Tablet10Landscape: Story = () => ; -export const Tablet10Portrait: Story = () => ; +export const PhoneLandscape: Story = (args) => ; +PhoneLandscape.args = controls.args; +PhoneLandscape.argTypes = controls.argTypes; + +export const PhonePortrait: Story = (args) => ; +PhonePortrait.args = controls.args; +PhonePortrait.argTypes = controls.argTypes; + +/** S23 Firefox grid area (viewport minus chrome/jogstrip) — landscape. */ +export const S23Landscape: Story = (args) => ; +S23Landscape.args = controls.args; +S23Landscape.argTypes = controls.argTypes; + +/** S23 Firefox grid area — portrait. */ +export const S23Portrait: Story = (args) => ; +S23Portrait.args = controls.args; +S23Portrait.argTypes = controls.argTypes; + +export const Tablet7Landscape: Story = (args) => ; +Tablet7Landscape.args = controls.args; +Tablet7Landscape.argTypes = controls.argTypes; + +export const Tablet10Landscape: Story = (args) => ; +Tablet10Landscape.args = controls.args; +Tablet10Landscape.argTypes = controls.argTypes; + +export const Tablet10Portrait: Story = (args) => ; +Tablet10Portrait.args = controls.args; +Tablet10Portrait.argTypes = controls.argTypes; diff --git a/client/src/demo.ts b/client/src/demo.ts index e83bfd6..58c8bf6 100644 --- a/client/src/demo.ts +++ b/client/src/demo.ts @@ -14,14 +14,14 @@ const FIREFOX: ServerLayout = { icon: { source: "simple-icons", name: "firefox" }, jogstrip_enabled: true, widgets: [ - { id: "new-tab", kind: "button", label: "New tab", icon: { source: "lucide", name: "plus" }, grid: [0, 0, 1, 1], action: { key: "ctrl+t" } }, - { id: "new-window", kind: "button", label: "New window", icon: { source: "lucide", name: "app-window" }, grid: [1, 0, 1, 1], action: { key: "ctrl+n" } }, - { id: "back", kind: "button", label: "Back", icon: { source: "lucide", name: "arrow-left" }, color: "#1e3a8a", grid: [2, 0, 1, 1], action: { key: "alt+left" } }, - { id: "forward", kind: "button", label: "Forward", icon: { source: "lucide", name: "arrow-right" }, color: "#1e3a8a", grid: [3, 0, 1, 1], action: { key: "alt+right" } }, - { id: "reload", kind: "button", label: "Reload", icon: { source: "lucide", name: "refresh-cw" }, grid: [0, 1, 1, 1], action: { key: "ctrl+r" } }, - { id: "focus-url", kind: "button", label: "URL bar", icon: { source: "lucide", name: "link" }, grid: [1, 1, 1, 1], action: { key: "ctrl+l" } }, - { id: "find", kind: "button", label: "Find", icon: { source: "lucide", name: "search" }, grid: [2, 1, 1, 1], action: { key: "ctrl+f" } }, - { id: "close-tab", kind: "button", label: "Close tab", icon: { source: "lucide", name: "x" }, grid: [3, 1, 1, 1], action: { key: "ctrl+w" } }, + { id: "new-tab", kind: "button", label: "New tab", icon: { source: "lucide", name: "plus" }, action: { key: "ctrl+t" } }, + { id: "new-window", kind: "button", label: "New window", icon: { source: "lucide", name: "app-window" }, action: { key: "ctrl+n" } }, + { id: "back", kind: "button", label: "Back", icon: { source: "lucide", name: "arrow-left" }, color: "#1e3a8a", action: { key: "alt+left" } }, + { id: "forward", kind: "button", label: "Forward", icon: { source: "lucide", name: "arrow-right" }, color: "#1e3a8a", action: { key: "alt+right" } }, + { id: "reload", kind: "button", label: "Reload", icon: { source: "lucide", name: "refresh-cw" }, action: { key: "ctrl+r" } }, + { id: "focus-url", kind: "button", label: "URL bar", icon: { source: "lucide", name: "link" }, action: { key: "ctrl+l" } }, + { id: "find", kind: "button", label: "Find", icon: { source: "lucide", name: "search" }, action: { key: "ctrl+f" } }, + { id: "close-tab", kind: "button", label: "Close tab", icon: { source: "lucide", name: "x" }, action: { key: "ctrl+w" } }, ], }; @@ -36,12 +36,12 @@ const YOUTUBE: ServerLayout = { web_app: true, jogstrip_enabled: true, widgets: [ - { id: "play-pause", kind: "button", label: "Play/Pause", icon: { source: "lucide", name: "play" }, color: "#ff0000", grid: [0, 0, 1, 1], action: { key: "k" } }, - { id: "mute", kind: "button", label: "Mute", icon: { source: "lucide", name: "volume-x" }, grid: [1, 0, 1, 1], action: { key: "m" } }, - { id: "fullscreen", kind: "button", label: "Fullscreen", icon: { source: "lucide", name: "maximize" }, grid: [2, 0, 1, 1], action: { key: "f" } }, - { id: "back-10", kind: "button", label: "-10s", icon: { source: "lucide", name: "rewind" }, grid: [0, 1, 1, 1], action: { key: "j" } }, - { id: "fwd-10", kind: "button", label: "+10s", icon: { source: "lucide", name: "fast-forward" }, grid: [1, 1, 1, 1], action: { key: "l" } }, - { id: "captions", kind: "button", label: "Captions", icon: { source: "lucide", name: "captions" }, grid: [2, 1, 1, 1], action: { key: "c" } }, + { id: "play-pause", kind: "button", label: "Play/Pause", icon: { source: "lucide", name: "play" }, color: "#ff0000", action: { key: "k" } }, + { id: "mute", kind: "button", label: "Mute", icon: { source: "lucide", name: "volume-x" }, action: { key: "m" } }, + { id: "fullscreen", kind: "button", label: "Fullscreen", icon: { source: "lucide", name: "maximize" }, action: { key: "f" } }, + { id: "back-10", kind: "button", label: "-10s", icon: { source: "lucide", name: "rewind" }, action: { key: "j" } }, + { id: "fwd-10", kind: "button", label: "+10s", icon: { source: "lucide", name: "fast-forward" }, action: { key: "l" } }, + { id: "captions", kind: "button", label: "Captions", icon: { source: "lucide", name: "captions" }, action: { key: "c" } }, ], }; @@ -50,10 +50,10 @@ const DEFAULT: ServerLayout = { app: "default (demo)", jogstrip_enabled: true, widgets: [ - { id: "open-url", kind: "button", label: "Open example.com", icon: { source: "lucide", name: "globe" }, grid: [0, 0, 1, 1] }, - { id: "audio-toggle", kind: "button", label: "VLC Play/Pause", icon: { source: "lucide", name: "play" }, grid: [1, 0, 1, 1] }, - { id: "xterm", kind: "button", label: "xterm", icon: { source: "lucide", name: "terminal" }, grid: [2, 0, 1, 1] }, - { id: "send-key", kind: "button", label: "Send Ctrl+T", icon: { source: "lucide", name: "keyboard" }, grid: [3, 0, 1, 1], action: { key: "ctrl+t" } }, + { id: "open-url", kind: "button", label: "Open example.com", icon: { source: "lucide", name: "globe" } }, + { id: "audio-toggle", kind: "button", label: "VLC Play/Pause", icon: { source: "lucide", name: "play" } }, + { id: "xterm", kind: "button", label: "xterm", icon: { source: "lucide", name: "terminal" } }, + { id: "send-key", kind: "button", label: "Send Ctrl+T", icon: { source: "lucide", name: "keyboard" }, action: { key: "ctrl+t" } }, ], }; @@ -69,14 +69,14 @@ const SHOWCASE: ServerLayout = { theme: "#6d28d9", jogstrip_enabled: true, widgets: [ - { id: "firefox", kind: "button", label: "Firefox", icon: { source: "simple-icons", name: "firefox" }, color: "#b5651d", grid: [0, 0, 1, 1] }, - { id: "vscode", kind: "button", label: "VS Code", icon: { source: "simple-icons", name: "vscodium" }, color: "#1e3a8a", grid: [1, 0, 1, 1] }, - { id: "signal", kind: "button", label: "Signal", icon: { source: "simple-icons", name: "signal" }, grid: [2, 0, 1, 1] }, - { id: "search", kind: "button", label: "Search", icon: { source: "lucide", name: "search" }, grid: [3, 0, 1, 1] }, - { id: "plain", kind: "button", label: "No icon", grid: [0, 1, 1, 1] }, - { id: "colored", kind: "button", label: "Accent", icon: { source: "lucide", name: "sparkles" }, color: "#6d28d9", grid: [1, 1, 1, 1] }, - { id: "danger", kind: "button", label: "Danger", icon: { source: "lucide", name: "trash-2" }, color: "#7f1d1d", grid: [2, 1, 1, 1] }, - { id: "missing", kind: "button", label: "Missing", icon: { source: "lucide", name: "not-a-real-icon" }, grid: [3, 1, 1, 1] }, + { id: "firefox", kind: "button", label: "Firefox", icon: { source: "simple-icons", name: "firefox" }, color: "#b5651d" }, + { id: "vscode", kind: "button", label: "VS Code", icon: { source: "simple-icons", name: "vscodium" }, color: "#1e3a8a" }, + { id: "signal", kind: "button", label: "Signal", icon: { source: "simple-icons", name: "signal" } }, + { id: "search", kind: "button", label: "Search", icon: { source: "lucide", name: "search" } }, + { id: "plain", kind: "button", label: "No icon" }, + { id: "colored", kind: "button", label: "Accent", icon: { source: "lucide", name: "sparkles" }, color: "#6d28d9" }, + { id: "danger", kind: "button", label: "Danger", icon: { source: "lucide", name: "trash-2" }, color: "#7f1d1d" }, + { id: "missing", kind: "button", label: "Missing", icon: { source: "lucide", name: "not-a-real-icon" } }, ], }; @@ -101,7 +101,7 @@ const METER: ServerLayout = { source: "cpu_percent", min: 0, max: 100, - grid: [0, 0, 2, 1], + size: [2, 1], }, { id: "mem_percent", @@ -111,11 +111,11 @@ const METER: ServerLayout = { source: "mem_percent", min: 0, max: 100, - grid: [0, 1, 2, 1], + size: [2, 1], }, - { id: "open-url", kind: "button", label: "example.com", icon: { source: "simple-icons", name: "firefox" }, grid: [2, 0, 1, 1] }, - { id: "tilix", kind: "button", label: "Tilix", icon: { source: "lucide", name: "square-terminal" }, grid: [3, 0, 1, 1] }, - { id: "audio-toggle", kind: "button", label: "VLC", icon: { source: "lucide", name: "play" }, grid: [2, 1, 1, 1] }, + { id: "open-url", kind: "button", label: "example.com", icon: { source: "simple-icons", name: "firefox" } }, + { id: "tilix", kind: "button", label: "Tilix", icon: { source: "lucide", name: "square-terminal" } }, + { id: "audio-toggle", kind: "button", label: "VLC", icon: { source: "lucide", name: "play" } }, // Combined stats cell — reads the same cpu_percent/mem_percent seeds as // the bar meters above (readings are keyed by source), so it renders // without its own seed entry. @@ -123,7 +123,6 @@ const METER: ServerLayout = { id: "system", kind: "stats", label: "System", - grid: [3, 1, 1, 1], metrics: [ { source: "cpu_percent", label: "CPU" }, { source: "mem_percent", label: "MEM" }, @@ -160,7 +159,7 @@ const VLC: ServerLayout = { id: "vlc-media", kind: "media", label: "VLC", - grid: [0, 0, 4, 2], + size: [4, 2], controls: ["play", "previous", "next", "volume", "position", "speed"], }, ], @@ -209,7 +208,7 @@ const MPRIS: ServerLayout = { { id: "browser", kind: "mediabrowser", - grid: [0, 0, 4, 2], + size: [4, 2], }, ], }; @@ -291,7 +290,7 @@ const MACRO: ServerLayout = { kind: "button", label: "Notify then browser", icon: { source: "lucide", name: "bell" }, - grid: [0, 0, 2, 1], + size: [2, 1], action: { key: "ctrl+t" }, macro: { steps: [ @@ -306,7 +305,6 @@ const MACRO: ServerLayout = { kind: "button", label: "Multi-key", icon: { source: "lucide", name: "keyboard" }, - grid: [2, 0, 1, 1], macro: { steps: [ { type: "key", value: "ctrl+a" }, @@ -319,7 +317,6 @@ const MACRO: ServerLayout = { kind: "button", label: "Keep going on error", icon: { source: "lucide", name: "list-checks" }, - grid: [3, 0, 1, 1], macro: { continue_on_error: true, steps: [ @@ -330,8 +327,8 @@ const MACRO: ServerLayout = { ], }, }, - { id: "open-url", kind: "button", label: "example.com", icon: { source: "lucide", name: "globe" }, grid: [0, 1, 1, 1] }, - { id: "tilix", kind: "button", label: "Terminal", icon: { source: "lucide", name: "square-terminal" }, grid: [1, 1, 1, 1] }, + { id: "open-url", kind: "button", label: "example.com", icon: { source: "lucide", name: "globe" } }, + { id: "tilix", kind: "button", label: "Terminal", icon: { source: "lucide", name: "square-terminal" } }, ], }; diff --git a/client/src/gallery.css b/client/src/gallery.css index 722b0cf..69a4322 100644 --- a/client/src/gallery.css +++ b/client/src/gallery.css @@ -47,6 +47,41 @@ color: #7dd3fc; } +.gallery-band { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 10px 18px; + background: #10151b; + border-bottom: 1px solid #232a32; +} + +.gallery-band-control { + display: flex; + align-items: center; + gap: 10px; +} + +.gallery-band-control label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #8a96a3; + white-space: nowrap; +} + +.gallery-band-val { + display: inline-block; + min-width: 36px; + color: #7dd3fc; + font-variant-numeric: tabular-nums; +} + +.gallery-band-control input[type="range"] { + width: 100px; + accent-color: #3fb1d8; +} + .gallery-grid { display: flex; flex-wrap: wrap; diff --git a/client/src/orientation.ts b/client/src/orientation.ts index c3f662f..51d3e0a 100644 --- a/client/src/orientation.ts +++ b/client/src/orientation.ts @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import type { Widget } from "./protocol"; export type Orientation = "portrait" | "landscape"; @@ -25,18 +24,3 @@ function currentOrientation(): Orientation { if (typeof window === "undefined" || !window.matchMedia) return "landscape"; return window.matchMedia("(orientation: portrait)").matches ? "portrait" : "landscape"; } - -/** Transpose every widget's grid so a layout authored for landscape - * (wider than tall) also fills a portrait viewport sensibly. A 4x2 firefox - * grid becomes 2x4; a single-row terminal grid becomes a single-column. - * Coordinates flip diagonally: ``[x, y, w, h] -> [y, x, h, w]``. - * - * ADR-0004 reserved orientation-specific YAML blocks for the future; until - * a layout opts into that, this auto-transpose gives portrait devices a - * usable button size without every layout author having to author twice. */ -export function transposeWidgets(widgets: Widget[]): Widget[] { - return widgets.map((w) => ({ - ...w, - grid: [w.grid[1], w.grid[0], w.grid[3], w.grid[2]] as Widget["grid"], - })); -} diff --git a/client/src/protocol.ts b/client/src/protocol.ts index d8b5cc8..402d740 100644 --- a/client/src/protocol.ts +++ b/client/src/protocol.ts @@ -41,12 +41,18 @@ export type MediaState = { * it as a per-row header, matching GNOME. ``null`` for the VLC path. */ app_name?: string | null; }; +/** A widget's extent in the reflow (ADR-0010). ``[w, h]`` is a column/row + * span (default ``[1, 1]``); the literal ``"full"`` opts the widget out of + * the flow to take the whole chrome-excluded surface. There is no position — + * widgets pack in list order, left-to-right, wrapping down. */ +export type WidgetSize = [number, number] | "full"; export type Widget = { id: string; - kind: "button" | "jogstrip" | "trackpad" | "meter" | "stats" | "media" | "mediabrowser"; + kind: "button" | "blank" | "jogstrip" | "trackpad" | "meter" | "stats" | "media" | "mediabrowser"; label?: string | null; icon?: Icon | null; - grid: [number, number, number, number]; + /** Reflow extent (ADR-0010). Absent means a ``[1, 1]`` single cell. */ + size?: WidgetSize | null; color?: string | null; action?: Record | null; /** Macro steps: an ordered list of key/shell/dbus/delay actions that the @@ -77,6 +83,12 @@ export type ServerLayout = { /** Optional chrome view identifier; null for focus-driven layouts. */ view?: string | null; widgets: Widget[]; + /** What happens when the defined widgets exceed the capacity the cell-size + * band yields at the current viewport (ADR-0010). ``clip`` (default) leaves + * trailing widgets off-surface; ``shrink-to-fit`` allows cells below the + * band's floor so every widget fits. The one genuinely per-layout sizing + * knob — everything else about cell size is a client-side device pref. */ + overflow?: "clip" | "shrink-to-fit"; jogstrip_enabled: boolean; /** Human-readable name for the bottom-chrome app badge; falls back to * ``app`` (the raw match token) when null. Relayed opaquely by the diff --git a/client/src/reflow.test.ts b/client/src/reflow.test.ts new file mode 100644 index 0000000..21bead0 --- /dev/null +++ b/client/src/reflow.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { computeReflow } from "./reflow"; + +const TARGET = 96; +const GAP = 8; + +describe("computeReflow — clip", () => { + it("fits fewer columns as the viewport narrows", () => { + const narrow = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 800, totalUnits: 8, mode: "clip" }); + const wide = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 1000, containerHeight: 800, totalUnits: 8, mode: "clip" }); + expect(narrow.cols).toBe(2); // floor(308/104) = 2 + expect(wide.cols).toBeGreaterThan(narrow.cols); + }); + + it("always yields at least one column, even below the floor", () => { + const tiny = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 40, containerHeight: 800, totalUnits: 4, mode: "clip" }); + expect(tiny.cols).toBe(1); + }); + + it("cells grow when fewer columns fit (no explicit max cap)", () => { + const r = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 150, containerHeight: 800, totalUnits: 4, mode: "clip" }); + expect(r.cols).toBe(1); + expect(r.cellPx).toBe(150); + }); + + it("ignores height in clip mode", () => { + const short = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 50, totalUnits: 30, mode: "clip" }); + const tall = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 5000, totalUnits: 30, mode: "clip" }); + expect(short).toEqual(tall); + }); +}); + +describe("computeReflow — even-row scan", () => { + it("8 widgets at 5 target cols → scan to 4 (4×2)", () => { + // cellSize=72, width=394: floor(402/80)=5. 5→4 gives even 4×2, 5+3 is ragged. + const r = computeReflow({ cellSize: 72, gap: 8, containerWidth: 394, containerHeight: 800, totalUnits: 8, mode: "clip" }); + expect(r.cols).toBe(4); + }); + + it("8 widgets at 3 target cols → stays 3 (2 blocked by w/3 cap, scan only goes down)", () => { + // cellSize=100, portrait 360: target=3. c=2: perfect but 176px > w/3=120 → skip. + const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 360, containerHeight: 668, totalUnits: 8, mode: "clip" }); + expect(r.cols).toBe(3); + }); + + it("6 widgets at 4 target cols → scan to 3 (3×2)", () => { + // cellSize=72, width=314: floor(322/80)=4. 4→3 gives even 3×2. + const r = computeReflow({ cellSize: 72, gap: 8, containerWidth: 314, containerHeight: 800, totalUnits: 6, mode: "clip" }); + expect(r.cols).toBe(3); + }); + + it("7 widgets at 6 target cols → best candidate is 4 (4+3, not 6+1)", () => { + // cellSize=100, width=747: target=floor(755/108)=6. Score(6)=2 (ragged 6+1). + // Candidates: 5 (score 2), 4 (score 1: 3≥2 half-full, rows=2), 3 (score 2), + // 2 (score 1 but cellPx=370 > 200 cap). Best: 4. + const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 747, containerHeight: 300, totalUnits: 7, mode: "clip" }); + expect(r.cols).toBe(4); + }); + + it("7 widgets at 3 target cols → stays at 3 (2 would be 176px, w/3 cap blocks it)", () => { + const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 360, containerHeight: 800, totalUnits: 7, mode: "clip" }); + expect(r.cols).toBe(3); + }); +}); + +describe("computeReflow — shrink-to-fit", () => { + it("adds columns so all widgets fit a short viewport", () => { + const clip = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 120, totalUnits: 12, mode: "clip" }); + const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 120, totalUnits: 12, mode: "shrink-to-fit" }); + expect(fit.cols).toBeGreaterThan(clip.cols); + const rows = Math.ceil(12 / fit.cols); + expect(rows * fit.cellPx + (rows - 1) * GAP).toBeLessThanOrEqual(120 + 1e-6); + }); + + it("allows cells below the hard floor to fit everything", () => { + const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 90, totalUnits: 20, mode: "shrink-to-fit" }); + expect(fit.cellPx).toBeLessThan(TARGET); + expect(fit.cellPx).toBeGreaterThanOrEqual(16); + }); + + it("matches clip when the content already fits the height", () => { + const clip = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 2000, totalUnits: 6, mode: "clip" }); + const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 2000, totalUnits: 6, mode: "shrink-to-fit" }); + expect(fit).toEqual(clip); + }); +}); diff --git a/client/src/reflow.ts b/client/src/reflow.ts new file mode 100644 index 0000000..52515c0 --- /dev/null +++ b/client/src/reflow.ts @@ -0,0 +1,111 @@ +/** Ordered-list reflow geometry (ADR-0010). + * + * The grid has no authored shape: widgets pack in list order, left-to-right, + * wrapping down, and the client computes how many columns fit the available + * width against a client-side cell-size target. This module is the pure + * geometry — given a measured container and the target, it yields the column + * count and the resolved square cell size. ``ButtonGrid`` feeds it live + * measurements from a ``ResizeObserver`` and turns the result into + * ``grid-template-columns`` + a ``--cell-px`` content-sizing var. + * + * Kept side-effect-free (no DOM, no React) so the packing maths is unit + * testable in isolation. */ + +export type OverflowMode = "clip" | "shrink-to-fit"; + +export type ReflowInput = { + /** Inner width of the grid area, in CSS pixels. */ + containerWidth: number; + /** Inner height of the grid area, in CSS pixels. Only consulted for + * ``shrink-to-fit`` — ``clip`` never looks at height (it just clips). */ + containerHeight: number; + /** Target square cell edge (CSS px). Columns are packed so the resolved + * cell size stays near this value; exact-fit distributes leftover width + * evenly (no separate max/cap — more columns simply fit as width grows). */ + cellSize: number; + /** Gap between cells, in CSS pixels (matches the CSS ``gap``). */ + gap: number; + /** Total occupied cells, counting spans (sum of ``w*h`` over flow widgets). + * Used only by ``shrink-to-fit`` to estimate the row count. */ + totalUnits: number; + mode: OverflowMode; +}; + +export type ReflowResult = { + /** Number of columns to render (``grid-template-columns: repeat(cols, 1fr)``). */ + cols: number; + /** Resolved square cell edge in CSS pixels, for ``--cell-px`` content sizing. */ + cellPx: number; +}; + +/** Absolute floor for ``shrink-to-fit`` so a pathological layout can't drive + * cells to zero (or negative) size. */ +const HARD_FLOOR = 16; + +export function computeReflow(input: ReflowInput): ReflowResult { + const { containerWidth, containerHeight, cellSize, gap, totalUnits, mode } = input; + const w = Math.max(0, containerWidth); + const targetPlusGap = cellSize + gap; + + // Columns that fit at the target cell size. + const colsForWidth = (width: number) => Math.max(1, Math.floor((width + gap) / targetPlusGap)); + // Cell edge when ``cols`` columns share the width evenly (no leftover). + const cellForCols = (cols: number) => (w - (cols - 1) * gap) / cols; + + let cols = colsForWidth(w); + let cellPx = cellForCols(cols); + + // Reflow favours fewer columns (larger cells). Scan downward from the + // target — never upward, since the user asked for fewer columns — and + // pick the best: prefer perfectly even rows, then at-least-half-full + // rows, then fewest total rows. The dynamic max-px cap is tighter on + // narrow screens (prevents 2-col phone layouts) and looser on wide ones + // (allows 4-col reflow of 7 widgets on a 747px screen). + if (totalUnits > 0) { + const rowsFor = (c: number) => Math.ceil(totalUnits / c); + const fill = (c: number) => totalUnits % c || c; + const score = (c: number): number => { + if (totalUnits % c === 0) return 0; + if (fill(c) >= Math.ceil(c / 2)) return 1; + return 2; + }; + const maxPx = Math.min(w / 3, Math.max(cellSize * 1.5, 200)); + let best = cols; + let bestScore = score(cols); + let bestRows = rowsFor(cols); + for (let c = cols - 1; c >= 2; c--) { + if (cellForCols(c) > maxPx) continue; + const s = score(c); + if (s > bestScore) continue; + if (s < bestScore || rowsFor(c) < bestRows || (rowsFor(c) === bestRows && c < best)) { + best = c; bestScore = s; bestRows = rowsFor(c); + } + } + cols = best; + cellPx = cellForCols(cols); + } + + if (mode === "shrink-to-fit" && totalUnits > 0 && containerHeight > 0) { + const rowsFor = (c: number) => Math.ceil(totalUnits / c); + const fits = (c: number, px: number) => { + const rows = rowsFor(c); + return rows * px + (rows - 1) * gap <= containerHeight; + }; + // Add columns (which shrinks cells) until every widget fits the height, + // or everything is packed into a single row. + while (!fits(cols, cellPx) && cols < totalUnits) { + cols += 1; + cellPx = cellForCols(cols); + } + // Even packed as wide as it goes it still overflows the height: clamp the + // cell to the height budget so the last row is visible, honouring the + // hard floor. + if (!fits(cols, cellPx)) { + const rows = rowsFor(cols); + cellPx = Math.min(cellPx, (containerHeight - (rows - 1) * gap) / rows); + } + cellPx = Math.max(HARD_FLOOR, cellPx); + } + + return { cols, cellPx: Math.max(0, cellPx) }; +} diff --git a/client/src/settings-store.ts b/client/src/settings-store.ts index 8a6b7ce..0a1b534 100644 --- a/client/src/settings-store.ts +++ b/client/src/settings-store.ts @@ -17,6 +17,7 @@ const INVERT_KEY = "deckd.scrollInvert"; const PAD_SENS_KEY = "deckd.trackpadSensitivity"; const WAKE_LOCK_KEY = "deckd.wakeLock"; const CONTENT_SCALE_KEY = "deckd.contentScale"; +const CELL_SIZE_KEY = "deckd.cellSize"; const JOG_WIDTH_KEY = "deckd.jogWidth"; const BOTTOM_SCALE_KEY = "deckd.bottomScale"; const LABEL_SCALE_KEY = "deckd.labelScale"; @@ -31,10 +32,22 @@ export const SCROLL_SCALE_MIN = 1; export const SCROLL_SCALE_MAX = 20; export const SCROLL_SCALE_DEFAULT = 3; -// Multiplier applied to grid content (button icon + label, in-grid jogstrip) -// on top of the responsive base size, so the user can dial readability per -// device. 1.0 reproduces the base look; range/step match the other sliders' -// conventions. See issue #37 / ADR-0006 (client-side per-device visual prefs). +// Target cell size (ADR-0010): the square cell edge (CSS px) the grid packs +// columns around. Cells grow/shrink to fill the width evenly (no separate max +// cap — more columns simply fit as width grows, keeping the result near the +// target). A client-side per-device preference (ADR-0006, like content scale) +// — never authored in the layout YAML. Icon/label size derives from the +// resolved cell size via CSS container units. +export const CELL_SIZE_MIN = 64; +export const CELL_SIZE_MAX = 240; +export const CELL_SIZE_DEFAULT = 100; +export const CELL_SIZE_STEP = 4; + +// Secondary nudge applied on top of the cell-derived content size (issue #37): +// 1.0 leaves the derived look, and the user can bias icon/label a little +// smaller or larger per device without changing the cell band. Kept as its own +// knob so the two don't fight — the band sets cell (and hence content) size, +// this only trims it. See ADR-0006 (client-side per-device visual prefs). export const CONTENT_SCALE_MIN = 0.75; export const CONTENT_SCALE_MAX = 2.5; export const CONTENT_SCALE_STEP = 0.1; @@ -97,6 +110,15 @@ export function clampContentScale(n: number): number { return Math.round(clamped / CONTENT_SCALE_STEP) * CONTENT_SCALE_STEP; } +function roundToStep(n: number, step: number): number { + return Math.round(n / step) * step; +} + +export function clampCellSize(n: number): number { + if (!Number.isFinite(n)) return CELL_SIZE_DEFAULT; + return roundToStep(Math.max(CELL_SIZE_MIN, Math.min(CELL_SIZE_MAX, n)), CELL_SIZE_STEP); +} + export function clampJogWidth(n: number): number { if (!Number.isFinite(n)) return JOG_WIDTH_DEFAULT; const clamped = Math.max(JOG_WIDTH_MIN, Math.min(JOG_WIDTH_MAX, n)); @@ -175,6 +197,33 @@ function readInitialContentScale(): number { } +function readInitialCellSize(): number { + try { + const url = new URLSearchParams(window.location.search).get("cellSize"); + if (url !== null) return clampCellSize(Number(url)); + const stored = localStorage.getItem(CELL_SIZE_KEY); + if (stored !== null) return clampCellSize(Number(stored)); + } catch { + // see readInitialScale. + } + return CELL_SIZE_DEFAULT; +} + +/** The target cell size (ADR-0010): the square cell edge (CSS px) the grid + * packs columns around. Client-side per-device preference; drives the + * ``--cell-size`` CSS var and is fed to the reflow maths. */ +export function useCellSize() { + const [size, setSizeState] = useState(readInitialCellSize); + + const setSize = useCallback((n: number) => { + const clamped = clampCellSize(n); + setSizeState(clamped); + safeSet(CELL_SIZE_KEY, String(clamped)); + }, []); + + return { size, setSize }; +} + function readInitialJogWidth(): number { try { const url = new URLSearchParams(window.location.search).get("jogWidth"); diff --git a/client/src/style.css b/client/src/style.css index ce04d32..d610f3a 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -382,11 +382,20 @@ button:focus-visible { * Layout grid area (chrome-excluded) * ------------------------------------------------------------------------- */ +/* Ordered-list reflow grid (ADR-0010). ``grid-template-columns`` and + ``grid-auto-rows`` are set inline by ``ButtonGrid`` from the measured width + and the cell-size band: fixed square ``cellPx`` tracks. Leftover width is + centered (``justify-content``) and leftover height sits below the top- + aligned rows (``align-content: start``) — horizontal fill only, by choice. + ``overflow: hidden`` realises ``clip`` overflow: rows past the fold are cut. */ .grid { display: grid; gap: 8px; + justify-content: center; + align-content: start; width: 100%; height: 100%; + overflow: hidden; } .cell-empty { background: transparent; } @@ -400,7 +409,9 @@ button:focus-visible { align-items: center; justify-content: center; gap: 6px; - padding: 12px; + /* Relative padding so the content box tracks the cell as it flexes within + the band (a fixed 12px eats most of a small cell). */ + padding: 8%; user-select: none; -webkit-user-select: none; touch-action: none; @@ -408,8 +419,13 @@ button:focus-visible { transition: transform 80ms ease, background 80ms ease; min-height: 0; min-width: 0; - /* Clip cleanly: at a high content scale on a dense layout, scaled-up - content stays inside the button rather than overflowing neighbours. */ + /* Size container (ADR-0010): descendants size against the resolved cell via + ``cqmin`` units, so icon/label grow with the cell — the band is the + primary content-size knob and ``--content-scale`` is only a nudge. */ + container-type: size; + container-name: cell; + /* Clip cleanly: scaled-up content stays inside the button rather than + overflowing neighbours. */ overflow: hidden; } @@ -421,10 +437,11 @@ button:focus-visible { } .cell-button .label { - /* Label text scales with the overall content size and, on top of that, the - user's Text-size preference (``--label-scale``, default 1) so the caption - under the icon can be dialled down without shrinking the icon. */ - font-size: calc(clamp(9px, 1.5vw, 12px) * var(--content-scale) * var(--label-scale, 1)); + /* Label text derives from the resolved cell size (``cqmin``) so it grows + with the cell, then the user's Content-nudge (``--content-scale``) and + Text-size (``--label-scale``) preferences trim it. Bounded so it stays + legible on tiny cells and doesn't dominate huge ones. */ + font-size: calc(clamp(8px, 15cqmin, 15px) * var(--content-scale) * var(--label-scale, 1)); font-weight: 600; line-height: 1.2; word-break: break-word; @@ -434,8 +451,8 @@ button:focus-visible { /* The key combo a button sends, shown under the label when the user enables key hints. Deliberately smaller and dimmer than the label so it reads as secondary metadata; monospace so combos line up legibly. - Scales with the same content/label preferences as the label. */ - font-size: calc(clamp(7px, 1.2vw, 10px) * var(--content-scale) * var(--label-scale, 1)); + Same cell-derived sizing as the label, one step smaller. */ + font-size: calc(clamp(7px, 11cqmin, 11px) * var(--content-scale) * var(--label-scale, 1)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 500; line-height: 1.1; @@ -446,11 +463,11 @@ button:focus-visible { /* Icon glyph (Lucide / Simple Icons), rendered above the label. The glyph is an ```` itself, so size it directly — one fixed box for every source regardless of the set's intrinsic viewBox, so icons read at a - single consistent size. Inherits the fixed light foreground via - ``currentColor``. */ + single consistent size. Derives from the cell (``cqmin``) so it scales with + the band; the Content-nudge trims it. Inherits ``currentColor``. */ .cell-button .icon { - width: calc(clamp(22px, 6vw, 34px) * var(--content-scale)); - height: calc(clamp(22px, 6vw, 34px) * var(--content-scale)); + width: calc(clamp(14px, 34cqmin, 44px) * var(--content-scale)); + height: calc(clamp(14px, 34cqmin, 44px) * var(--content-scale)); color: currentColor; } @@ -460,7 +477,7 @@ button:focus-visible { display: inline-flex; align-items: center; justify-content: center; - font-size: calc(clamp(20px, 5.5vw, 30px) * var(--content-scale)); + font-size: calc(clamp(14px, 34cqmin, 40px) * var(--content-scale)); opacity: 0.45; } diff --git a/daemon/deckd/diagnostics.py b/daemon/deckd/diagnostics.py index 9183a00..9c4c6d7 100644 --- a/daemon/deckd/diagnostics.py +++ b/daemon/deckd/diagnostics.py @@ -618,7 +618,9 @@ def _safe_widget(widget: Any) -> dict[str, Any]: "id": widget.id, "kind": widget.kind, "label": widget.label, - "grid": list(widget.grid), + # Reflow extent (ADR-0010): a ``[w, h]`` span, ``"full"``, or ``None`` + # for a default 1x1 cell. There is no position — widgets pack in order. + "size": widget.size, "has_action": widget.action is not None, "kind_specific": _widget_kind_specific(widget), } diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 5f0178a..1aacb5c 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -88,7 +88,13 @@ class Widget(BaseModel): kind: str label: str | None = None icon: Icon | None = None - grid: list[int] = Field(min_length=4, max_length=4) + # Reflow extent (ADR-0010). ``[w, h]`` is a column/row span; the literal + # ``"full"`` opts the widget out of the flow to take the whole surface. + # There is no position — widgets pack in list order, left-to-right, + # wrapping down against a client-side cell-size band. Absent means a + # ``[1, 1]`` single cell. The old ``grid: [x, y, w, h]`` coordinate field + # is gone; migrate by dropping ``x, y`` and keeping ``w, h`` as ``size``. + size: list[int] | Literal["full"] | None = None # Optional CSS colour string applied as the button's background. Any # value the browser accepts is fine ("#1e3a8a", "rebeccapurple", # "hsl(...)"). Client trust: layouts are user-owned config, not user @@ -154,6 +160,21 @@ def _validate_media_controls(cls, v: list[MediaControl] | None) -> list[MediaCon raise ValueError("media controls must not contain duplicates") return v + @field_validator("size") + @classmethod + def _validate_size(cls, v: object) -> object: + # ``"full"`` and absent are fine; a span must be exactly two positive + # ints (columns, rows). Guard the shape here so a bad ``size: [0, 2]`` + # or ``size: [1, 2, 3]`` fails at load with a clear message rather than + # silently producing a zero-span cell in the client. + if v is None or v == "full": + return v + if not isinstance(v, list) or len(v) != 2: + raise ValueError("size span must be a [columns, rows] pair, or the literal \"full\"") + if any(not isinstance(n, int) or n < 1 for n in v): + raise ValueError(f"size span values must be positive integers; got {v!r}") + return v + @field_validator("art_source") @classmethod def _validate_art_source(cls, v: list[str] | None) -> list[str] | None: @@ -179,6 +200,27 @@ def _validate_media_invariants(self) -> "Widget": mediabrowser_fields = { "empty_state": self.empty_state, } + if self.kind == "blank": + # A ``blank`` is a deliberate gap in the reflow (ADR-0010): it + # only holds space, honouring an optional ``size`` span. Anything + # that would make it interactive or content-bearing is a mistake, + # so reject label/icon/color/action and every widget-specific + # field rather than silently ignoring them. + forbidden = { + "label": self.label, + "icon": self.icon, + "color": self.color, + "action": self.action, + "macro": self.macro, + "source": self.source, + "metrics": self.metrics, + **media_fields, + **mediabrowser_fields, + } + invalid = sorted(name for name, value in forbidden.items() if value is not None) + if invalid: + raise ValueError(f"blank widgets take only 'size'; got: {', '.join(invalid)}") + return self if self.kind == "media" and self.controls is None: self.controls = ["play", "volume", "position"] if self.kind == "mediabrowser": @@ -278,6 +320,12 @@ class Layout(BaseModel): id: str = "" match: list[str] = Field(default_factory=list) widgets: list[Widget] = Field(default_factory=list) + # What happens when the widgets exceed the capacity the client's cell-size + # band yields at the current viewport (ADR-0010). ``clip`` leaves trailing + # widgets off-surface; ``shrink-to-fit`` lets cells drop below the band's + # floor so all widgets fit. The one genuinely per-layout sizing knob — + # every other cell-size concern is a client-side device preference. + overflow: Literal["clip", "shrink-to-fit"] = "shrink-to-fit" jogstrip: bool = True # Chrome app-identity presentation relayed opaquely to the client # (ADR-0007). The client renders these in the always-on bottom strip: diff --git a/daemon/deckd/mpris.py b/daemon/deckd/mpris.py index 46bc475..ea5b589 100644 --- a/daemon/deckd/mpris.py +++ b/daemon/deckd/mpris.py @@ -3,9 +3,9 @@ import dataclasses import logging from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Any, Callable, Protocol +from typing import TYPE_CHECKING, Any, Callable, Literal, Protocol -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from .layouts import MediaBrowserEmptyState from .media import MediaState, _art_token @@ -162,8 +162,9 @@ class MediaBrowser(BaseModel): - ``id``: the widget id (used as the layout-internal id and surfaced to the client so it can correlate per-row updates). - - ``grid``: the standard 4-int grid placement, identical to every - other widget kind. + - ``size``: the standard reflow extent, identical to every other widget + kind (ADR-0010) — a ``[w, h]`` span or ``"full"``. Optional; a + mediabrowser is typically a full-surface view rendered outside the flow. - ``empty_state``: whether the cell still renders a placeholder row when no MPRIS player is discovered. ``show`` (default) keeps the chrome's icon reachable; ``hide`` collapses the cell so a layout @@ -178,7 +179,7 @@ class MediaBrowser(BaseModel): model_config = ConfigDict(extra="forbid") id: str - grid: list[int] = Field(min_length=4, max_length=4) + size: list[int] | Literal["full"] | None = None empty_state: MediaBrowserEmptyState = "show" diff --git a/daemon/deckd/protocol.py b/daemon/deckd/protocol.py index bb2f988..40f9277 100644 --- a/daemon/deckd/protocol.py +++ b/daemon/deckd/protocol.py @@ -14,6 +14,10 @@ class LayoutMessage(BaseModel): app: str = "default" view: str | None = None widgets: list[dict] + # Overflow behaviour for the client's reflow (ADR-0010): ``clip`` drops + # trailing widgets off-surface, ``shrink-to-fit`` shrinks cells below the + # band floor so all fit. Relayed from the layout's ``overflow`` field. + overflow: Literal["clip", "shrink-to-fit"] = "shrink-to-fit" jogstrip_enabled: bool = True # Chrome app badge (ADR-0007), relayed opaquely. The client renders a # branded pill in the always-on bottom strip from these three: diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 48c35d7..02faa91 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -397,6 +397,7 @@ async def push_current(self) -> None: type="layout", app=app_id, view=view_id, + overflow=layout.overflow, jogstrip_enabled=layout.jogstrip, display_name=layout.display_name, theme=layout.theme, @@ -411,6 +412,7 @@ async def push_current(self) -> None: type="layout", app=app_id, view=view_id, + overflow=layout.overflow, jogstrip_enabled=layout.jogstrip, display_name=layout.display_name, theme=layout.theme, diff --git a/docs/adr/0010-grid-reflow.md b/docs/adr/0010-grid-reflow.md index 36a06e7..bfd0695 100644 --- a/docs/adr/0010-grid-reflow.md +++ b/docs/adr/0010-grid-reflow.md @@ -1,6 +1,6 @@ # Grid layout: ordered-list reflow with a banded cell size -**Supersedes [ADR-0004](0004-orientation-scaling.md).** Tracked in issue #92; not yet implemented at time of writing (the code still stores coordinates and transposes). +**Supersedes [ADR-0004](0004-orientation-scaling.md).** Tracked in issue #92; **implemented** — the `Widget` schema carries an ordered list with an optional `size` span (no coordinates), the client reflows against a client-side cell-size band, and the transpose is gone. ADR-0004 authored layouts as a fixed grid of absolute `[x, y, w, h]` coordinates and handled portrait by transposing them diagonally. That model assumes a grid whose shape is known when the layout is written — the Stream Deck premise, where the hardware *is* the grid. deckd's "deck" is an arbitrary browser viewport: a phone, a tablet, a laptop window being dragged narrower, a super-wide-but-short panel. There is no fixed grid shape to author against, so absolute coordinates are the wrong vocabulary. This ADR replaces them. @@ -30,8 +30,8 @@ Crucially, the band is a **client-side per-device preference ([ADR-0006](0006-wi When the defined widgets exceed the capacity the band yields at the current viewport, the behaviour is: -- **clip** (default) — trailing widgets are off-surface and currently inaccessible. Ten buttons on a surface that fits eight means the last two cannot be reached. **No pagination in v1** (a candidate follow-on). -- **shrink-to-fit** — allow cells below `MIN` so every widget fits. +- **shrink-to-fit** (default) — cells may shrink below the readability floor (respecting a hard 16 px floor) so every widget stays on the same surface. When the total widget set already fits at the floor size, the layout behaves exactly like `clip` (the floor is the same). +- **clip** — trailing widgets are off-surface and currently inaccessible. Ten buttons on a surface that fits eight means the last two cannot be reached. **No pagination in v1** (a candidate follow-on). This is layout-semantic rather than device-ergonomic, so it may be a layout property (with a global default). It is the only sizing choice the layout author owns. diff --git a/layouts.macos/default.yaml b/layouts.macos/default.yaml index cd764cf..fbca02a 100644 --- a/layouts.macos/default.yaml +++ b/layouts.macos/default.yaml @@ -11,7 +11,6 @@ widgets: source: lucide name: file-code color: "#2e328a" - grid: [0, 0, 1, 1] action: shell: code - id: firefox @@ -20,7 +19,6 @@ widgets: source: simple-icons name: firefox color: "#b5641d" - grid: [1, 0, 1, 1] action: shell: "open -a Firefox" - id: obsidian @@ -29,7 +27,6 @@ widgets: source: simple-icons name: obsidian color: "#6d28c8" - grid: [2, 0, 1, 1] action: shell: "open -a Obsidian" - id: thunderbird @@ -38,7 +35,6 @@ widgets: source: simple-icons name: thunderbird color: "#0A84FF" - grid: [3, 0, 1, 1] action: shell: "open -a Thunderbird" @@ -48,7 +44,6 @@ widgets: source: simple-icons name: github color: "black" - grid: [0, 1, 1, 1] action: shell: "open https://github.com" @@ -58,7 +53,6 @@ widgets: source: lucide name: square-terminal color: "#6b0101" - grid: [1, 1, 1, 1] action: # terminal: true shell: "open -a iTerm" @@ -69,14 +63,12 @@ widgets: icon: source: lucide name: keyboard - grid: [2, 1, 1, 1] action: key: "super+t" - id: system kind: stats label: System - grid: [3, 1, 1, 1] metrics: - source: cpu_percent label: CPU diff --git a/layouts.macos/firefox.yaml b/layouts.macos/firefox.yaml index 8c41142..247f374 100644 --- a/layouts.macos/firefox.yaml +++ b/layouts.macos/firefox.yaml @@ -9,6 +9,8 @@ theme: "#ff7139" icon: source: simple-icons name: firefox +# Widgets reflow in list order (ADR-0010): navigation first, then tab/window/ +# find/url on the next row. widgets: - id: back kind: button @@ -16,7 +18,6 @@ widgets: source: lucide name: arrow-left color: "#1e3a8a" - grid: [0, 0, 1, 1] action: key: "super+[" - id: forward @@ -25,58 +26,51 @@ widgets: source: lucide name: arrow-right color: "#1e3a8a" - grid: [1, 0, 1, 1] action: key: "super+]" - - id: new-tab + - id: reload kind: button - label: New tab icon: source: lucide - name: plus - grid: [0, 1, 1, 1] + name: refresh-cw action: - key: "super+t" - - id: new-window + key: "super+r" + - id: close-tab kind: button - label: New window icon: source: lucide - name: app-window - grid: [1, 1, 1, 1] + name: x action: - key: "super+n" - - id: reload + key: "super+w" + - id: new-tab kind: button + label: New tab icon: source: lucide - name: refresh-cw - grid: [2, 0, 1, 1] + name: plus action: - key: "super+r" - - id: focus-url + key: "super+t" + - id: new-window kind: button - label: URL bar + label: New window icon: source: lucide - name: link - grid: [3, 1, 1, 1] + name: app-window action: - key: "super+l" + key: "super+n" - id: find kind: button label: Find icon: source: lucide name: search - grid: [2, 1, 1, 1] action: key: "super+f" - - id: close-tab + - id: focus-url kind: button + label: URL bar icon: source: lucide - name: x - grid: [3, 0, 1, 1] + name: link action: - key: "super+w" \ No newline at end of file + key: "super+l" diff --git a/layouts/default.yaml b/layouts/default.yaml index 6c7b456..b6b0788 100644 --- a/layouts/default.yaml +++ b/layouts/default.yaml @@ -9,7 +9,6 @@ widgets: source: lucide name: file-code color: "#2e328a" - grid: [0, 0, 1, 1] action: shell: code - id: firefox @@ -19,7 +18,6 @@ widgets: source: simple-icons name: firefox color: "#b5641d" - grid: [1, 0, 1, 1] action: shell: "firefox" - id: obsidian @@ -29,7 +27,6 @@ widgets: source: simple-icons name: obsidian color: "#6d28c8" - grid: [2, 0, 1, 1] action: shell: "md.obsidian.Obsidian" - id: thunderbird @@ -39,7 +36,6 @@ widgets: source: simple-icons name: thunderbird color: "#0A84FF" - grid: [3, 0, 1, 1] action: shell: "Thunderbird" @@ -50,7 +46,6 @@ widgets: source: simple-icons name: github color: "black" - grid: [0, 1, 1, 1] action: url: "https://github.com" @@ -61,7 +56,6 @@ widgets: source: lucide name: square-terminal color: "#6b0101" - grid: [1, 1, 1, 1] action: # Open the auto-detected terminal ($TERMINAL, then a candidate list). # For a specific emulator, use e.g. shell: "tilix" instead. @@ -73,22 +67,36 @@ widgets: icon: source: lucide name: keyboard - grid: [2, 1, 1, 1] action: key: "ctrl+t" - # Audio / VLC media control. Commented out — the cell is now home - # to the live CPU meter (the audio use case is rare from a phone - # in practice, and the daemon already sends widget_update frames - # for it). Re-enable by removing the leading ``#``s; the grid slot - # below is intentionally vacant so adding it back doesn't shift - # neighbouring widgets. + # Live system stats (issue #40). A bar-less ``stats`` cell showing + # several psutil-backed sensor values at once — CPU + memory here — so + # they work identically on Linux + macOS Intel + Apple Silicon with no + # per-platform install step. The daemon pushes ``widget_update`` frames + # at each source's poll cadence (1s). Easy to grow: add another entry to + # ``metrics`` (each names a daemon-side source; ``label`` is optional and + # defaults to the source name, e.g. cpu_percent -> CPU). For a single + # value with a proportional bar instead, use a ``kind: meter`` widget + # (see README) bound to one ``source``. + - id: system + kind: stats + label: System + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent + label: MEM + # Audio / VLC media control. Commented out — the cell is now home to the + # live CPU stats above (the audio use case is rare from a phone in practice, + # and the daemon already sends widget_update frames for it). Re-enable by + # removing the leading ``#``s; widgets reflow in list order (ADR-0010), so + # it drops in wherever you place it in the sequence. # - id: audio-toggle # kind: button # label: VLC Play/Pause # icon: # source: lucide # name: play - # grid: [3, 2, 1, 1] # action: # dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" # Macro example (issue #68): chains a shell notification with a @@ -101,7 +109,7 @@ widgets: icon: source: lucide name: list-ordered - grid: [0, 2, 2, 1] + size: [2, 1] macro: steps: - type: shell @@ -110,21 +118,3 @@ widgets: value: "500" - type: key value: "ctrl+t" - # Live system stats (issue #40). A bar-less ``stats`` cell showing - # several psutil-backed sensor values at once — CPU + memory here — so - # they work identically on Linux + macOS Intel + Apple Silicon with no - # per-platform install step. The daemon pushes ``widget_update`` frames - # at each source's poll cadence (1s). Easy to grow: add another entry to - # ``metrics`` (each names a daemon-side source; ``label`` is optional and - # defaults to the source name, e.g. cpu_percent -> CPU). For a single - # value with a proportional bar instead, use a ``kind: meter`` widget - # (see README) bound to one ``source``. - - id: system - kind: stats - label: System - grid: [3, 1, 1, 1] - metrics: - - source: cpu_percent - label: CPU - - source: mem_percent - label: MEM diff --git a/layouts/firefox.yaml b/layouts/firefox.yaml index a8ad612..a1e6414 100644 --- a/layouts/firefox.yaml +++ b/layouts/firefox.yaml @@ -5,6 +5,8 @@ theme: "#ff7139" icon: source: simple-icons name: firefox +# Widgets reflow in list order (ADR-0010): row-major sequence, wrapping to the +# viewport. Navigation on the first row, tab/window/find/url on the second. widgets: - id: back kind: button @@ -13,7 +15,6 @@ widgets: source: lucide name: arrow-left color: "#1e3a8a" - grid: [0, 0, 1, 1] action: key: "alt+Left" - id: forward @@ -23,60 +24,53 @@ widgets: source: lucide name: arrow-right color: "#1e3a8a" - grid: [1, 0, 1, 1] action: key: "alt+Right" - - id: new-tab + - id: reload kind: button - label: New tab + # label: Reload icon: source: lucide - name: plus - grid: [0, 1, 1, 1] + name: refresh-cw action: - key: "ctrl+t" - - id: new-window + key: "ctrl+r" + - id: close-tab kind: button - label: New window + # label: Close tab icon: source: lucide - name: app-window - grid: [1, 1, 1, 1] + name: x action: - key: "ctrl+n" - - id: reload + key: "ctrl+w" + - id: new-tab kind: button - # label: Reload + label: New tab icon: source: lucide - name: refresh-cw - grid: [2, 0, 1, 1] + name: plus action: - key: "ctrl+r" - - id: focus-url + key: "ctrl+t" + - id: new-window kind: button - label: URL bar + label: New window icon: source: lucide - name: link - grid: [3, 1, 1, 1] + name: app-window action: - key: "ctrl+l" + key: "ctrl+n" - id: find kind: button label: Find icon: source: lucide name: search - grid: [2, 1, 1, 1] action: key: "ctrl+f" - - id: close-tab + - id: focus-url kind: button - # label: Close tab + label: URL bar icon: source: lucide - name: x - grid: [3, 0, 1, 1] + name: link action: - key: "ctrl+w" + key: "ctrl+l" diff --git a/layouts/google-meet.yaml b/layouts/google-meet.yaml index b283600..3166f3d 100644 --- a/layouts/google-meet.yaml +++ b/layouts/google-meet.yaml @@ -28,7 +28,6 @@ widgets: source: lucide name: mic color: "#00897b" - grid: [0, 0, 1, 1] action: key: "ctrl+d" - id: toggle-camera @@ -37,7 +36,6 @@ widgets: icon: source: lucide name: video - grid: [1, 0, 1, 1] action: key: "ctrl+e" - id: raise-hand @@ -46,7 +44,6 @@ widgets: icon: source: lucide name: hand - grid: [2, 0, 1, 1] action: key: "ctrl+alt+h" - id: toggle-chat @@ -55,7 +52,6 @@ widgets: icon: source: lucide name: message-square - grid: [0, 1, 1, 1] action: key: "ctrl+alt+c" - id: toggle-participants @@ -64,7 +60,6 @@ widgets: icon: source: lucide name: users - grid: [1, 1, 1, 1] action: key: "ctrl+alt+p" - id: toggle-captions @@ -73,6 +68,5 @@ widgets: icon: source: lucide name: captions - grid: [2, 1, 1, 1] action: key: "c" diff --git a/layouts/mpris.yaml b/layouts/mpris.yaml index 22f6611..05d46bb 100644 --- a/layouts/mpris.yaml +++ b/layouts/mpris.yaml @@ -18,4 +18,4 @@ display_name: MPRIS widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] diff --git a/layouts/musicca.yaml b/layouts/musicca.yaml index c4aa615..c9f60ee 100644 --- a/layouts/musicca.yaml +++ b/layouts/musicca.yaml @@ -13,6 +13,16 @@ # # Range: one octave F3-F4, the block verified against the live site. The lower # octave (z/x/c... rows) can be added once its keys are confirmed the same way. +# +# KNOWN LIMITATION (ADR-0010): this layout wants a piano's 2D alignment — black +# keys sitting *above* the white keys they belong between. The old coordinate +# grid guaranteed that; reflow does not — the column count now follows the +# viewport width, so the two rows only line up when the grid happens to render +# at 8 columns. The keys still all work (they are plain `key:` actions); only +# the visual black/white registration is best-effort. A `blank` holds the +# no-black-key gap between B and C so the F#/G#/A# and C#/D# clusters stay +# grouped in sequence. Faithful piano rendering likely needs a positioned / +# full-surface widget — tracked as follow-on, not solved here. match: - "title:*Musicca*" # piano, synthesizer, and any Musicca keyboard page # - "title:*Virtual Piano*" # example: extend to another site with the same mapping @@ -28,71 +38,60 @@ widgets: kind: button label: "F#" color: "#1a1a1a" - grid: [0, 0, 1, 1] action: { key: "2" } - id: gs3 kind: button label: "G#" color: "#1a1a1a" - grid: [1, 0, 1, 1] action: { key: "3" } - id: as3 kind: button label: "A#" color: "#1a1a1a" - grid: [2, 0, 1, 1] action: { key: "4" } - # gap at x=3 (no black key between B and C) + # No black key between B and C — a blank keeps the two clusters grouped. + - id: gap-b-c + kind: blank - id: cs4 kind: button label: "C#" color: "#1a1a1a" - grid: [4, 0, 1, 1] action: { key: "6" } - id: ds4 kind: button label: "D#" color: "#1a1a1a" - grid: [5, 0, 1, 1] action: { key: "7" } # --- White keys (bottom row) F3 G3 A3 B3 C4 D4 E4 F4 --- - id: f3 kind: button label: "F" - grid: [0, 1, 1, 1] action: { key: "q" } - id: g3 kind: button label: "G" - grid: [1, 1, 1, 1] action: { key: "w" } - id: a3 kind: button label: "A" - grid: [2, 1, 1, 1] action: { key: "e" } - id: b3 kind: button label: "B" - grid: [3, 1, 1, 1] action: { key: "r" } - id: c4 kind: button label: "C" - grid: [4, 1, 1, 1] action: { key: "t" } - id: d4 kind: button label: "D" - grid: [5, 1, 1, 1] action: { key: "y" } - id: e4 kind: button label: "E" - grid: [6, 1, 1, 1] action: { key: "u" } - id: f4 kind: button label: "F" - grid: [7, 1, 1, 1] action: { key: "i" } diff --git a/layouts/netflix.yaml b/layouts/netflix.yaml index 56bcddf..04644af 100644 --- a/layouts/netflix.yaml +++ b/layouts/netflix.yaml @@ -23,7 +23,6 @@ widgets: source: lucide name: play color: "#e50914" - grid: [0, 0, 1, 1] action: key: "space" - id: mute @@ -32,7 +31,6 @@ widgets: icon: source: lucide name: volume-x - grid: [1, 0, 1, 1] action: key: "m" - id: fullscreen @@ -41,7 +39,6 @@ widgets: icon: source: lucide name: maximize - grid: [2, 0, 1, 1] action: key: "f" - id: back-10 @@ -50,7 +47,6 @@ widgets: icon: source: lucide name: rewind - grid: [0, 1, 1, 1] action: key: "left" - id: fwd-10 @@ -59,7 +55,6 @@ widgets: icon: source: lucide name: fast-forward - grid: [1, 1, 1, 1] action: key: "right" - id: skip-intro @@ -68,6 +63,5 @@ widgets: icon: source: lucide name: skip-forward - grid: [2, 1, 1, 1] action: key: "s" diff --git a/layouts/terminal.yaml b/layouts/terminal.yaml index 92c4412..b2d2022 100644 --- a/layouts/terminal.yaml +++ b/layouts/terminal.yaml @@ -17,7 +17,6 @@ widgets: icon: source: lucide name: clipboard - grid: [0, 0, 1, 1] action: key: "ctrl+shift+c" - id: paste @@ -26,7 +25,6 @@ widgets: icon: source: lucide name: clipboard-paste - grid: [1, 0, 1, 1] action: key: "ctrl+shift+v" - id: new-tab @@ -35,7 +33,6 @@ widgets: icon: source: lucide name: plus - grid: [2, 0, 1, 1] action: key: "ctrl+shift+t" - id: find @@ -44,6 +41,5 @@ widgets: icon: source: lucide name: search - grid: [3, 0, 1, 1] action: key: "ctrl+shift+f" diff --git a/layouts/tilix.yaml b/layouts/tilix.yaml index 8dfe1e4..7820d81 100644 --- a/layouts/tilix.yaml +++ b/layouts/tilix.yaml @@ -13,7 +13,6 @@ widgets: icon: source: lucide name: columns-2 - grid: [0, 0, 1, 1] action: key: "ctrl+alt+r" - id: split-down @@ -22,7 +21,6 @@ widgets: icon: source: lucide name: rows-2 - grid: [1, 0, 1, 1] action: key: "ctrl+alt+d" - id: new-window @@ -31,7 +29,6 @@ widgets: icon: source: lucide name: app-window - grid: [2, 0, 1, 1] action: key: "ctrl+shift+n" - id: next-terminal @@ -40,7 +37,6 @@ widgets: icon: source: lucide name: arrow-right - grid: [3, 0, 1, 1] action: key: "ctrl+tab" - id: close-terminal @@ -49,7 +45,6 @@ widgets: icon: source: lucide name: x - grid: [0, 1, 1, 1] action: key: "ctrl+shift+w" - id: find @@ -58,6 +53,5 @@ widgets: icon: source: lucide name: search - grid: [1, 1, 1, 1] action: key: "ctrl+shift+f" diff --git a/layouts/vlc.yaml b/layouts/vlc.yaml index 934a01d..8a98bb5 100644 --- a/layouts/vlc.yaml +++ b/layouts/vlc.yaml @@ -6,7 +6,7 @@ widgets: - id: vlc-media kind: media label: VLC - grid: [0, 0, 4, 2] + size: [4, 2] controls: [play, previous, next, volume, position, speed] # Album art sources, in order. 'vlc' uses VLC's own art (embedded tags / # its cache); 'itunes' falls back to an online cover-art lookup when VLC diff --git a/layouts/youtube.yaml b/layouts/youtube.yaml index 46a4af0..e54dd7f 100644 --- a/layouts/youtube.yaml +++ b/layouts/youtube.yaml @@ -21,7 +21,6 @@ widgets: source: lucide name: play color: "#ff0000" - grid: [0, 0, 1, 1] action: key: "k" - id: mute @@ -30,7 +29,6 @@ widgets: icon: source: lucide name: volume-x - grid: [1, 0, 1, 1] action: key: "m" - id: fullscreen @@ -39,7 +37,6 @@ widgets: icon: source: lucide name: maximize - grid: [2, 0, 1, 1] action: key: "f" - id: back-10 @@ -48,7 +45,6 @@ widgets: icon: source: lucide name: rewind - grid: [0, 1, 1, 1] action: key: "j" - id: fwd-10 @@ -57,7 +53,6 @@ widgets: icon: source: lucide name: fast-forward - grid: [1, 1, 1, 1] action: key: "l" - id: captions @@ -66,6 +61,5 @@ widgets: icon: source: lucide name: captions - grid: [2, 1, 1, 1] action: key: "c" diff --git a/tests/fixtures/layouts/default.yaml b/tests/fixtures/layouts/default.yaml index 96c801c..ac3ad45 100644 --- a/tests/fixtures/layouts/default.yaml +++ b/tests/fixtures/layouts/default.yaml @@ -14,40 +14,36 @@ widgets: - id: open-url kind: button label: Open URL - grid: [0, 0, 1, 1] action: shell: "xdg-open https://example.com" - id: open-terminal kind: button label: Terminal - grid: [1, 0, 1, 1] action: terminal: true - id: send-key kind: button label: Send Ctrl+T - grid: [2, 0, 1, 1] action: key: "ctrl+t" - id: audio-toggle kind: button label: VLC Play/Pause - grid: [3, 0, 1, 1] action: dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" - - id: scroll-strip - kind: jogstrip - grid: [0, 2, 4, 1] - id: press-url kind: button label: Press URL - grid: [0, 1, 1, 1] action: url: "https://example.com/hello?q=1" - id: press-text kind: button label: Press Text - grid: [1, 1, 1, 1] action: text: "hello" text_mode: simulate + # Full-width scroll strip: a [4, 1] span won't fit after the two buttons on + # its row, so strict-order reflow (ADR-0010) wraps it to a row of its own. + - id: scroll-strip + kind: jogstrip + size: [4, 1] diff --git a/tests/fixtures/layouts/firefox.yaml b/tests/fixtures/layouts/firefox.yaml index 87166ee..96490e7 100644 --- a/tests/fixtures/layouts/firefox.yaml +++ b/tests/fixtures/layouts/firefox.yaml @@ -12,6 +12,5 @@ widgets: - id: back kind: button label: Back - grid: [0, 0, 1, 1] action: key: "alt+Left" diff --git a/tests/test_actions.py b/tests/test_actions.py index 025a2e9..ae7f4c4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -34,7 +34,6 @@ def _widget(dbus_value: str) -> Widget: id="dbus-btn", kind="button", label="dbus", - grid=[0, 0, 1, 1], action=Action(dbus=dbus_value), ) @@ -240,7 +239,6 @@ async def fake_exec(*args, **kwargs): widget = Widget( id="url-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(url="https://example.com/path?q=1&frag=#top"), ) await run_action(widget, _ctx_key_sink(FakePointerSink())) @@ -272,7 +270,6 @@ def fake_which(name: str) -> str | None: widget = Widget( id="url-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(url="https://example.com"), ) await run_action(widget, _ctx_key_sink(FakePointerSink())) @@ -305,7 +302,6 @@ async def test_press_text_simulate_types_chars() -> None: widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="hello", text_mode="simulate"), ) await run_action(widget, _ctx_key_sink(key_sink)) @@ -326,7 +322,6 @@ async def test_press_text_simulate_defaults_to_simulate() -> None: widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="ab"), ) await run_action(widget, _ctx_key_sink(key_sink)) @@ -353,7 +348,6 @@ async def fake_paste(text, ctx, restore, delay_ms=1000): widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="a🎉b"), ) with caplog.at_level(logging.WARNING, logger="deckd.actions"): @@ -371,7 +365,6 @@ async def test_press_text_explicit_simulate_drops_unknown_chars() -> None: widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="aéb", text_mode="simulate"), ) await run_action(widget, _ctx_key_sink(key_sink)) @@ -431,7 +424,6 @@ def fake_which(name: str) -> str | None: widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="hello world", text_mode="paste", restore_clipboard=True), ) await run_action(widget, _ctx_key_sink(key_sink)) @@ -456,7 +448,6 @@ async def test_press_text_paste_no_clipboard_tool_falls_back_to_simulate( widget = Widget( id="text-btn", kind="button", - grid=[0, 0, 1, 1], action=Action(text="ab", text_mode="paste"), ) await run_action(widget, _ctx_key_sink(key_sink)) diff --git a/tests/test_chrome_media_websocket.py b/tests/test_chrome_media_websocket.py index e41104c..6b39869 100644 --- a/tests/test_chrome_media_websocket.py +++ b/tests/test_chrome_media_websocket.py @@ -51,7 +51,7 @@ async def _boot_chrome_media_websocket( widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) server, *_ = make_test_server( @@ -288,7 +288,7 @@ async def test_chrome_media_emits_when_no_mediabrowser_widget_mounted( widgets: - id: pad kind: trackpad - grid: [0, 0, 4, 2] + size: [4, 2] """ ) (tmp_path / "mpris.yaml").write_text( @@ -297,7 +297,7 @@ async def test_chrome_media_emits_when_no_mediabrowser_widget_mounted( widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) server, *_ = make_test_server( diff --git a/tests/test_diag_endpoints.py b/tests/test_diag_endpoints.py index 41154d2..fcd5f93 100644 --- a/tests/test_diag_endpoints.py +++ b/tests/test_diag_endpoints.py @@ -270,7 +270,7 @@ async def test_mpris_players_with_fake_backend(monkeypatch, tmp_path) -> None: widgets: - id: mpris kind: mediabrowser - grid: [0, 0, 4, 1] + size: [4, 1] """ ) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 275b0de..871018e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -163,13 +163,11 @@ def test_build_layouts_snapshot_hides_action_bodies() -> None: Widget( id="button-1", kind="button", - grid=[0, 0, 1, 1], action=Action(shell="xdg-open https://secret.example"), ), Widget( id="meter-1", kind="meter", - grid=[1, 0, 1, 1], source="cpu_percent", ), ], diff --git a/tests/test_focus.py b/tests/test_focus.py index 44fd19e..69091ae 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -35,13 +35,11 @@ - id: back kind: button label: Back - grid: [0, 0, 1, 1] action: key: "alt+Left" - id: forward kind: button label: Forward - grid: [1, 0, 1, 1] action: key: "alt+Right" """ @@ -53,7 +51,6 @@ - id: new-tab kind: button label: New tab - grid: [0, 0, 1, 1] action: key: "ctrl+shift+t" """ @@ -65,7 +62,6 @@ - id: home kind: button label: Home - grid: [0, 0, 1, 1] action: shell: "xdg-open https://example.com" """ @@ -405,7 +401,6 @@ async def fake_shell(cmd: str) -> None: - id: brand-new kind: button label: Brand new - grid: [0, 0, 1, 1] """ ) async with aiohttp.ClientSession() as http: diff --git a/tests/test_layouts.py b/tests/test_layouts.py index 8e4e842..3ccdd88 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -29,7 +29,6 @@ def _write(tmp: Path, name: str, body: str) -> Path: - id: back kind: button label: Back - grid: [0, 0, 1, 1] action: key: "alt+Left" """ @@ -41,7 +40,6 @@ def _write(tmp: Path, name: str, body: str) -> Path: - id: new-tab kind: button label: New tab - grid: [0, 0, 1, 1] action: key: "ctrl+shift+t" """ @@ -53,7 +51,6 @@ def _write(tmp: Path, name: str, body: str) -> Path: - id: home kind: button label: Home - grid: [0, 0, 1, 1] action: shell: "xdg-open https://example.com" """ @@ -64,7 +61,6 @@ def _write(tmp: Path, name: str, body: str) -> Path: - id: orphan kind: button label: Orphan - grid: [0, 0, 1, 1] """ @@ -75,7 +71,6 @@ def test_media_schema_accepts_typed_controls_and_volume_fallback_actions() -> No { "id": "media", "kind": "media", - "grid": [0, 0, 1, 1], "controls": ["play", "volume"], "volume_up_action": {"key": "volumeup"}, "volume_down_action": {"key": "volumedown"}, @@ -87,13 +82,13 @@ def test_media_schema_accepts_typed_controls_and_volume_fallback_actions() -> No @pytest.mark.parametrize("controls", [[], ["play", "play"], ["unknown"]]) def test_media_schema_rejects_invalid_controls(controls: list[str]) -> None: with pytest.raises(ValueError): - Widget.model_validate({"id": "media", "kind": "media", "grid": [0, 0, 1, 1], "controls": controls}) + Widget.model_validate({"id": "media", "kind": "media", "controls": controls}) def test_media_schema_rejects_empty_password_ref() -> None: with pytest.raises(ValueError): Widget.model_validate( - {"id": "media", "kind": "media", "grid": [0, 0, 1, 1], "media_http": {"password_ref": ""}} + {"id": "media", "kind": "media", "media_http": {"password_ref": ""}} ) @@ -111,7 +106,66 @@ def test_media_schema_rejects_empty_password_ref() -> None: ) def test_media_only_fields_rejected_on_other_widgets(field: str, value: object) -> None: with pytest.raises(ValueError, match="media-only"): - Widget.model_validate({"id": "button", "kind": "button", "grid": [0, 0, 1, 1], field: value}) + Widget.model_validate({"id": "button", "kind": "button", field: value}) + + +# --- Reflow schema (ADR-0010): size span, blank, overflow ------------------ + + +def test_widget_size_defaults_to_none() -> None: + """An absent ``size`` is a plain 1x1 cell; there is no ``grid`` field.""" + w = Widget.model_validate({"id": "b", "kind": "button"}) + assert w.size is None + assert not hasattr(w, "grid") + + +@pytest.mark.parametrize("size", [[2, 1], [1, 3], "full"]) +def test_widget_accepts_valid_size(size: object) -> None: + w = Widget.model_validate({"id": "b", "kind": "button", "size": size}) + assert w.size == size + + +@pytest.mark.parametrize("size", [[0, 1], [1, -2], [1], [1, 2, 3], "big"]) +def test_widget_rejects_bad_size(size: object) -> None: + with pytest.raises(ValueError): + Widget.model_validate({"id": "b", "kind": "button", "size": size}) + + +def test_blank_widget_accepts_only_size() -> None: + blank = Widget.model_validate({"id": "gap", "kind": "blank", "size": [2, 1]}) + assert blank.kind == "blank" + assert blank.size == [2, 1] + + +@pytest.mark.parametrize( + "field,value", + [ + ("label", "x"), + ("icon", {"source": "lucide", "name": "x"}), + ("color", "#fff"), + ("action", {"key": "a"}), + ], +) +def test_blank_widget_rejects_content_fields(field: str, value: object) -> None: + with pytest.raises(ValueError, match="blank"): + Widget.model_validate({"id": "gap", "kind": "blank", field: value}) + + +def test_layout_overflow_defaults_to_shrink_to_fit() -> None: + layout = Layout.model_validate({"match": ["x"], "widgets": []}) + assert layout.overflow == "shrink-to-fit" + + +def test_layout_accepts_shrink_to_fit_overflow() -> None: + layout = Layout.model_validate( + {"match": ["x"], "widgets": [], "overflow": "shrink-to-fit"} + ) + assert layout.overflow == "shrink-to-fit" + + +def test_layout_rejects_unknown_overflow() -> None: + with pytest.raises(ValueError): + Layout.model_validate({"match": ["x"], "widgets": [], "overflow": "wrap"}) @@ -203,7 +257,6 @@ def test_resolve_picks_first_matching_layout_in_load_order(tmp_path: Path) -> No - id: a kind: button label: a - grid: [0, 0, 1, 1] """ body_b = """ match: @@ -213,7 +266,6 @@ def test_resolve_picks_first_matching_layout_in_load_order(tmp_path: Path) -> No - id: b kind: button label: b - grid: [0, 0, 1, 1] """ _write(tmp_path, "01-firefox.yaml", body_a) _write(tmp_path, "02-firefox.yaml", body_b) @@ -245,7 +297,6 @@ def test_resolve_layouts_without_match_list_never_resolve(tmp_path: Path) -> Non - id: play kind: button label: Play - grid: [0, 0, 1, 1] action: key: "k" """ @@ -334,7 +385,6 @@ def test_layout_id_is_first_match_token_for_multi_match(tmp_path: Path) -> None: - id: ext kind: button label: ext - grid: [0, 0, 1, 1] """ _write(tmp_path, "code.yaml", body) store = load_layouts(tmp_path) @@ -357,7 +407,6 @@ def test_resolve_id_maps_friendly_names_to_canonical_id(tmp_path: Path) -> None: - id: split kind: button label: split - grid: [0, 0, 1, 1] """ _write(tmp_path, "tilix.yaml", body) store = load_layouts(tmp_path) @@ -396,7 +445,6 @@ def test_widget_color_field_round_trips(tmp_path: Path) -> None: kind: button label: Back color: "#1e3a8a" - grid: [0, 0, 1, 1] action: key: "alt+Left" """, @@ -424,7 +472,6 @@ def test_layout_with_jogstrip_false_parses(tmp_path: Path) -> None: - id: home kind: button label: Home - grid: [0, 0, 1, 1] """ _write(tmp_path, "default.yaml", body) store = load_layouts(tmp_path) @@ -467,7 +514,6 @@ def test_layout_round_trips_app_badge_fields(tmp_path: Path) -> None: - id: back kind: button label: Back - grid: [0, 0, 1, 1] """ _write(tmp_path, "firefox.yaml", body) store = load_layouts(tmp_path) @@ -498,7 +544,6 @@ def test_layout_icon_validates_source_and_name_non_empty(tmp_path: Path) -> None widgets: - id: back kind: button - grid: [0, 0, 1, 1] """ _write(tmp_path, "firefox.yaml", body) with pytest.raises(SystemExit): @@ -515,7 +560,6 @@ def test_layout_rejects_unknown_top_level_field(tmp_path: Path) -> None: widgets: - id: home kind: button - grid: [0, 0, 1, 1] """ _write(tmp_path, "default.yaml", body) with pytest.raises(SystemExit): @@ -530,7 +574,6 @@ def test_action_terminal_true_parses(tmp_path: Path) -> None: widgets: - id: term kind: button - grid: [0, 0, 1, 1] action: terminal: true """ @@ -548,7 +591,6 @@ def test_action_terminal_string_rejected_with_guidance(tmp_path: Path) -> None: widgets: - id: term kind: button - grid: [0, 0, 1, 1] action: terminal: "tilix" """ @@ -599,7 +641,6 @@ def test_overlay_replaces_same_id_base_entry(tmp_path: Path) -> None: - id: new-tab kind: button label: New tab - grid: [0, 0, 1, 1] action: key: "super+t" """, @@ -634,7 +675,6 @@ def test_overlay_wins_on_match_conflict_with_different_filename(tmp_path: Path) - id: new-tab kind: button label: New tab - grid: [0, 0, 1, 1] action: key: "super+t" """, @@ -660,7 +700,6 @@ def test_overlay_can_add_new_layouts(tmp_path: Path) -> None: - id: new-tab kind: button label: New tab - grid: [0, 0, 1, 1] action: key: "super+t" """) @@ -751,7 +790,6 @@ def test_action_url_parses(tmp_path: Path) -> None: widgets: - id: url-btn kind: button - grid: [0, 0, 1, 1] action: url: "https://example.com" """ @@ -768,7 +806,6 @@ def test_action_url_rejects_unknown_scheme(tmp_path: Path) -> None: widgets: - id: url-btn kind: button - grid: [0, 0, 1, 1] action: url: "ftp://example.com" """ @@ -785,7 +822,6 @@ def test_action_url_accepts_file_scheme(tmp_path: Path) -> None: widgets: - id: url-btn kind: button - grid: [0, 0, 1, 1] action: url: "file:///tmp/test.html" """ @@ -807,7 +843,6 @@ def test_action_text_simulate_mode_parses(tmp_path: Path) -> None: widgets: - id: text-btn kind: button - grid: [0, 0, 1, 1] action: text: "hello" text_mode: simulate @@ -827,7 +862,6 @@ def test_action_text_paste_mode_parses(tmp_path: Path) -> None: widgets: - id: text-btn kind: button - grid: [0, 0, 1, 1] action: text: "hello world" text_mode: paste @@ -848,7 +882,6 @@ def test_action_text_defaults_to_simulate(tmp_path: Path) -> None: widgets: - id: text-btn kind: button - grid: [0, 0, 1, 1] action: text: "hello" """ @@ -867,7 +900,6 @@ def test_action_text_rejects_empty_string(tmp_path: Path) -> None: widgets: - id: text-btn kind: button - grid: [0, 0, 1, 1] action: text: "" """ diff --git a/tests/test_macros.py b/tests/test_macros.py index 60dd54d..c9909a2 100644 --- a/tests/test_macros.py +++ b/tests/test_macros.py @@ -48,7 +48,6 @@ def test_widget_with_macro_loads() -> None: widget = Widget( id="btn", kind="button", - grid=[0, 0, 1, 1], macro=Macro(steps=[MacroStep(type="key", value="a")]), ) assert widget.macro is not None @@ -60,7 +59,6 @@ def test_widget_with_action_and_macro_coexist() -> None: widget = Widget( id="btn", kind="button", - grid=[0, 0, 1, 1], action=Action(shell="echo hi"), macro=Macro(steps=[MacroStep(type="key", value="a")]), ) @@ -270,7 +268,6 @@ async def trail_key(step, _ctx): widget = Widget( id="btn", kind="button", - grid=[0, 0, 1, 1], action=Action(shell="should-not-run"), macro=Macro(steps=[MacroStep(type="key", value="ctrl+t")]), ) @@ -284,7 +281,6 @@ async def test_execute_falls_back_to_action_when_no_macro() -> None: widget = Widget( id="btn", kind="button", - grid=[0, 0, 1, 1], action=Action(shell="true"), ) outcome = await run_action(widget, _ctx()) @@ -368,7 +364,6 @@ async def trail_step(step, _ctx): - id: macro-btn kind: button label: Macro - grid: [0, 0, 1, 1] macro: steps: - type: key @@ -429,7 +424,6 @@ async def failing_step(step, _ctx): - id: fail-btn kind: button label: Fail - grid: [0, 0, 1, 1] macro: steps: - type: key diff --git a/tests/test_media_art_route.py b/tests/test_media_art_route.py index 9adc446..93e5158 100644 --- a/tests/test_media_art_route.py +++ b/tests/test_media_art_route.py @@ -37,7 +37,7 @@ def _media_layout() -> Layout: Widget( id="vlc-media", kind="media", - grid=[0, 0, 4, 2], + size=[4, 2], controls=["play", "position"], media_http=MediaHttp(host="127.0.0.1", port=8080), ) diff --git a/tests/test_media_websocket.py b/tests/test_media_websocket.py index 589f959..1ed98cd 100644 --- a/tests/test_media_websocket.py +++ b/tests/test_media_websocket.py @@ -45,7 +45,7 @@ async def _serve(tmp_path: Path, manager: FakeMediaManager) -> tuple[TestServer, widgets: - id: media kind: media - grid: [0, 0, 4, 2] + size: [4, 2] media_http: host: media.local port: 9090 diff --git a/tests/test_mediabrowser.py b/tests/test_mediabrowser.py index 30ae26f..a398aa7 100644 --- a/tests/test_mediabrowser.py +++ b/tests/test_mediabrowser.py @@ -41,20 +41,20 @@ def test_media_browser_defaults() -> None: - """The model's required fields are ``id`` and ``grid``; the only - optional knob, ``empty_state``, defaults to ``show`` (issue #58 - removed the ``ordering`` knob).""" - widget = MediaBrowser.model_validate({"id": "browser", "grid": [0, 0, 4, 2]}) + """The model's only required field is ``id``; ``size`` is an optional + reflow extent (ADR-0010) and ``empty_state`` defaults to ``show`` + (issue #58 removed the ``ordering`` knob).""" + widget = MediaBrowser.model_validate({"id": "browser", "size": [4, 2]}) assert widget.id == "browser" - assert widget.grid == [0, 0, 4, 2] + assert widget.size == [4, 2] assert widget.empty_state == "show" def test_media_browser_accepts_explicit_knobs() -> None: """The ``empty_state`` knob accepts both documented values.""" hidden = MediaBrowser.model_validate( - {"id": "browser", "grid": [0, 0, 4, 2], "empty_state": "hide"} + {"id": "browser", "size": [4, 2], "empty_state": "hide"} ) assert hidden.empty_state == "hide" @@ -69,15 +69,14 @@ def test_media_browser_accepts_explicit_knobs() -> None: def test_media_browser_rejects_unknown_knobs(field: str, value: str) -> None: """Invalid knob values are a schema violation, surfaced as ``ValidationError``.""" with pytest.raises(ValidationError): - MediaBrowser.model_validate({"id": "browser", "grid": [0, 0, 4, 2], field: value}) + MediaBrowser.model_validate({"id": "browser", "size": [4, 2], field: value}) -def test_media_browser_rejects_bad_grid() -> None: - """The grid field reuses the existing 4-int tuple invariant.""" - with pytest.raises(ValidationError): - MediaBrowser.model_validate({"id": "browser", "grid": [0, 0, 1]}) - with pytest.raises(ValidationError): - MediaBrowser.model_validate({"id": "browser", "grid": [0, 0, 1, 1, 1]}) +def test_media_browser_defaults_size_to_none() -> None: + """``size`` is optional (ADR-0010): a mediabrowser is typically a + full-surface view rendered outside the flow, so it needs no span.""" + widget = MediaBrowser.model_validate({"id": "browser"}) + assert widget.size is None def test_media_browser_rejects_extra_fields() -> None: @@ -85,7 +84,7 @@ def test_media_browser_rejects_extra_fields() -> None: (issue #58: the removed ``ordering`` knob is the most likely typo).""" with pytest.raises(ValidationError): MediaBrowser.model_validate( - {"id": "browser", "grid": [0, 0, 4, 2], "ordring": "stable"} + {"id": "browser", "size": [4, 2], "ordring": "stable"} ) @@ -99,7 +98,7 @@ def test_rejects_removed_ordering_knob(model) -> None: payload = { "id": "browser", "kind": "mediabrowser", - "grid": [0, 0, 4, 2], + "size": [4, 2], "ordering": "playing_first", } with pytest.raises(ValidationError): @@ -115,7 +114,7 @@ def test_widget_accepts_mediabrowser_kind_with_optional_knobs() -> None: { "id": "browser", "kind": "mediabrowser", - "grid": [0, 0, 4, 2], + "size": [4, 2], "empty_state": "hide", } ) @@ -127,7 +126,7 @@ def test_widget_accepts_mediabrowser_kind_with_optional_knobs() -> None: def test_widget_defaults_mediabrowser_knobs() -> None: widget = Widget.model_validate( - {"id": "browser", "kind": "mediabrowser", "grid": [0, 0, 4, 2]} + {"id": "browser", "kind": "mediabrowser", "size": [4, 2]} ) assert widget.empty_state == "show" @@ -140,7 +139,6 @@ def test_widget_rejects_mediabrowser_knobs_on_other_kinds() -> None: { "id": "back", "kind": "button", - "grid": [0, 0, 1, 1], "empty_state": "hide", } ) @@ -154,7 +152,7 @@ def test_widget_mediabrowser_round_trips_through_json_wire_shape() -> None: receiving the key even when the YAML omits it — issue #58 dropped ``ordering``).""" widget = Widget.model_validate( - {"id": "browser", "kind": "mediabrowser", "grid": [0, 0, 4, 2]} + {"id": "browser", "kind": "mediabrowser", "size": [4, 2]} ) # The on-the-wire shape is ``model_dump_json()`` parsed by the TS # client — every key the TS union declares must be present so a @@ -167,7 +165,7 @@ def test_widget_mediabrowser_round_trips_through_json_wire_shape() -> None: { "id": "browser", "kind": "mediabrowser", - "grid": [0, 0, 4, 2], + "size": [4, 2], "empty_state": "hide", } ) @@ -219,7 +217,7 @@ def test_shipping_layouts_round_trip_through_layout_dump() -> None: widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ DEFAULT_LAYOUT = """ @@ -229,7 +227,6 @@ def test_shipping_layouts_round_trip_through_layout_dump() -> None: - id: home kind: button label: Home - grid: [0, 0, 1, 1] """ @@ -335,7 +332,6 @@ async def test_view_resolution_holds_across_focus_changes(tmp_path: Path) -> Non - id: back kind: button label: Back - grid: [0, 0, 1, 1] """, ) _write(tmp_path, "mpris.yaml", MPRIS_LAYOUT) diff --git a/tests/test_meters.py b/tests/test_meters.py index f6bc8d3..7c7e5a3 100644 --- a/tests/test_meters.py +++ b/tests/test_meters.py @@ -84,7 +84,6 @@ def test_meter_widget_loads_with_source() -> None: source: cpu_percent min: 0 max: 100 - grid: [0, 0, 1, 1] """ p = Path("/tmp/_meter_layout.yaml") p.write_text(yaml) @@ -108,7 +107,6 @@ def test_meter_widget_without_source_is_rejected() -> None: widgets: - id: cpu kind: meter - grid: [0, 0, 1, 1] """ p = Path("/tmp/_meter_layout_bad.yaml") p.write_text(yaml) @@ -131,7 +129,6 @@ def test_meter_widget_with_inverted_range_is_rejected() -> None: source: cpu_percent min: 100 max: 50 - grid: [0, 0, 1, 1] """ p = Path("/tmp/_meter_layout_bad2.yaml") p.write_text(yaml) @@ -167,7 +164,6 @@ def meter_layout(tmp_path: Path) -> Path: source: cpu_percent min: 0 max: 100 - grid: [0, 0, 1, 1] """ ) return tmp_path @@ -284,7 +280,6 @@ def test_stats_widget_loads_with_metrics(tmp_path: Path) -> None: - id: system kind: stats label: System - grid: [0, 0, 1, 1] metrics: - source: cpu_percent label: CPU @@ -307,7 +302,6 @@ def test_stats_widget_without_metrics_is_rejected(tmp_path: Path) -> None: widgets: - id: system kind: stats - grid: [0, 0, 1, 1] """ ) with pytest.raises(SystemExit): @@ -325,7 +319,6 @@ def stats_layout(tmp_path: Path) -> Path: - id: system kind: stats label: System - grid: [0, 0, 1, 1] metrics: - source: cpu_percent label: CPU diff --git a/tests/test_mpris_art_route.py b/tests/test_mpris_art_route.py index ab54805..08a0c49 100644 --- a/tests/test_mpris_art_route.py +++ b/tests/test_mpris_art_route.py @@ -49,7 +49,7 @@ class _MprisArtFixture: def _mediabrowser_layout() -> Layout: return Layout( id="mpris", - widgets=[Widget(id="browser", kind="mediabrowser", grid=[0, 0, 4, 2])], + widgets=[Widget(id="browser", kind="mediabrowser", size=[4, 2])], ) diff --git a/tests/test_mpris_dbus.py b/tests/test_mpris_dbus.py index 4dfd251..9e5b5b4 100644 --- a/tests/test_mpris_dbus.py +++ b/tests/test_mpris_dbus.py @@ -991,8 +991,8 @@ def factory(_bt: Any) -> FakeDbusBus: store = _store_with_layouts( [ - ("default", [{"id": "btn", "kind": "button", "grid": [0, 0, 1, 1]}]), - ("firefox", [{"id": "btn", "kind": "button", "grid": [0, 0, 1, 1]}]), + ("default", [{"id": "btn", "kind": "button"}]), + ("firefox", [{"id": "btn", "kind": "button"}]), ] ) @@ -1010,7 +1010,7 @@ def factory(_bt: Any) -> FakeDbusBus: [ ( "mpris", - [{"id": "browser", "kind": "mediabrowser", "grid": [0, 0, 4, 2]}], + [{"id": "browser", "kind": "mediabrowser", "size": [4, 2]}], ), ] ) diff --git a/tests/test_mpris_websocket.py b/tests/test_mpris_websocket.py index 1f91e87..f256175 100644 --- a/tests/test_mpris_websocket.py +++ b/tests/test_mpris_websocket.py @@ -31,7 +31,7 @@ async def test_mpris_rows_and_commands_cross_websocket_boundary(tmp_path: Path) widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) backend = FakeMprisBackend( @@ -83,7 +83,7 @@ async def test_mpris_rows_flow_when_browser_is_a_non_current_view( widgets: - id: pad kind: trackpad - grid: [0, 0, 4, 2] + size: [4, 2] """ ) # ...the browser lives only in the separate mpris view layout. @@ -93,7 +93,7 @@ async def test_mpris_rows_flow_when_browser_is_a_non_current_view( widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) backend = FakeMprisBackend( @@ -127,7 +127,7 @@ async def test_late_session_receives_current_players_via_snapshot( widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) backend = FakeMprisBackend( @@ -178,7 +178,7 @@ async def _boot_mpris_websocket( widgets: - id: browser kind: mediabrowser - grid: [0, 0, 4, 2] + size: [4, 2] """ ) server, *_ = make_test_server( diff --git a/tests/test_server.py b/tests/test_server.py index 5c225f6..7e43e25 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -101,7 +101,6 @@ async def fake_terminal(target: bool | str = True) -> None: - id: open-terminal kind: button label: Open terminal - grid: [0, 0, 1, 1] action: terminal: true """ @@ -409,7 +408,6 @@ async def fake_shell(cmd: str) -> None: - id: home kind: button label: Home - grid: [0, 0, 1, 1] """ ) (tmp_path / "nochrome.yaml").write_text( @@ -421,7 +419,6 @@ async def fake_shell(cmd: str) -> None: - id: scroll-own kind: button label: Scroll own - grid: [0, 0, 1, 1] """ ) @@ -478,7 +475,6 @@ async def fake_shell(cmd: str) -> None: widgets: - id: home kind: button - grid: [0, 0, 1, 1] """ ) (tmp_path / "firefox.yaml").write_text( @@ -493,7 +489,6 @@ async def fake_shell(cmd: str) -> None: widgets: - id: back kind: button - grid: [0, 0, 1, 1] """ ) @@ -557,7 +552,6 @@ async def fake_shell(cmd: str) -> None: - id: home kind: button label: Home - grid: [0, 0, 1, 1] """ ) @@ -722,7 +716,6 @@ async def test_key_message_emits_named_key(srv: ServerHandle) -> None: - id: home kind: button label: Home - grid: [0, 0, 1, 1] """ VALID_DEFAULT_V2 = """ @@ -732,7 +725,6 @@ async def test_key_message_emits_named_key(srv: ServerHandle) -> None: - id: home-v2 kind: button label: Home v2 - grid: [0, 0, 1, 1] """ INVALID_YAML = """ @@ -741,7 +733,6 @@ async def test_key_message_emits_named_key(srv: ServerHandle) -> None: widgets: - id: broken kind: button - grid: [0, 0, 1, 1] action: key: 42 # wrong type; schema says str unknown_field: nope