# Architecture Switchboard is an Omarchy shell plugin of kinds `overlay` + `service`. It runs inside the long-lived `omarchy-shell` Quickshell process, unsandboxed, in the same process as the user's bar and notifications. A thrown exception here costs someone their desktop shell, which is why so much of this file is about defensive boundaries. ## The pieces | File | Role | |---|---| | `Switchboard.js` | Everything that does not need a compositor. Pure functions, no QML types, unit-tested under `node --test`. | | `Service.qml` | Headless. Owns config, the `switchboard` IPC target, and compositor actions. | | `Overlay.qml` | The surface: the layer-shell window, the model, the keyboard, drag resolution, the hero flight. | | `WorkspaceSection.qml` | One workspace column: heading, packed rows, drop target. | | `Tile.qml` | One window: live capture, frame, chip, badge, pointer gestures. | | `qmldir` | Required. Without it the sibling types do not resolve — see below. | `Switchboard.js` is deliberately large and the QML deliberately thin. Layout, matching, and naming are all decisions that can be tested in a second without a compositor; the QML above them is close to declarative. ## Four things that will bite you **1. `qmldir` is load-bearing.** A plugin's QML is loaded by URL from `~/.config/omarchy/plugins//`, and sibling types do *not* resolve implicitly from there — `WorkspaceSection` and `Tile` come back as "is not a type". The `qmldir` in the repo root plus `import "."` is what fixes it. **2. Qt hands you array-*likes*, not arrays.** `Hyprland.toplevels.values` is an `UntypedObjectModel`, and `lastIpcObject.size` / `.at` are `QVariantList`s. All are indexable, all have a `length`, and all fail `Array.isArray`. This cost two separate bugs: an overview that was reliably empty, and — more subtly — one where every window fell back to the same placeholder aspect ratio, so every tile rendered the same shape and the captures letterboxed inside frames that did not match them. Everything crossing that boundary goes through `Switchboard.toArray`. **3. The toplevel model only fills while it is observed.** Reading `Hyprland.toplevels.values` without an observer returns an empty list forever. `Overlay.qml` keeps an `Instantiator` bound to the model purely as a subscription; it creates no visual items. **4. Bindings do not fire on child property changes.** Hyprland delivers a toplevel *before* it knows that toplevel's workspace, and mutating `toplevel.workspace` does not retrigger a binding on the parent list. A bound model latches onto an empty desktop and never recovers. So `structure` is refreshed explicitly — on compositor events and on a 200 ms poll — and guarded by `Switchboard.structureSignature`, a cheap fingerprint of everything that affects layout. The guard is what makes the poll free, and it is also what keeps delegates alive: a new model object rebuilds every delegate and restarts every live capture on screen. ## Why structure and query state are separate `buildModel()` knows nothing about the search query. `applyQuery()` resolves match state against an already-built model. This is not tidiness — if the query fed the structure, every keystroke would produce a new model object, QML would rebuild every delegate, and every `ScreencopyView` on screen would restart. The split is what makes typing smooth. It also gives the design its shape: because structure is stable across keystrokes, filtering can dim tiles *in place* rather than reflowing them. ## Layout Workspaces are columns (`Switchboard.columnWidth`). Stacking them as horizontal bands wasted almost all of the width on a wide screen — four workspaces holding one window each used a fifth of the display and read as a sidebar. Within a column, `Switchboard.packColumn` tries every row count and keeps the one that makes the smallest tile as large as possible while still fitting the column's height. Greedy width-filling (the classic justified-gallery algorithm) produced a cliff: two windows would sit side by side at half height, leaving two thirds of the column empty, purely because a third window would not have fit. Consequently tile heights differ between columns — a busy workspace shows smaller tiles than a quiet one. That is the same bargain Mission Control makes, and it beats sizing every column for the worst one. Two invariants are covered by tests and both were once violated: - **No row is ever wider than its column.** Clamping a row's height above its exact fitting height overflows, and any attempt to hide that by adjusting one cell's width silently destroys that window's aspect ratio. - **Widths are floored, never rounded.** Half a pixel multiplied through an aspect ratio pushes a row past the column edge. ## Compositor interaction Hyprland 0.56 replaced string dispatchers with a Lua API, so `movetoworkspacesilent N,address:0x…` no longer parses — it is a Lua syntax error, and Hyprland reports nothing useful. `Switchboard.focusCommand` and `Switchboard.moveCommand` build the current forms and are tested as strings. Where a Wayland protocol exists it is preferred over a dispatcher: focus and close go through `Toplevel.activate()` / `.close()`, which need no string building and no address formatting. Moving a window has no protocol equivalent, so it is the one action that still dispatches. Quickshell reports addresses without the `0x` prefix that Hyprland's selectors require. An unmatched selector is not an error, so getting this wrong makes every action fail *silently*. `Switchboard.hyprAddress` normalises it. `closeToplevel` has no dispatcher fallback on purpose: `hl.dsp.window.close` acts on the *focused* window, so dispatching it for an unfocused tile would close the wrong one. A no-op is the only safe failure. ## The hero return On jump the overlay does not cut away — the chosen tile flies to where its window actually sits, using the rect Hyprland already reported. The ordering matters. Setting `heroRunning` drops the surface's exclusive keyboard focus, but that change only reaches the compositor on the next commit. Activating the window in the same frame means Hyprland is still looking at a focus-grabbing layer and the jump is quietly ignored — the animation plays and nothing happens. So the handover is deferred one tick, and the flight's `onStopped` re-runs it if the animation was cut short, so a shortened flight can never swallow the jump it was decorating. ## Performance Only the cursor tile streams (`liveCaptures`, default 8). Every other tile captures one frame and freezes. Closing the overlay drops the model entirely, which tears down every `ScreencopyView` with it — that is what makes `keepLoaded: true` affordable, and it is why Switchboard opens instantly. ## Defensive boundaries Every read of `lastIpcObject` goes through `Switchboard.ipcField` with a fallback. Nothing chains property access on compositor data. Window titles are content the window itself controls and reach a rich-text renderer, so `Switchboard.highlightHtml` escapes every segment before emitting `StyledText`. The fixed user-writable configuration path never enters a `FileView`, which has no byte ceiling and retains its complete target. A one-second poll reads at most 12 KiB plus one sentinel byte through a timed child process; oversized regular files and symlink targets are rejected before Base64 decoding or JSON parsing. ## Testing ```bash node --test 'test/*.test.js' # no compositor needed omarchy plugin validate . ``` `test/load.js` strips the `.pragma library` line — invalid JavaScript, required by QML — and compiles the real shipped `Switchboard.js`, so the tests exercise the artifact rather than a copy. For UI work, run the overlay outside `omarchy-shell` so a mistake costs a throwaway process instead of the desktop. Create a directory containing symlinks to `/usr/share/omarchy/shell/{Commons,Ui,services}` plus one to this repo, and a `shell.qml` that loads `switchboard/Overlay.qml` and injects a stub `shell` object. Keep it **outside** the repo: `omarchy plugin validate` rejects a plugin folder containing symlinks. One caveat if you develop against a symlinked plugin directory (linking this repo into `~/.config/omarchy/plugins/` rather than cloning it there): saving a file does **not** hot-reload, and neither does `omarchy-shell shell rescanPlugins`. The shell's watcher does not follow the symlink, so it keeps serving the code that was loaded when the plugin was enabled — which is a confusing way to spend twenty minutes debugging a fix that is already correct on disk. Run `omarchy restart shell` to pick up changes.