Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
```

Expand Down
17 changes: 17 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { Settings } from "./Settings";
import { useMeterStore } from "./meter-store";
import { useMediaStore } from "./media-store";
import {
clampCellSize,
useCellSize,
useBottomScale,
useContentScale,
useJogWidth,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -402,6 +414,7 @@ export function App() {
{
"--content-scale": contentScale.scale,
"--label-scale": labelScale.scale,
"--cell-size": `${effectiveCellSize.size}px`,
} as CSSProperties
}
>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -481,6 +496,8 @@ export function App() {
) : layout ? (
<ButtonGrid
widgets={layout.widgets}
overflow={layout.overflow}
cellSize={effectiveCellSize.size}
onPress={press}
onJog={jog}
onJogEnd={jogEnd}
Expand Down
72 changes: 31 additions & 41 deletions client/src/ButtonGrid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { CSSProperties } from "react";
import { ButtonGrid } from "./ButtonGrid";
import { DEMO_LAYOUTS } from "./demo";
import {
CELL_SIZE_MIN,
CELL_SIZE_MAX,
CELL_SIZE_DEFAULT,
CELL_SIZE_STEP,
CONTENT_SCALE_DEFAULT,
CONTENT_SCALE_MAX,
CONTENT_SCALE_MIN,
Expand All @@ -13,35 +17,34 @@ export default { title: "ButtonGrid" };

const noop = () => {};

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 (
<div
style={
{ height: 440, maxWidth: 820, "--content-scale": contentScale } as CSSProperties
{
height: 440,
maxWidth: 820,
"--content-scale": contentScale,
} as CSSProperties
}
>
<ButtonGrid
Expand All @@ -53,37 +56,24 @@ function Frame({
scrollInvert={false}
onMediaCommand={noop}
showKeyHints={showKeyHints}
cellSize={cellSize}
/>
</div>
);
}

export const Firefox: Story<ContentScaleArgs> = ({ contentScale }) => (
<Frame name="firefox" contentScale={contentScale} />
);
Firefox.args = contentScaleControl.args;
Firefox.argTypes = contentScaleControl.argTypes;
export const Firefox: Story<Controls> = (args) => <Frame name="firefox" {...args} />;
Firefox.args = controls.args;
Firefox.argTypes = controls.argTypes;

export const Default: Story<ContentScaleArgs> = ({ contentScale }) => (
<Frame name="default" contentScale={contentScale} />
);
Default.args = contentScaleControl.args;
Default.argTypes = contentScaleControl.argTypes;
export const Default: Story<Controls> = (args) => <Frame name="default" {...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<ContentScaleArgs> = ({ contentScale }) => (
<Frame name="showcase" contentScale={contentScale} />
);
Showcase.args = contentScaleControl.args;
Showcase.argTypes = contentScaleControl.argTypes;
export const Showcase: Story<Controls> = (args) => <Frame name="showcase" {...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<ContentScaleArgs> = ({ contentScale }) => (
<Frame name="firefox" contentScale={contentScale} showKeyHints />
);
KeyHints.args = contentScaleControl.args;
KeyHints.argTypes = contentScaleControl.argTypes;
export const KeyHints: Story<Controls> = (args) => <Frame name="firefox" {...args} showKeyHints />;
KeyHints.args = controls.args;
KeyHints.argTypes = controls.argTypes;
Loading
Loading