Skip to content

Commit dce13ae

Browse files
ix-windows: floating blurred overlays that auto-size to content (#1327)
## What Reworks `ix-windows` from tiled borderless webview windows into floating, blurred **overlay** cards that auto-size to their content. No tiling, no layout manager. - **Blur behind.** Each window paints its transparent `wry` webview on top of a native `NSVisualEffectView` (behind-window blur, `HUDWindow` material), inserted as the content view's first subview with a rounded, shadowed layer. The rendered HTML sits on top. - **Auto-size to content.** A page `ResizeObserver` measures the `#ix-root` panel and posts its pixel size over `wry`'s IPC channel; the new `UserEvent::Resize` drives `WindowManager::resize`, which fits the OS window (clamped to the monitor, 1px hysteresis to break any reflow loop). The panel is `inline-block`/`max-width` so its intrinsic size doesn't depend on window width. - **Floating overlay.** Windows are transparent + borderless + always-on-top, and join all spaces / float over fullscreen apps (`NSWindowCollectionBehavior`). ## Why The previous behavior relied on an external tiling WM (aerospace/yabai rules) to lay windows out. This makes each resource a self-contained, self-sizing HUD overlay instead. ## Validation - `nix build .#ix-windows` passes (darwin). - New `parse_size` unit test; existing `shell`/`escape_text` tests retained. Window/webview/blur paths need a display and are exercised manually. - Docs + README updated (`docs/ix-windows/overview.md`). 🤖 Drafted with Claude Code (Opus). <!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> > [!NOTE] > ### Add floating blurred overlay windows with auto-size to content in ix-windows > - Windows are now always-on-top transparent overlays instead of borderless square windows, with initial size reduced to 480x300 and position cascade adjusted. > - A `ResizeObserver`-based script ([MEASURE_JS](https://github.com/indexable-inc/index/pull/1327/files#diff-1e1abdcf7151daee9cdb984d29eca28bda3ded2eaa1b78937674b8156ef55a43)) measures the `#ix-root` panel and reports pixel dimensions to the native layer via wry IPC, driving `WindowManager::resize` to clamp and apply the new size against monitor bounds. > - On macOS, `install_blur()` uses `objc2-app-kit` to insert an `NSVisualEffectView` beneath the webview and sets collection behavior to float across spaces and over fullscreen apps. > - A new `UserEvent` enum wraps both producer stream events and `Resize` reports; `main.rs` forwards producer events as `UserEvent::Producer` and dispatches `UserEvent::Resize` to `WindowManager::resize`. > - `parse_size` strictly validates IPC size messages, rejecting non-finite or non-positive values to avoid panics during clamping. > > <!-- Macroscope's review summary starts here --> > > <sup><a href="https://app.macroscope.com">Macroscope</a> summarized 1895a3e.</sup> > <!-- Macroscope's review summary ends here --> > <!-- macroscope-ui-refresh --> <!-- Macroscope's pull request summary ends here -->
1 parent 6ea6815 commit dce13ae

8 files changed

Lines changed: 420 additions & 171 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ nix = "0.31"
204204
nix-web-monitor-parser = { path = "packages/nix/nix-web-monitor/parser" }
205205
numpy = "0.28"
206206
objc2 = "0.6"
207+
objc2-app-kit = "0.3"
207208
objc2-foundation = "0.3"
208209
objc2-web-kit = "0.3"
209210
object_store = { version = "0.13", features = ["aws"] }

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ From-source documentation for the packages in the `index` repo (a shared, open-s
5050
| [ix-dev-diagnose](ix-dev-diagnose/overview.md) | `packages/ix-dev-diagnose` probes `https://ix.dev/` (or any HTTPS URL) from the caller's network path and writes a single JSON diagnostic capturing every layer of the request: system DNS... |
5151
| [ix-fleet](ix-fleet/overview.md) | `packages/ix-fleet` renders and executes declarative **fleet plans**: a single JSON document describes a set of remote ix VMs (nodes) and their images, NixOS switch targets, east-west gro... |
5252
| [ix-sdk-python](ix-sdk-python/overview.md) | `packages/ix-sdk-python` is the Nix package that makes the precompiled Python SDK bindings available in-repo as `pkgs.ix-sdk-python` / `nix build .#ix-sdk-python`. |
53-
| [ix-windows](ix-windows/overview.md) | `packages/ix-windows` renders each live MCP resource as its own borderless, square, ghostty-styled native webview window. |
53+
| [ix-windows](ix-windows/overview.md) | `packages/ix-windows` renders each live MCP resource as its own floating, blurred overlay webview window that auto-sizes to its content. |
5454
| [kitty](kitty/overview.md) | `packages/kitty` is an encoder for the kitty terminal graphics protocol: it turns image bytes into the `APC _G ... |
5555
| [lake](lake/overview.md) | `packages/lake` (member `lake/iceberg`, crate `lake-iceberg`) is the Iceberg corpus lake: the durable, replayable log under the multi-source search corpus (issue #752), succeeding the ful... |
5656
| [launchk](launchk/overview.md) | `packages/launchk` builds launchk, a cursive (Rust TUI) tool for observing launchd agents and daemons, from source. |

docs/ix-windows/overview.md

Lines changed: 98 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# ix-windows
22

3-
`packages/ix-windows` renders each live MCP resource as its own borderless,
4-
square, ghostty-styled native webview window. It is a second consumer of the
5-
dashboard producer stream, alongside the [dashboard](../dashboard/overview.md)
6-
web aggregator: instead of folding producers into a web board, it maps each
7-
`resource/<id>` html pane to one OS window. A window opens when a resource
8-
appears, re-renders in place on update (no reload, so scroll and focus survive),
9-
and closes when the resource closes or its producer disconnects.
3+
`packages/ix-windows` renders each live MCP resource as its own floating,
4+
blurred **overlay** webview window that auto-sizes to its content. It is a second
5+
consumer of the dashboard producer stream, alongside the
6+
[dashboard](../dashboard/overview.md) web aggregator: instead of folding
7+
producers into a web board, it maps each `resource/<id>` html pane to one OS
8+
window. A window opens when a resource appears, re-renders in place on update (no
9+
reload, so scroll and focus survive), and closes when the resource closes or its
10+
producer disconnects.
1011

1112
It reuses [dashboard-core](../dashboard-core/overview.md) for the entire
1213
transport, so the MCP needs no change: the MCP already publishes every
@@ -23,8 +24,8 @@ It is split into a reusable engine library (`src/lib.rs`, `[lib] name =
2324
Rust workspace package (`packages/ix-windows/Cargo.toml`), built as the
2425
`ix-windows` flake output (`package.nix`: `flake = true`). Deps: `dashboard-core`,
2526
`clap`, `tao`, `wry`, `tokio`, `serde_json`; on macOS also `objc2` +
26-
`objc2-foundation` + `objc2-web-kit` for the WebKit/window tuning
27-
(`Cargo.toml:31`).
27+
`objc2-app-kit` + `objc2-foundation` + `objc2-web-kit` for the native blur and
28+
WebKit tuning.
2829

2930
```
3031
nix run .#ix-windows # watch the default discovery dir
@@ -34,111 +35,126 @@ nix build .#ix-windows
3435

3536
**Darwin-only flake output.** `wry` links the system WebKit framework on macOS;
3637
Linux (WebKitGTK) is a later add, so `default.nix` restricts `meta.platforms` to
37-
`aarch64-darwin` and `x86_64-darwin` (`packages/ix-windows/default.nix:11`). The
38-
macOS WebKit tuning (`src/lib.rs:294`) is behind `#[cfg(target_os = "macos")]`,
39-
so the crate still compiles on other targets, just without that tuning.
38+
`aarch64-darwin` and `x86_64-darwin`. The native blur and WebKit tuning are
39+
behind `#[cfg(target_os = "macos")]`, so the crate still compiles on other
40+
targets, just without that styling (no blur, no auto-resize is unaffected since
41+
it is plain `tao`).
4042

41-
## CLI (`src/main.rs:22`)
43+
## CLI (`src/main.rs`)
4244

4345
| flag | default | meaning |
4446
| --- | --- | --- |
45-
| `--dir` | discovery dir | producer-socket directory to watch, matching the `dashboard` aggregator. Defaults via [`discovery_dir`](../dashboard-core/overview.md#discovery-paths) (`main.rs:28`). |
46-
| `--rescan-ms` | `500` | how often to rescan the directory for new/removed sockets (`main.rs:33`). |
47+
| `--dir` | discovery dir | producer-socket directory to watch, matching the `dashboard` aggregator. Defaults via [`discovery_dir`](../dashboard-core/overview.md#discovery-paths). |
48+
| `--rescan-ms` | `500` | how often to rescan the directory for new/removed sockets. |
4749

48-
## Threading model (`src/main.rs:37`)
50+
## Threading model (`src/main.rs`)
4951

5052
Windows must be created and driven on the main thread (`tao`), but the
51-
subscriber is async. The binary:
53+
subscriber is async, and the page's measuring script also feeds events back. The
54+
binary:
5255

53-
1. Builds a `tao` `EventLoop` parameterized on `ProducerEvent` as its user-event
54-
type, and a proxy (`main.rs:43`).
56+
1. Builds a `tao` `EventLoop` parameterized on [`UserEvent`] as its user-event
57+
type, and a proxy. [`UserEvent`] wraps either a `ProducerEvent`
58+
(`UserEvent::Producer`) or a content-size report (`UserEvent::Resize`).
5559
2. Spawns a side thread running a multi-thread tokio runtime that calls
5660
[`subscribe`](../dashboard-core/overview.md#consumer-side-subscribe-srcsubscribers)
57-
and forwards each `ProducerEvent` into the event loop via the proxy; it stops
58-
when the loop has exited (`main.rs:49`).
61+
and forwards each event as `UserEvent::Producer` into the loop via a clone of
62+
the proxy; it stops when the loop has exited.
5963
3. Runs the event loop with `ControlFlow::Wait` (reactive viewer, not an
60-
animation loop) and dispatches to a [`WindowManager`] (`main.rs:65`):
61-
`UserEvent(Snapshot)` -> `apply_snapshot`, `UserEvent(Gone)` ->
62-
`producer_gone`, `WindowEvent::CloseRequested` -> `window_closed`.
64+
animation loop) and dispatches to a [`WindowManager`]:
65+
`Producer(Snapshot)` -> `apply_snapshot`, `Producer(Gone)` -> `producer_gone`,
66+
`Resize { window, .. }` -> `resize`, `WindowEvent::CloseRequested` ->
67+
`window_closed`.
6368

6469
## The engine: `WindowManager` (`src/lib.rs`)
6570

66-
[`WindowManager`] (`lib.rs:69`) owns the open windows and reconciles them against
67-
each producer snapshot. It is decoupled from the event source and generic over
68-
the loop's user-event type `T` so an embedder can drive it from its own `tao`
69-
loop; the binary's `main` is a thin wrapper. A window's global identity is the
70-
`PaneKey = (producer id, pane id)` (`lib.rs:35`), since a pane id is unique only
71-
within its producer.
71+
[`WindowManager`] owns the open windows and reconciles them against each producer
72+
snapshot. The window-creation path is generic over the loop's user-event type
73+
`T` (it only needs the `EventLoopWindowTarget<T>` to build windows), but the
74+
manager also emits [`UserEvent::Resize`] through the loop proxy, so it holds an
75+
`EventLoopProxy<UserEvent>` and is constructed with one. A window's global
76+
identity is the `PaneKey = (producer id, pane id)`, since a pane id is unique
77+
only within its producer.
7278

7379
Public surface:
7480

75-
- [`WindowManager::new`] (`lib.rs:89`) / `Default`: an empty manager.
76-
- [`apply_snapshot<T>(target, snapshot)`] (`lib.rs:99`): for each pane that is an
77-
`Html` view whose id starts with `resource/` (`RESOURCE_PREFIX`, `lib.rs:31`),
78-
refresh an open window or open a new one; close windows for this producer's
79-
resources no longer present; and forget dismissals for resources that vanished.
80-
Non-html or non-`resource/` panes are skipped, so exec/namespace/cell panes
81-
stay on the web canvas.
82-
- [`producer_gone(producer)`] (`lib.rs:141`): drop every window of a disconnected
83-
producer and clear its dismissals.
84-
- [`window_closed(window_id)`] (`lib.rs:157`): the user closed a window; remove
85-
it and record the dismissal. Returns whether it was one of ours.
86-
- [`is_empty`] (`lib.rs:168`): whether any resource windows are open.
81+
- [`WindowManager::new`]`(proxy)`: an empty manager that emits resize events
82+
through `proxy`.
83+
- [`apply_snapshot<T>(target, snapshot)`]: for each pane that is an `Html` view
84+
whose id starts with `resource/` (`RESOURCE_PREFIX`), refresh an open window or
85+
open a new one; close windows for this producer's resources no longer present;
86+
and forget dismissals for resources that vanished. Non-html or non-`resource/`
87+
panes are skipped, so exec/namespace/cell panes stay on the web canvas.
88+
- [`resize(window, width, height)`]: fit the overlay window to the natural pixel
89+
size its content reported, clamped to the window's monitor work area; a report
90+
within 1px of the last applied size is ignored, which also breaks any
91+
resize/reflow loop.
92+
- [`producer_gone(producer)`]: drop every window of a disconnected producer and
93+
clear its dismissals.
94+
- [`window_closed(window_id)`]: the user closed a window; remove it and record
95+
the dismissal. Returns whether it was one of ours.
96+
- [`is_empty`]: whether any resource windows are open.
8797

8898
### Reconcile invariants
8999

90-
- **In-place refresh.** `OpenWindow::refresh` (`lib.rs:51`) swaps only the
91-
`#ix-root` inner HTML via `evaluate_script` when the html changed, and resets
92-
the title when it changed, so the document, scroll position, and focus survive
93-
an update (a full reload would flicker and reset them). The new HTML is injected
94-
as a `serde_json::to_string` JS string literal, so arbitrary resource HTML is
95-
escaped safely (`lib.rs:55`).
96-
- **Dismissal tracking.** `dismissed` (`lib.rs:79`) records resources the user
97-
closed while still live. Without it, the next snapshot (any content change
98-
republishes one) would find the window gone and re-open it, fighting the user.
99-
It is cleared when the resource actually vanishes or its producer disconnects,
100-
so a genuine re-registration opens a fresh window.
101-
- **Reverse index.** `by_window` (`lib.rs:73`) maps an OS `WindowId` back to its
102-
`PaneKey` for close events.
103-
- **Cascade.** `opened` (`lib.rs:83`) offsets each new window so they do not
104-
stack exactly on a plain desktop; a tiling WM ignores the position hint.
105-
106-
## Native window styling (macOS)
107-
108-
- **Borderless, square corners.** Windows are built `with_decorations(false)`,
109-
`with_transparent(true)`, 720x480 logical (`lib.rs:194`), exactly like
110-
ghostty's `window-decoration = none`. The macOS window server only rounds
111-
*titled* windows, so dropping decorations gives square corners for free.
112-
- **Ghostty-flavored shell.** `shell(title, body)` (`lib.rs:249`) wraps the
113-
resource HTML in a dark, monospace, Catppuccin-ish document whose `#ix-root`
114-
holds the body; the `<title>` is escaped, the body is injected verbatim (the
115-
same trust model as the web dashboard's sandboxed html pane). Styling is the
116-
`STYLE` constant (`lib.rs:270`).
117-
- **120Hz.** `enable_high_refresh` (`lib.rs:294`) disables WebKit's private
100+
- **In-place refresh.** `OpenWindow::refresh` swaps only the `#ix-root` inner
101+
HTML via `evaluate_script` when the html changed, and resets the title when it
102+
changed, so the document, scroll position, and focus survive an update (a full
103+
reload would flicker and reset them). The new HTML is injected as a
104+
`serde_json::to_string` JS string literal, so arbitrary resource HTML is
105+
escaped safely. The page's `ResizeObserver` notices the resulting size change
106+
and drives a `resize`.
107+
- **Dismissal tracking.** `dismissed` records resources the user closed while
108+
still live. Without it, the next snapshot (any content change republishes one)
109+
would find the window gone and re-open it, fighting the user. It is cleared
110+
when the resource actually vanishes or its producer disconnects, so a genuine
111+
re-registration opens a fresh window.
112+
- **Reverse index.** `by_window` maps an OS `WindowId` back to its `PaneKey` for
113+
close and resize events.
114+
- **Cascade.** `opened` offsets each new overlay so several do not stack exactly
115+
on top of each other.
116+
117+
## Overlay styling and auto-size
118+
119+
- **Floating overlay.** Windows are built `with_decorations(false)`,
120+
`with_transparent(true)`, `with_always_on_top(true)`. On macOS the window also
121+
joins all spaces and floats over fullscreen apps (`NSWindowCollectionBehavior`).
122+
- **Blur behind.** `install_blur` (macOS) inserts an `NSVisualEffectView`
123+
(`HUDWindow` material, `BehindWindow` blending) as the content view's first
124+
subview, beneath the transparent webview, with a rounded, shadowed layer. The
125+
rendered HTML paints on top of it.
126+
- **Transparent shell.** `shell(title, body)` wraps the resource HTML in a fully
127+
transparent document whose `#ix-root` panel shrink-wraps its content with a
128+
faint tint and rounded corners (the `STYLE` constant); the `<title>` is
129+
escaped, the body injected verbatim (the same trust model as the web
130+
dashboard's sandboxed html pane).
131+
- **Auto-size to content.** The shell embeds `MEASURE_JS`: a `ResizeObserver` on
132+
`#ix-root` posts the panel's `offsetWidth`x`offsetHeight` over `wry`'s IPC
133+
channel (coalesced to one report per frame, deduped). The IPC handler forwards
134+
it as `UserEvent::Resize`, and `WindowManager::resize` fits the OS window. The
135+
panel is `inline-block` / `max-width` so its intrinsic size does not depend on
136+
the window width, which keeps the measurement stable (no resize loop).
137+
- **120Hz.** `enable_high_refresh` disables WebKit's private
118138
`PreferPageRenderingUpdatesNear60FPSEnabled` experimental feature via the
119139
private `_setEnabled:forExperimentalFeature:` selector (gated by
120140
`respondsToSelector:` checks), so the webview renders at the display's native
121141
rate (ProMotion) instead of the ~60fps cap. Best-effort: an OS without those
122142
selectors stays at the default.
123143

124-
## Tiling under aerospace / yabai
144+
## Tests (`src/lib.rs`)
125145

126-
A borderless window has no fullscreen button, so aerospace's dialog heuristic
127-
floats it by default, and `ix-windows` has no bundle id to special-case. The
128-
crate `README.md` gives the rules: an aerospace `on-window-detected` rule
129-
matching `app-name-regex-substring = 'ix-windows'` with `run = 'layout tiling'`,
130-
or `yabai -m rule --add app='^ix-windows$' manage=on`.
131-
132-
## Tests (`src/lib.rs:334`)
133-
134-
`shell` wraps the body in `#ix-root` and escapes the title;
135-
`escape_text` covers `<`, `&`, `>`. The window/webview paths require a display
136-
and are exercised manually.
146+
`shell` wraps the body in `#ix-root` and embeds the measuring script and escapes
147+
the title; `escape_text` covers `<`, `&`, `>`; `parse_size` reads the
148+
`"<w>x<h>"` IPC body. The window/webview/blur paths require a display and are
149+
exercised manually.
137150

138151
[`HtmlView`]: ../dashboard-core/overview.md#wire-types-srcpanrs
139152
[`WindowManager`]: #the-engine-windowmanager-srclibrs
153+
[`UserEvent`]: #threading-model-srcmainrs
154+
[`UserEvent::Resize`]: #threading-model-srcmainrs
140155
[`WindowManager::new`]: #the-engine-windowmanager-srclibrs
141156
[`apply_snapshot<T>(target, snapshot)`]: #the-engine-windowmanager-srclibrs
157+
[`resize(window, width, height)`]: #the-engine-windowmanager-srclibrs
142158
[`producer_gone(producer)`]: #the-engine-windowmanager-srclibrs
143159
[`window_closed(window_id)`]: #the-engine-windowmanager-srclibrs
144160
[`is_empty`]: #the-engine-windowmanager-srclibrs

packages/ix-windows/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ tao.workspace = true
2424
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "net", "io-util", "time"] }
2525
wry.workspace = true
2626

27-
# Native macOS window tuning via objc2: WebKit's private 120Hz experimental-
28-
# feature API and the `canBecomeKey`/`canBecomeMain` overrides that let a
29-
# borderless window tile under aerospace. Versions tracked to wry's own objc2
27+
# Native macOS overlay styling via objc2: the `NSVisualEffectView` blur behind
28+
# the transparent webview (objc2-app-kit) and WebKit's private 120Hz
29+
# experimental-feature API (objc2-web-kit). Versions tracked to wry's own objc2
3030
# graph.
3131
[target.'cfg(target_os = "macos")'.dependencies]
3232
objc2 = { workspace = true }
33+
objc2-app-kit = { workspace = true }
3334
objc2-foundation = { workspace = true }
3435
objc2-web-kit = { workspace = true }

packages/ix-windows/README.md

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ix-windows
22

3-
Render each live MCP resource as its own borderless, square, ghostty-styled
4-
native webview window.
3+
Render each live MCP resource as its own floating, blurred **overlay** webview
4+
window that auto-sizes to its content.
55

66
`ix-windows` is a standalone consumer of the dashboard producer stream. The MCP
77
already publishes every resource onto the producer sockets as an `html` pane
@@ -14,27 +14,25 @@ nix run .#ix-windows # watch the default discovery dir
1414
nix run .#ix-windows -- --dir /tmp/ixw
1515
```
1616

17+
## Overlay, not tiles
18+
19+
Each window is a chrome-less, always-on-top card floating above the desktop. No
20+
tiling, no layout manager.
21+
22+
- **Blur behind.** The `wry` webview is transparent and is painted on top of a
23+
native `NSVisualEffectView` (behind-window blur), so the overlay frosts
24+
whatever is behind it. The content lives in a faintly tinted, rounded `#ix-root`
25+
panel for legibility; the blur layer is rounded and shadowed to match.
26+
- **Auto-size to content.** There is no fixed window size. A `ResizeObserver` in
27+
the page measures the rendered panel and posts its pixel size over `wry`'s IPC
28+
channel; the OS window is grown or shrunk to fit (clamped to the monitor work
29+
area), so a window is exactly as big as the HTML it holds and expands as the
30+
content grows.
31+
- **Floating across spaces.** The window is always-on-top and joins all spaces /
32+
floats over fullscreen apps (`NSWindowCollectionBehavior`).
33+
1734
## macOS
1835

19-
- **Square corners.** Windows are borderless (`with_decorations(false)`), exactly
20-
like ghostty's `window-decoration = none`. The macOS window server only rounds
21-
*titled* windows, so dropping decorations gives square corners for free.
2236
- **120Hz.** WebKit's private experimental flag
2337
`PreferPageRenderingUpdatesNear60FPSEnabled` is disabled so the webview renders
2438
at the display's full refresh rate (ProMotion).
25-
26-
### Tiling under aerospace
27-
28-
A borderless window has no fullscreen button, so aerospace's dialog heuristic
29-
floats it by default. This is the same reason terminals like `kitty` and
30-
`alacritty` are special-cased in aerospace, and the reason `ghostty`'s bundle id
31-
is hardcoded to tile. `ix-windows` is a bare binary with no bundle id, so tiling
32-
relies on an `on-window-detected` rule matching the app name:
33-
34-
```toml
35-
[[on-window-detected]]
36-
if.app-name-regex-substring = 'ix-windows'
37-
run = 'layout tiling'
38-
```
39-
40-
yabai users want the equivalent `yabai -m rule --add app='^ix-windows$' manage=on`.

0 commit comments

Comments
 (0)