diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8428026..c06bfd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,17 @@ jobs: # why: lint the DEFAULT/shipping feature config too — CI only ever linted # the tray build, so a clippy regression on the default build slipped through. - run: cargo clippy --locked --all-targets -- -D warnings + # Overlay feature (macOS: real transparent PopUp window + objc2 panel harden). + - run: cargo build --locked --features overlay + - run: cargo clippy --locked --all-targets --features overlay -- -D warnings + - run: cargo clippy --locked --all-targets --features tray,overlay -- -D warnings # why: run the test suite — the command_palette fuzzy-match tests existed but # CI never executed them, so an agent could break scoring invisibly. - run: cargo test --locked + # why: the overlay unit tests (OverlayState/anchor math, JobStatus + HudState + # reducers) are gated behind --features overlay, so run them explicitly or they + # never execute. The reducers/anchor math are platform-independent — runs on Linux too. + - run: cargo test --locked --features overlay linux: runs-on: ubuntu-latest @@ -84,9 +92,16 @@ jobs: # why: lint the DEFAULT/shipping feature config too — CI only ever linted # the tray build, so a clippy regression on the default build slipped through. - run: cargo clippy --locked --all-targets -- -D warnings + # Overlay feature (Linux: compile-only no-op path — no LayerShell in v1). + - run: cargo build --locked --features overlay + - run: cargo clippy --locked --all-targets --features overlay -- -D warnings # why: run the test suite — the command_palette fuzzy-match tests existed but # CI never executed them, so an agent could break scoring invisibly. - run: cargo test --locked + # why: the overlay unit tests (OverlayState/anchor math, JobStatus + HudState + # reducers) are gated behind --features overlay, so run them explicitly or they + # never execute. The reducers/anchor math are platform-independent — runs on Linux too. + - run: cargo test --locked --features overlay # why: supply-chain gate (advisories / bans / sources / licenses) via cargo-deny. # deny.toml is already seeded and passes locally (`cargo deny check` is green). This lands diff --git a/Cargo.lock b/Cargo.lock index f74e40f..10ba808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,6 +1397,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "raw-window-handle", "serde", "serde_json", "tray-icon", diff --git a/Cargo.toml b/Cargo.toml index 405d5fd..7149188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,13 +59,22 @@ tray-icon = { version = "0.24", optional = true } [features] default = [] tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"] +# Floating overlay surfaces (transparent always-on-top PopUp window). macOS-only +# in v1; on Linux it compiles to a no-op (no LayerShell yet). Reuses the SAME +# objc2 stack as `tray` (no new compiled crate) and adds raw-window-handle (rwh +# 0.6 is already in the tree via gpui, so this is a `dep:` surfacing, not a new dep). +overlay = ["dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation", "dep:raw-window-handle"] -# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a -# Linux/Windows `--features tray` build never tries to compile Apple crates. +# objc2 is only used to hide the macOS dock icon (tray) and harden the overlay +# panel — pull it on macOS only, so a Linux/Windows `--features tray`/`overlay` +# build never tries to compile Apple crates. [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6", optional = true } objc2-app-kit = { version = "0.3", optional = true } objc2-foundation = { version = "0.3", optional = true } +# Recover the raw NSView from a gpui Window to harden the overlay panel (objc2 +# bridge). rwh 0.6.2 is already in Cargo.lock via gpui — no new compiled crate. +raw-window-handle = { version = "0.6", optional = true } [profile.release] strip = true diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index e89d5b3..6f98833 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -561,3 +561,35 @@ renders real rows. **4. Filter and search in memory.** The palette matches its registry with a synchronous, dependency- free fuzzy scorer — no I/O, no background thread per keystroke (§16). Do the same for your own pickers. + +--- + +## 18. Floating panels / hovering windows — transparent HUD surfaces {#overlay} + +Building a floating panel (a status rail, a recording pill, a HUD) means opening a *second* +always-on-top window and making it genuinely transparent. The whole working example is `src/overlay/` +(`--features overlay`, macOS; full spec in `docs/overlay.md`). The non-obvious knobs: + +- **Open it as a transparent `PopUp`.** `WindowKind::PopUp` (non-activating NSPanel, all-Spaces) + + `WindowBackgroundAppearance::Transparent` + `focus: false` + `titlebar: None`, sized to content and + anchored off `PlatformDisplay::visible_bounds()`. Never `cx.activate()` to summon it (that steals the + foreground app's activation). +- **Do NOT wrap the view in gpui-component `Root`.** `Root::render` paints `cx.theme().background` over + the *entire* window — that's the "dark box" that looks like a transparency failure but isn't. Return + the view entity **directly** from the `open_window` build closure. The cost: you lose Root's + tooltip/notification/modal layers — fine for a simple surface, so only add `Root` back if you need them. +- **Kill the window's OS shadow if your panel is heavily rounded.** gpui keeps a macOS window shadow on + transparent windows (it gives the window a near-opaque backing on purpose, *"to avoid broken shadow"*). + That shadow is a rounded-**rectangle** the size of the window — behind a `rounded_full` pill it shows + as a mismatched frame poking past the rounded ends. Disable it with `NSWindow::setHasShadow(false)` and + let the content render its **own** `shadow_lg` instead. A `rounded_xl` panel roughly matches the window + rect, so it can keep the window shadow. The knob here is `harden::harden_panel(window, hide_shadow)`: + the bottom pill passes `true` (frameless float), the top rail passes `false` (keeps the window shadow). +- **Stop clicks from stealing focus.** `PopUp` is non-activating, but a click still makes the panel + *key* — `setBecomesKeyOnlyIfNeeded(true)` on the NSPanel (also in `harden_panel`) keeps the focused app's + text caret. Both of these live in `src/overlay/harden.rs`, the one place with `unsafe` (the raw + `NSView → NSWindow` bridge), fenced with `// SAFETY:` + a scoped `#[allow(unsafe_code)]`. +- **Push state in from the background the usual way.** Stash a `WeakEntity` in a `Global`, + hop to the main thread, `upgrade()` (None = closed, a free no-op), mutate, `cx.notify()` — the + background-job spine (§17, `docs/background-jobs.md`). `WindowKind::PopUp` fires hover even while the + app is inactive, so `.hover()`/`.on_click()` on styled `div`s just work. diff --git a/docs/background-jobs.md b/docs/background-jobs.md new file mode 100644 index 0000000..856894f --- /dev/null +++ b/docs/background-jobs.md @@ -0,0 +1,185 @@ +# Background jobs in Deck + +How to run work that outlives a keystroke — a multi-turn agent talking to an API for +minutes, a listener that waits hours for a signal — without freezing the UI or pulling in +a second async runtime. + +**The omakase take:** GPUI already *is* your async runtime. Deck ships an opinionated, +zero-dependency job pattern built on it, and documents two escape hatches for when you want +the tokio ecosystem or a different stack. Pick the default, or swap a layer — nothing here +locks you in. + +> Performance rule first (see `docs/LEARNINGS.md` §17 and `CLAUDE.md`): **never block the +> render thread on I/O.** Everything below runs work off the UI thread and pushes results +> back with `cx.notify()`. That rule is why this doc exists. + +--- + +## 1. You already have a runtime — don't add one + +GPUI bundles a full executor (the same one Zed runs its editor, language servers, and agent +panel on). You do **not** need `tokio`, `async-std`, or a thread-pool crate for the runtime +itself. Verified API (`gpui/src/executor.rs`, `gpui/src/app/async_context.rs`): + +| You want | Call | Notes | +|---|---|---| +| Run hours-long / blocking / CPU work off the UI thread | `cx.background_executor().spawn(fut)` | future must be `Send + 'static` (`executor.rs:89`) | +| Run async work that touches UI state | `cx.spawn(async move \|cx\| { … })` → `AsyncApp` | main-thread, future need not be `Send` (`async_context.rs:204`) | +| Sleep / poll | `cx.background_executor().timer(Duration)` | returns `Task<()>` (`executor.rs:162`); **never** `std::thread::sleep` on the UI thread | +| Bounded wait | `executor.block_with_timeout(dur, fut)` | `executor.rs:364` | +| Fire-and-forget with error logging | `task.detach_and_log_err(cx)` | `TaskExt`, `executor.rs:33` | + +**`Task` is the handle, and dropping it cancels the work** (at the next `.await` point). +That gives you structured cancellation for free: hold the `Task` on an entity, drop the +entity (or replace the `Task`), and the job stops. Call `.detach()` only when you truly want +it to outlive its handle. `tray.rs:62-78` is the canonical "listen forever, push to UI" loop. + +--- + +## 2. The omakase pattern: a cancellable job that reports status + +The shape Deck recommends for any long-running job (an agent turn loop, a watcher, an +import): an entity owns a `Task` plus a status enum; the job runs on the **background** +executor and pushes status back through a **`WeakEntity` + `cx.notify()`** (the same spine +the overlay's agent-row uses — see `docs/overlay.md` Child #1). + +```rust +pub enum JobStatus { + Idle, + Running { turn: usize, note: SharedString }, + Retrying { attempt: u32, after: Duration }, + Failed(SharedString), + Done(SharedString), +} + +pub struct AgentJob { + status: JobStatus, + task: Option>, // dropping this cancels the run +} + +impl AgentJob { + pub fn start(&mut self, cx: &mut Context) { + let this = cx.entity().downgrade(); // WeakEntity + self.task = Some(cx.background_executor().spawn(async move { + let mut attempt = 0; + loop { + match run_one_turn().await { // your async API call + Ok(turn) => { + // hop back to the app to mutate UI state + repaint + let _ = this.update(/* AsyncApp */ cx, |job, cx| { + job.status = JobStatus::Running { turn, note: "ok".into() }; + cx.notify(); // smallest entity that changed + }); + if turn.is_final() { break; } + attempt = 0; + } + Err(e) if attempt < MAX_RETRIES => { + attempt += 1; + let backoff = retry_backoff(attempt); // pure, unit-tested below + // … set Retrying status via this.update(…) … + cx.background_executor().timer(backoff).await; + } + Err(e) => { /* set Failed via this.update(…); */ break; } + } + } + })); + // Note: the closure above needs an AsyncApp to call `this.update`; in real code use + // `cx.spawn(async move |cx| { … cx.background_executor().spawn(...) … })` so you hold + // an AsyncApp, or pass results out via a channel and apply them on the foreground. + } + + pub fn cancel(&mut self) { self.task = None; } // drop = cancel +} +``` + +Two true things to internalize: +- **`WeakEntity::upgrade()` returning `None` is your "UI is gone" signal** — pushes after the + window/entity closes simply no-op. No leaks, no panics. (This is exactly how the overlay + spec handles a closed HUD.) +- **`this.update(...)` returns a `Result`** — handle it (`?` or `let _ =`); a dropped + fallible result trips `unused_must_use` (denied in this repo). + +### Retry/backoff is pure and testable (zero-dep) + +Keep the backoff math out of the async closure so it unit-tests like +`command_palette::fuzzy()`: + +```rust +fn retry_backoff(attempt: u32) -> Duration { + // exp backoff, capped — swap for the `backoff` crate if you want jitter/decorrelation + let secs = (1u64 << attempt.min(6)).min(60); + Duration::from_secs(secs) +} + +#[cfg(test)] +mod tests { + #[test] fn backoff_grows_then_caps() { /* 2,4,8,…,60,60 */ } + #[test] fn status_transitions_terminal() { /* Failed/Done don't resume */ } +} +``` + +--- + +## 3. The HTTP / API client — the one real decision + +GPUI runs *futures* but ships **no HTTP client**. Deck is UI-only today, so this is +greenfield. Three stacks, each a deliberate tradeoff: + +| Option | How | Pros | Cons | Dep cost | +|---|---|---|---|---| +| **`gpui_tokio` + reqwest / official SDKs** *(recommended for agents)* | `gpui_tokio::init(cx)` once, then `Tokio::spawn(cx, async { reqwest … }) -> Task>` (`gpui_tokio.rs:55`) | Unlocks the mature tokio ecosystem: `reqwest`, `async-openai`, the Anthropic SDKs, streaming, TLS. Results come back as GPUI `Task`s. | Runs a second (tokio) runtime alongside GPUI's. New deps. | new: `gpui_tokio` (first-party) + `reqwest`/SDK — approval-gated (DoD #4) | +| **Blocking client on the bg pool** | `cx.background_executor().spawn(\|\| ureq::post(...))` | Dead simple, robust, no second runtime. The bg pool tolerates blocking. | A blocking call **can't cancel mid-flight** (drop only cancels at await points). No streaming ergonomics. | new: `ureq` — approval-gated | +| **Zed's `http_client`** | the `http_client` crate in the gpui stack | Already in the wider tree; matches Zed's abstraction. | Heavier, Zed-shaped API; more than a starter needs. | new: `http_client` | + +**Omakase pick:** `gpui_tokio` + `reqwest` (or the official Anthropic SDK) for anything +agent-shaped — multi-turn, streaming, cancellation all work cleanly. It's a first-party gpui +crate, so the bridge is low-risk; the SDK is your call. Reach for blocking `ureq` only for a +one-shot call where simplicity beats cancellability. + +**Escape hatch (the "pick your own stack" promise):** the job pattern in §2 is +client-agnostic — it only cares that `run_one_turn()` returns a `Future`. Swap reqwest for +ureq, or tokio for a smol-native client, by changing that one function. Nothing else moves. + +--- + +## 4. Cancellation, the honest version + +- **Async path:** hold the `Task`; drop it to cancel at the next `.await`. For mid-request + cancel, `futures::select!` the request against a cancel channel (`async-channel` / + `flume`), or use `tokio_util::sync::CancellationToken` when on the tokio bridge. +- **Blocking path:** a `ureq`/blocking call on the bg pool **cannot** be interrupted; it runs + to completion (or its own timeout). Set a request timeout; don't promise instant cancel. +- A **panic** inside a `Task` aborts that task only — the app survives — but there's **no + auto-restart**. Wrap the loop body, convert errors to `Failed`, and let the user retry. + +--- + +## 5. Listening for hours, and the macOS gotcha + +A watcher (a socket, a channel, `notify` for filesystem events, a unix signal via +`signal-hook`) is just a background task that awaits in a loop and `cx.update`s on each event +— identical to `tray.rs`'s menu-event drain. Hours-long is fine: GPUI's bg thread pool is +persistent, and a native desktop process keeps its threads alive (no mobile/web sandbox +killing you). + +**The one real platform caveat: macOS App Nap.** When your app is in the background, macOS +can throttle its timers and CPU (App Nap), which stalls a long agent run or a polling +listener. If you need guaranteed background progress, disable it via +`NSProcessInfo.processInfo.beginActivityWithOptions(.userInitiated | .idleSystemSleepDisabled, reason:)` +through objc2 — the same objc2 path `tray.rs:94-104` already uses for the dock policy. Hold +the returned activity token for the duration of the work, drop it when idle. Linux has no +equivalent throttle. + +--- + +## 6. What Deck ships vs. what you add + +- **Free, in the box (zero-dep):** the executor, `Task` cancellation, `timer`, the §2 job + pattern, pure retry/backoff logic. The runtime is *not* a dependency decision. +- **Approval-gated (DoD #4 — new deps):** any HTTP client, `gpui_tokio`, official agent SDKs, + `backoff`, `notify`, `signal-hook`, App-Nap objc2 (reuses the `tray` feature's objc2). Add + them behind a feature flag (mirror `--features tray`) so the default fork stays lean. + +The omakase default is: lean core + the §2 pattern, and you opt into a client stack when you +wire a real agent. That keeps "fork it, rename it, ship it" honest while leaving every stack +choice reversible. diff --git a/docs/overlay.md b/docs/overlay.md new file mode 100644 index 0000000..31459fd --- /dev/null +++ b/docs/overlay.md @@ -0,0 +1,648 @@ +# Spec: Floating overlay surfaces for Deck + +Status: DESIGN (reviewed) · Scope: macOS-only v1 · Audience: reusable Deck template flow +Branch: `hellno/hover-overlay-window` · Research backing: `.context/floating-overlay-design.md` (working notes, uncommitted) +Decision log: #4 (over-other-apps hardening) is a **v1 gate**; its P2 spike runs first. +Independent review: codex (gpt) scored the v1 draft **5/10** executability; this revision folds in its +ten findings (status-push root-type bug, P2 spike, resize wording, lifecycle, Linux/CI, dead-zone, objc2 +helper, unsafe/dep wording). Corrections are marked `[codex]` inline. + +> ## Redesign (2026-06-10) — two small transparent surfaces, no `Root` wrapper +> +> The original v1 below shipped **two oversized opaque dark rectangles** out of one +> 460×160 window (a top-right status row + a bottom Wispr-style toolbar/capsule/banner +> state machine). User feedback: too big, too many buttons, no hover, too much for a +> mock. The redesign replaces both with **two small, genuinely-transparent surfaces +> rendered together** — each its own content-sized transparent `PopUp` window: +> +> - **Top-right RAIL** (`src/overlay/rail.rs`) — a small frosted vertical panel: generic +> job-status icons → a thin divider → **3 square clickable action buttons** (basic +> hover). ≈76×300. +> - **Bottom-center PILL** (`src/overlay/pill.rs`) — a small frosted pill with a circular +> **record button** that pulses red while recording; toggled by click and by `space` +> while Deck is focused. ≈220×64. +> +> **Root cause of the old "dark box," now fixed:** the overlay wrapped its view in +> gpui-component `Root`, and `Root::render` paints an opaque `theme.background` over the +> whole window. The redesign drops `Root` — each window's `open_window` build closure +> returns the view entity directly — so the window stays transparent and only the frosted +> panels paint. The simplified surfaces need none of `Root`'s layers (no +> tooltips/notifications/modals), so skipping it costs nothing. See CHILD #2 / #3 below. +> +> **What was removed as over-built:** the single shared 460×160 canvas, the +> `HudState` dormant→toolbar→capsule→banner machine + `Transition` morphs, tooltips, the +> inline `Kbd` chips, and the `OverlayState` opacity-pulse show/hide workaround. The +> `overlay_anchor` user setting + `DECK_OVERLAY_ANCHOR` env override are gone too — +> `OverlayAnchor` survives only as an **internal positioning helper** in `state.rs` +> (rail = `TopRight`, pill = `BottomCenter`); `DECK_OVERLAY=0/1` remains the master on/off. +> The two `unsafe` blocks in `harden.rs` are unchanged; no new deps. +> +> The EPIC, CHILD #1, §3/§4 spike history, and the §4 SPIKE RESULT below are retained as +> accurate **history** (the transparent-PopUp primitive, the P2 focus spike, and the +> objc2 bridge all carry forward). Where they describe the *old* surfaces (one window, +> the HUD state machine, the anchor picker) as the **current** design, they are annotated +> or superseded by the redesign notes inline. + +All API claims verified against the pinned sources: +- GPUI `~/.cargo/git/checkouts/zed-a70e2ad075855582/86effff/crates/{gpui,gpui_macos}` +- gpui-component `~/.cargo/git/checkouts/gpui-component-95ce574d8a0da8b8/dadfca9` +- Deck: this repo. + +--- + +## EPIC — Floating overlay surfaces (always-on-top transparent HUD primitive + agent-row + Wispr pill) + +### Context +Deck opens exactly one normal window (`src/main.rs:134`). There is no way to show ambient, always-on-top status — the thing every async-agent tool (Wispr Flow, Raycast, Linear) leans on. This epic adds a **reusable overlay surface** to the starter: a transparent, borderless, always-on-top window (`WindowKind::PopUp`) you mount glanceable status/controls into, gated behind `--features overlay` so the default fork stays lean. The visual/foundation tiers need **no new crates and no app-level unsafe**; the over-other-apps interaction hardening is fenced into its own child (#4) and may carry one scoped `unsafe`. macOS-only for v1; a `#[cfg(target_os)]` seam is left for a later Linux/Wayland `LayerShell` pass but is **not** a v1 acceptance gate. + +### Child issues +| # | Title | Priority | Effort | Depends on | New crate / unsafe | +|---|-------|----------|--------|-----------|--------------------| +| 1 | Overlay **primitive** (`--features overlay`, transparent PopUp, edge-anchored, status-push spine, lifecycle) | Critical | ~2d | — | none / none | +| 2 | **Top-right rail** surface (generic job-status icons + divider + 3 square action buttons; no agent-specific UI) `[redesign]` | High | ~1d | #1 | none / none | +| 3 | **Bottom-center recording pill** surface (record button + `space`-while-focused toggle + red pulse) `[redesign]` | High | ~1d | #1 | none / none | +| 4 | **macOS interaction hardening** (no-steal-on-click + click-through) — **v1 GATE** for #3; the surfaces must work over other apps | Critical | spike + ~1.5d | #1 | none / likely 1 scoped unsafe | + +> `[redesign 2026-06-10]` #2 is now the top-right **rail** and #3 the bottom-center +> **recording pill** (two separate transparent windows, no `Root`). The old #3 HUD state +> machine (dormant→toolbar→recording→banner) is removed. See the redesign note above and +> the rewritten CHILD #2 / #3 sections. + +### Dependency graph +``` +#1 Overlay primitive ──> #4 SPIKE (P2: prove no-steal) ─┬─> #2 Status-icon row + └─> #3 Wispr HUD pill ──> #4 rest (P3 + harden) +``` +### Sequencing rationale +#1 is the shared window/anchoring/status-push/lifecycle foundation. **Because #4 is a v1 gate and its P2 mechanism is unproven** (`becomesKeyOnlyIfNeeded` on GPUI's single NSView), its spike runs **immediately after #1** — de-risk the over-other-apps requirement before investing in #3's full surface. If the spike fails, we learn it cheaply and renegotiate scope. #2 is still the cheapest *visual* proof of the animation + status-push spine (no focus caveats), so it proceeds in parallel once #1 lands; #3 follows, gated on #4's hardening. + +### Definition of Done (epic) +1. `cargo run --features overlay` shows a transparent always-on-top window with at least one live surface. +2. A background task pushes a state change into the overlay and it repaints (no UI-thread I/O). +3. **Over-other-apps (v1 gate, #4):** with another app foreground, clicking a HUD button does not move that app's text-field focus (P2), and clicks on transparent overlay pixels reach the app below (P3, per the chosen option). +4. Closing the main window tears the overlay down cleanly (no leaked task/Global); pushes after close are no-ops. `[codex #8]` +5. `just ci` green on default, `--features tray`, **and** `--features overlay` (+ combined `tray,overlay`), on macOS **and** Linux (Linux `overlay` compiles to a no-op). `[codex #9]` +6. Default `cargo run` is unchanged (feature additive, off by default). + +### Out of scope (epic) +Cursor-trail and screen-draw surfaces; global hotkeys; AX focused-textbox detection; text insertion into other apps; Linux/Wayland `LayerShell` impl; real audio/dictation. Documented Phase-3 follow-ups. + +### Rollback +Pure-additive + feature-gated: revert the PR or drop `--features overlay`. No migration, no shared-state change. + +--- + +## CHILD #1 — Overlay primitive (`--features overlay`) + +### Context +The foundation: open a second transparent, borderless, always-on-top window anchored to the active display, expose an `Entity` a background task can push into, manage its lifecycle, and gate it behind `--features overlay` mirroring `--features tray`. + +### Implementation details +- **Module layout:** `src/overlay/mod.rs` (`pub fn install(cx, &Settings)`), `src/overlay/state.rs` (`OverlayState`, `OverlayAnchor` enums + transitions; unit-tested like `command_palette.rs:217`/`:754`), `src/overlay/view.rs` (`struct Overlay` + `impl Render`). + **`[redesign 2026-06-10]`** After the redesign the module layout is: `mod.rs` (opens two + `Root`-less content-sized windows + the `ToggleRecording` action/`space` binding + weak + handles), `rail.rs` (top-right rail, **new**), `pill.rs` (recording pill, was `hud.rs`), + `status.rs` (pure `JobStatus` logic, was `status_row.rs`), `state.rs` (`OverlayAnchor` + positioning helper only — `OverlayState` removed), `harden.rs` (unchanged). `view.rs` is + deleted. +- **macOS-only window, Linux no-op `[codex #9]`:** put the window-opening body behind `#[cfg(target_os = "macos")]`; the non-macOS `install()` is an empty no-op. objc2 deps stay macOS-only (they already are: `Cargo.toml:65-68`). This keeps Linux `cargo clippy --features overlay` compiling green without a LayerShell impl. +- **Window:** `cx.open_window` (`gpui/src/app.rs:1136`). Inside the closure, build the overlay view first, then wrap it: + ```rust + let opts = WindowOptions { + kind: WindowKind::PopUp, // non-activating NSPanel + NSPopUpWindowLevel(101) + all-Spaces + titlebar: None, // borderless + focus: false, // don't grab focus on open + window_background: WindowBackgroundAppearance::Transparent, + is_movable: false, is_resizable: false, is_minimizable: false, + display_id: Some(display_id), + window_bounds: Some(WindowBounds::Windowed(bounds)), // sized once to MAX footprint + ..Default::default() + }; + let handle: WindowHandle = cx.open_window(opts, |window, cx| { + let overlay = cx.new(|cx| Overlay::new(anchor, window, cx)); // Entity + cx.set_global(OverlayHandle { overlay: overlay.downgrade(), window: window.window_handle() }); + cx.new(|cx| Root::new(overlay.clone().into(), window, cx)) // Root wraps the view + })?; + ``` + `PopUp` already yields non-activating panel + popup level + all-Spaces (`gpui_macos/src/window.rs:714,919,924-927`). Root wrap is required (`main.rs:135-137`) or tooltips/notifications no-op. + **`[redesign 2026-06-10]`** This is the precise trap the redesign fixes: `Root::render` + paints an opaque `theme.background` over the whole window (the dark box). `Root` is + required **only if you need its tooltip / notification / modal layers** — `YES` + permits them, it does not require them. The redesigned rail/pill use none of those + layers, so they **return the view entity directly** (no `Root`) and the window stays + transparent. Wrap in `Root` only when a surface actually needs those overlay layers. +- **Status-push spine `[codex #1 — the must-fix bug]`:** `cx.open_window` returns `WindowHandle`, NOT `WindowHandle` — the root is gpui-component `Root` (`root.rs:30`, it holds `view: AnyView`). So you **cannot** do `handle.update(|o| o.state = ...)`; that gives `&mut Root`. Instead stash a **`WeakEntity`** in a GPUI `Global` (`OverlayHandle`, mirroring `tray.rs:29-33` `TrayState`) and push through the *entity*: + ```rust + // background work then hop back to the main thread: + cx.background_executor().spawn(heavy).await; // app.rs:1714 + cx.update(|cx| { // AsyncApp::update, app.rs:1729 + if let Some(overlay) = cx.global::().overlay.upgrade() { + overlay.update(cx, |o, cx| { o.state = next; cx.notify(); }); + } + })?; // handle the Result (unused_must_use) + ``` + `WeakEntity::upgrade()` returning `None` is the natural no-op-after-close. `[codex #8]` This is the generic + background-job spine — see `docs/background-jobs.md` for the cancellation/retry/HTTP-client pattern the agent-row (#2) reuses. +- **Lifecycle `[codex #8]`:** subscribe to the main window's close (or `cx.on_window_closed`) and close the overlay with it; on overlay close, remove `OverlayHandle` from globals so later pushes find nothing and no-op; ensure the demo background task observes the weak handle and exits. Decide explicitly whether `cx.quit()` should fire when only the overlay remains (recommend: overlay never keeps the app alive — quit when the main window closes). +- **Anchoring + the resize question `[codex #3]`:** GPUI *does* expose `Window::resize()` (`window.rs:2217`), but it has no origin/bounds setter (`PlatformWindow`, `platform.rs:614-660`), so a resize anchored bottom-center would drift. v1 **chooses the fixed-canvas approach**: size the window once to the max footprint and animate a child inside it. Document the chosen **dead-zone budget** (see #4/P3). Compute the anchor rect from `cx.primary_display()`/`displays()` (`app.rs:1192,1197`) + `PlatformDisplay::visible_bounds()` (`platform.rs:262`). +- **Settings:** add `#[serde(default)]` `overlay_enabled: bool` + `overlay_anchor: OverlayAnchor` to `Settings` (`settings.rs:43-61`; update `Default` `:52-61`). Persist via `save_best_effort()` (`settings.rs:108`) at a commit boundary only. **`[redesign 2026-06-10]`** `overlay_anchor` is **removed** — only `overlay_enabled` remains a setting (see §5). +- **Wiring:** `#[cfg(feature="overlay")] mod overlay;` (`main.rs:15-16`) + `#[cfg(feature="overlay")] overlay::install(cx, &settings);` after the main `open_window` (`main.rs:142-143` pattern). Capture overlay fields before `settings` moves into the Shell closure (mirror `main.rs:60`). **Do not** call `cx.activate(true)` when summoning the overlay — that re-activates Deck and re-introduces P1. `[codex #6]` +- **Cargo/CI:** `overlay = []` in `[features]` (`Cargo.toml:60`). Add `--features overlay` (and `tray,overlay`) clippy runs to `justfile` `check`/`ci`/`fix` (`:25-42`) and the macOS CI job (`.github/workflows/ci.yml` ~44-51); on the Linux job add a **compile-only** `cargo clippy --features overlay` that exercises the no-op path (~82-89). Add `run-overlay: cargo run --features overlay`. + +### Acceptance criteria +1. `cargo run --features overlay` opens a transparent, borderless, always-on-top window pinned to the active display's chosen anchor; it floats over other apps and across Spaces. +2. Opening it does **not** deactivate the foreground app; with TextEdit/VS Code foreground, opening the overlay leaves their text caret active. `[codex #6]` +3. A demo background task flips `OverlayState` after N seconds via the `WeakEntity` and the window repaints; verified no `save()`/IO on the render path. +4. Closing the main window closes the overlay, clears `OverlayHandle`, and the demo task exits; a push issued after close is a no-op (no panic, no `Err` unwrap). `[codex #8]` +5. `OverlayState` transition logic has ≥3 unit tests (pattern: `command_palette.rs:754`). +6. Scenario checks pass: second monitor (anchors to the active display), enter/leave a fullscreen Space, display hot-unplug while open (no crash), sleep/wake, and `--features tray,overlay` combined. `[codex #10]` +7. `just ci` green: default, `--features tray`, `--features overlay`, on macOS and Linux (Linux overlay = no-op). Default `cargo run` unchanged. `unsafe_code` stays zero in #1; no `todo!()`/`dbg!()`; every `update`/`open_window` `Result` handled. + +### Testing +| Layer | What | Count | +|---|---|---| +| Unit | `OverlayState` transitions / anchor math | +3 | +| Manual | scenario checklist (criteria 1,2,4,6) | checklist | + +### Effort +~2d `[codex: +0.5d for lifecycle + scenarios]`: 3h window factory + anchoring · 3h status-push (WeakEntity/Global) + lifecycle · 2h settings + feature gate + Linux no-op + CI matrix · 3h tests + scenario verify. + +### Out of scope +Visible surface content beyond a placeholder (#2/#3); no-steal-on-click and click-through (#4). + +--- + +## CHILD #2 — Top-right rail surface (generic job status + action buttons) + +> **Redesign (2026-06-10).** This was the "status-icon row." It is now the **top-right +> rail** (`src/overlay/rail.rs`, a `Rail` view) — its own small transparent window, **no +> `Root` wrapper**. The generic job-status icons are kept as the top section; below a +> divider sit **3 square clickable action buttons** with a basic hover state. The +> "strictly generic, no agent/LLM-specific UI" rule still holds. + +> **Design decision (user):** strictly generic — the status section represents *any* async +> job, with **no agent/LLM-specific UI or demo**. It's the visual proof of the background-job +> spine (`docs/background-jobs.md`). "Agent" framing is intentionally out. + +### Context +A small frosted vertical panel, fixed top-right, sized to content (≈76×300). Three +stacked sections: ambient job-status icons (the spine proof — animate while running, +settle when done) → a thin divider → three square action buttons that demonstrate a +clickable hover control. The user asked for "larger icons or square buttons in a +vertical line that are clickable / have a basic hover state, that's it." + +### Implementation details +- **Window:** opened by `mod.rs` as a transparent `PopUp` at `OverlayAnchor::TopRight`, + with the view entity returned **directly** (`cx.new(|cx| Rail::new(..))`, no `Root`) so + the window stays transparent. Only the rounded frosted panel paints. +- **Frosted panel:** `v_flex().rounded_xl().border_1().border_color(theme.border) + .bg(theme.popover.opacity(0.85)).shadow_lg()`. No real blur/vibrancy (out of scope). +- **Section 1 — job-status icons:** one icon per `JobStatus` (the enum from + `docs/background-jobs.md` §2, now in `src/overlay/status.rs`). `Running` pulses its + opacity via `with_animation` + `pulsating_between` (`gpui/src/elements/animation.rs:52,247`); + `Done`/`Failed` settle static in distinct theme colors (`success`/`danger`). + `AnimationExt` is **not** in the prelude — `use gpui::{Animation, AnimationExt}`. The + animated arm is an opaque element — never attach `.id()`/`.on_click()` after + `with_animation`. Driven by the demo spine (the `WeakEntity` push from `mod.rs`). +- **Section 2 — divider:** a thin `div().h(px(1.0))` in `theme.border`. +- **Section 3 — 3 square action buttons:** styled `div`s, **not** gpui-component `Button` + (full control over the square shape + hover; avoids `Root`/tooltip coupling). + `div().id(..).size(px(34.0)).rounded_lg().bg(rest).hover(|s| s.bg(hover)).active(|s| + s.bg(press)).on_click(..)`. The generic set: + - **Pin** — a toggle (`IconName::Star` → `IconName::StarFill`) that flips a `pinned: bool` + and stays **visibly filled** (primary tint) while active. + - **Eye** and **Bell** — momentary buttons. + - Each click prints a one-line stderr marker (e.g. `overlay rail: pin -> true`, + `overlay rail: eye clicked`) so "clickable" is verifiable. + No tooltips, no `Kbd` chips. +- **Theme** via `cx.theme()` (`.popover`, `.border`, `.primary`, `.success`, `.danger`, + `.muted_foreground`); copy the colors out of the `&Theme` borrow before the button + `cx.listener` closures re-borrow `cx` as `&mut`. + +### Acceptance criteria +1. With `--features overlay`, the rail shows a small top-right frosted panel — **no + full-window dark box** (transparent pixels show the app/desktop behind). +2. A mock task marks a job "running" → its icon pulses; "done"/"failed" → it settles in a + distinct color, all from a background task (no UI-thread blocking). +3. The 3 square buttons each have a visible hover (bg tint) + press state and are + clickable; each click has an observable effect (stderr marker and/or, for pin, a + filled active state). Repaints only the rail entity (smallest-entity `cx.notify()`). +4. `just ci` green across the matrix. + +### Testing +| Layer | What | Count | +|---|---|---| +| Unit | `JobStatus` status reducer (kept in `status.rs`) | +2 | +| Manual | icons pulse while running, settle on done; buttons hover + click markers | checklist | + +### Out of scope +Any agent/LLM-specific UI (deliberately generic — wiring a real agent is downstream); +click-to-expand a job; persisted job history; tooltips / `Kbd` chips on the buttons. + +--- + +## CHILD #3 — Bottom-center recording pill surface + +> **Redesign (2026-06-10).** This was the "Wispr-style HUD pill," a +> dormant→toolbar→capsule→banner state machine with `HudState`, `Transition` morphs, +> tooltips, and `Kbd` chips. **All of that is removed** as over-built. It is now the +> **minimal recording pill** (`src/overlay/pill.rs`, a `RecordingPill` view) — its own +> small transparent window, **no `Root` wrapper**: a frosted pill with a single record +> button and a `recording: bool`. The user "liked the space-bar recording and the look of +> the individual elements," not the multi-state toolbar. + +### Context +A small frosted compact pill, fixed bottom-center, sized to content (≈220×64): a circular +record button that pulses/colors red while "recording." The minimal version of the +space-bar record mock — real audio/dictation stays Phase 3. + +### Implementation details +- **Window:** opened by `mod.rs` as a transparent `PopUp` at `OverlayAnchor::BottomCenter`, + with the view entity returned **directly** (`cx.new(|cx| RecordingPill::new(..))`, no + `Root`) so the window stays transparent. Only the rounded frosted pill paints. +- **Pill + record button:** `h_flex()` frosted container (`rounded_full`, translucent + `theme.popover`, border, shadow) holding one circular record button — a styled `div` + with `.id("pill-record")`, `.hover()`/`.active()` tints, `.on_click(..)`. The inner + record dot is a child: at rest a static `theme.muted_foreground` circle; while + `recording` a red (`theme.danger`) circle whose opacity **pulses** via `with_animation` + + `pulsating_between`. (`with_animation` returns an opaque element — it is the dot, a + child, never the clickable surface; the `.id()` button is the stateful parent that + carries `.on_click`.) +- **Toggle — two paths (Decision: "robustly work and make sense, don't overengineer"):** + 1. **Click** the record button → flips `recording` + `cx.notify()` (pure + `RecordingPill::toggled` reducer). + 2. A `ToggleRecording` action (`gpui::actions!`) bound to **`space`** (`cx.bind_keys` in + `mod.rs`), handled via `cx.on_action`, flips the pill through its weak handle + + `cx.notify()` — **while the Deck window is focused.** The binding is scoped to a + non-input context (`!Input && !NumberInput && !SearchPanel`) so a space typed in a + text field is never eaten. **No global hotkey** — over-other-apps space is Phase 3 + (overlay windows never hold focus, so it would need a global hotkey = real-app + territory). +- **P2 focus spike (kept):** the record-button click also calls + `harden::log_focus_state(window)`, logging the panel's `isKeyWindow` + frontmost app — + the objective signal for the over-other-apps verdict (see §4 SPIKE RESULT). + +### Acceptance criteria +1. With `--features overlay`, a small bottom-center frosted pill is visible — **no + full-window dark box** (transparent pixels show the app/desktop behind). +2. Clicking the record button toggles a visible recording state (red dot + pulse); + clicking again stops it. Pressing **`space` while the Deck window is focused** also + toggles it; a space typed in a text input does not. +3. Repaints only the pill entity (smallest-entity `cx.notify()` rule). +4. **Gated on #4 (v1):** with another app foreground, clicking the record button does not + steal its keyboard focus (logged via `log_focus_state`); transparent-area clicks pass + through. +5. `just ci` green across the matrix. + +### Testing +| Layer | What | Count | +|---|---|---| +| Unit | `RecordingPill::toggled` recording reducer | +1 | +| Manual | record click + `space`-while-focused toggle; red pulse; focus-spike log | checklist | + +### Out of scope +Real microphone/dictation; sending text to other apps; a global hotkey so `space` records +over other apps (Phase 3); click-without-focus-steal (#4); tooltips / `Kbd` chips; the +removed toolbar/capsule/banner states and language popover. + +--- + +## CHILD #4 — macOS interaction hardening (no-steal-on-click + click-through) + +> **v1 GATE (user decision):** the HUD must work over OTHER apps from day one (the true Wispr gesture). **The P2 spike runs immediately after #1, before #3's full build** — its central mechanism is unproven on GPUI's single-NSView model and must be demonstrated first; if it fails, scope is renegotiated rather than discovered late. `[codex MUST_FIX_FIRST]` + +### Three distinct OS-level problems (commonly conflated) + +| # | Problem | What the user sees | v1 state | +|---|---------|--------------------|----------| +| P1 | **App activation** — a normal window click makes Deck frontmost, deactivating the app you were in | Your editor visibly loses active-app chrome | ✅ solved by `WindowKind::PopUp` non-activating NSPanel (`gpui_macos/src/window.rs:714-715`) — **as long as we never call `cx.activate(true)` to summon it** `[codex #6]` | +| P2 | **Key-window focus steal** — even non-activating, clicking the panel makes it *key*, so the other app's focused text field loses first-responder | You click the mic, your keystrokes stop reaching your editor | ❌ GPUI hardcodes `canBecomeKeyWindow → YES` (`gpui_macos/src/window.rs:364-367`); needs work | +| P3 | **Click absorption** — clicking a *transparent* pixel of the overlay window consumes the event instead of passing it to the app below | Clicks "die" on the invisible overlay rectangle | ❌ no passthrough API in gpui | + +P2 and P3 are independent; fixing one does not fix the other. + +### P2 — Key-window focus steal (SPIKE FIRST) +**Candidate fix: per-instance `setBecomesKeyOnlyIfNeeded: YES`** on the panel (NOT class swizzling). It makes the panel become key only when a view in it reports `needsPanelToBecomeKey`. This is **not contradictory** with GPUI's hardcoded `canBecomeKeyWindow → YES` `[codex fact-correction]`: `YES` merely *permits* key status; `becomesKeyOnlyIfNeeded` gates the *click-to-key* behavior, so the two compose. + +**Why it's a spike, not an assertion `[codex #2]`:** GPUI renders the whole window into *one* custom `NSView`, not native `NSControl`s, so it is unproven that a focused GPUI text input will report `needsPanelToBecomeKey`. The spike must: +1. Open a `PopUp` overlay, apply `setBecomesKeyOnlyIfNeeded(true)`. +2. With TextEdit foreground holding a caret, click a HUD **button** → assert TextEdit keeps focus (P2 fixed). +3. Focus a GPUI **text input** inside the HUD → determine whether it can receive typing at all. If not, **document "no in-HUD text input"** as a known limitation (the Wispr model is global-hotkey-driven, so this is acceptable) and proceed; if yes, great. + +**Why NOT swizzle `canBecomeKeyWindow → NO`:** it's a class method on the shared `GPUIPanel` class (`gpui_macos/src/window.rs:125-129`), hitting every `PopUp`/`Floating`/`Dialog` window app-wide and brittle across `just bump-gpui`; it's also too strong (a never-key panel can never host text and never gets `keyDown:`). Per-instance is strictly safer. (Blast radius today is small — gpui-component opens only `WindowKind::Normal`, verified — but still.) + +### P3 — Click absorption / passthrough +**`setIgnoresMouseEvents` is whole-window, all-or-nothing** — turn it on and the HUD's own buttons stop receiving clicks. So it is only a tool for fully passive overlays (cursor trails / screen-draw-idle, out of v1 scope), never the interactive HUD. + +**v1 answer: size the window to content + a defined dead-zone budget.** `[codex #3,#4]` +- The fixed-canvas window must be sized to the **max** state footprint (no atomic origin/bounds setter; `resize()` exists but drifts when re-anchoring). Define a hard budget, e.g. **HUD ≤ 460×160 px**, banner text truncates/scrolls rather than spanning the screen. +- **Bottom-center is the worst place to absorb clicks** (dock, app toolbars). Mitigations, pick one in the spike: (a) cap each state's footprint and accept a small documented dead zone; (b) anchor the HUD a few px above the dock; (c) split dormant/expanded/banner into *separate* tightly-sized windows so the dead zone matches the visible pixels. Acceptance must include a "click the transparent area, the app below receives it" test for whatever option is chosen. + +**Dynamic per-region passthrough is blocked in pure GPUI `[codex #7]`:** `on_mouse_event::` exists (`window.rs:4284`), but once `setIgnoresMouseEvents(YES)` is set the window receives no events, so it can't detect the cursor re-entering an interactive region. This is a *GPUI-only* limitation — a native global event monitor (`NSEvent.addGlobalMonitorForEvents`) or a second always-passthrough window are viable later. Defer. + +### objc2 bridge — one main-thread helper, no stored pointers `[codex #5]` +Provide a single function, called once on the main thread after open: +``` +fn harden_panel(window: &Window) { // macOS only + // 1. window.window_handle() -> RawWindowHandle::AppKit { ns_view } (window.rs:5933; gpui_macos:1794) + // 2. ns_view.window -> the NSWindow/NSPanel (guard nil: detached view) + // 3. confirm it responds as NSPanel, then setBecomesKeyOnlyIfNeeded(true) + // never store the native pointer; do everything inside this call +} +``` +Threading: must run on the main thread (objc2 `MainThreadMarker`, as `tray.rs:99`). Nil-guard the detached-view case. Re-apply if the window is recreated (multi-monitor re-anchor). + +### Unsafe / dependency budget `[codex fact-corrections]` +- "No new crate" is accurate; but enabling `--features overlay` on macOS **does pull the existing** `objc2`/`objc2-app-kit`/`objc2-foundation` (already declared, macOS-only) — so the feature's macOS dep set grows even though no new crate name appears. +- **Likely one scoped `unsafe`:** recovering the `NSWindow` from the raw `NSView` and downcasting to `NSPanel` probably needs `msg_send!`/pointer work even if `setBecomesKeyOnlyIfNeeded` itself is a safe objc2-app-kit wrapper. Budget for a single `#[allow(unsafe_code)] // SAFETY:` block (the manifest is `deny`-not-`forbid` for exactly this, `Cargo.toml:23`). The spike resolves whether any unsafe is truly required. + +### Acceptance criteria (#4) +1. **Spike gate:** a written result proving (or disproving) that `setBecomesKeyOnlyIfNeeded(true)` keeps TextEdit's caret active when a HUD button is clicked, plus the in-HUD-text-input verdict. The rest of #4 proceeds only if the spike confirms P2 is achievable. +2. The overlay is sized to content within the dead-zone budget; a click on a transparent overlay pixel reaches the app below for the chosen P3 option. +3. Any `unsafe` carries `// SAFETY:` + scoped `#[allow(unsafe_code)]`, on the main thread, storing no native pointer; `clippy -D warnings` stays green. +4. `just ci` green across the matrix. + +### Effort +spike 0.5d + ~1.5d: 0.5d spike (P2 focus test + objc2-safe-wrapper check) · 0.5d `harden_panel` + main-thread guard · 0.5d footprint sizing + dead-zone/transparent-click audit. + +### Out of scope (#4) +Full-screen click-through layers; dynamic per-region passthrough; global cursor tracking; global hotkeys — all Phase 3. + +--- + +## Decision log (all resolved) +1. **#3 granularity:** keep #3 as one issue; split only if it balloons during implementation. +2. **#4 as v1 gate:** YES — the HUD must work over other apps; the P2 spike runs right after #1 to de-risk the riskiest unknown early. +3. **#2 framing:** strictly generic async-job status, **no agent/LLM-specific UI**. +4. **Doc:** promoted to `docs/overlay.md`, committed. +5. **Platform:** macOS-only v1; Linux `--features overlay` compiles to a no-op (LayerShell deferred). + +## Codex review — verdict & disposition +- Score: 5/10 (v1 draft) → revised above. +- Applied: #1 status-push root-type bug, #3 lifecycle, #4 P2-as-spike, P3 resize wording + dead-zone budget + bottom-center risk, objc2 helper, unsafe/dep wording, Linux/CI no-op, scenario acceptance criteria, "don't `cx.activate` on summon". + +--- + +# Implementation handoff (for a fresh agent thread) + +> **You are picking this up cold. Read §0–§2, then execute the §3 sequence in order. T0 (the spike) +> is mandatory and first — it decides whether the v1 scope is even feasible. Do not build #1's full +> surface before T0 returns PASS.** Everything you need is below or cited; you should not need to +> re-derive the design. + +## §0 — Orientation + +**What you're building.** A reusable "floating overlay surface" for Deck (a GPUI + gpui-component +macOS/Linux desktop-app *starter*): a transparent, borderless, always-on-top window you mount +ambient status/controls into, gated behind `--features overlay`. It exists so every fork gets a +first-class way to show async/background work (the thing Wispr Flow, Raycast, Linear lean on). The +visual reference is the Wispr Flow dictation HUD: a bottom-center pill that idles as a tiny handle, +expands on hover into a toolbar, morphs into a recording capsule, and drops a guidance banner. + +**Why these specific decisions** (see "Decision log" above): macOS-only v1 (Linux is a compiling +no-op); #4 (over-other-apps hardening) is a v1 gate, so its riskiest unknown (P2) is spiked first; +#2 is strictly generic job status with no agent-specific UI; the morph is done by animating a child +inside a fixed-size window (GPUI has no atomic window move/bounds setter). + +**Prime constraints (from `CLAUDE.md` — non-negotiable):** +- Definition of Done = `just ci` green (fmt + clippy `-D warnings` on **both** default and + `--features tray`, plus `cargo test`). Add `--features overlay` to that matrix. Paste evidence. +- **No new deps** without explicit approval. The overlay's objc2 calls reuse the **existing** + `objc2`/`objc2-app-kit`/`objc2-foundation` (already declared macOS-only under `--features tray`, + `Cargo.toml:65-68`) — surface them under `overlay` too via `dep:` entries; that's allowed (no new + crate). Anything else (HTTP client, gpui_tokio, etc.) is out of scope here. +- `unsafe_code` is **deny** (not forbid): a genuinely-needed block gets `// SAFETY:` + a scoped + `#[allow(unsafe_code)]`. Aim for zero; budget at most one in T0/T6 (the raw-handle bridge). +- Never block the render thread on I/O; `cx.notify()` the smallest entity; persist via + `Settings::save_best_effort()` off the hot path. (`docs/LEARNINGS.md` §17.) +- No `todo!()`/`dbg!()`; never drop a `Result` (`unused_must_use` denied). + +## §1 — Verified API quick-reference (don't re-research; all checked against the pinned source) + +Pinned source (this machine): GPUI `~/.cargo/git/checkouts/zed-a70e2ad075855582/86effff/crates/{gpui,gpui_macos}`, +gpui-component `~/.cargo/git/checkouts/gpui-component-95ce574d8a0da8b8/dadfca9`. (GPUI rev `86effffd…`, +component rev `dadfca9…` from `Cargo.lock`; on another machine locate via `cargo metadata`.) +Copyable examples live in `…/gpui/examples/`: `window_positioning.rs`, `animation.rs`, `opacity.rs`, +`painting.rs`, `popover.rs`, `move_entity_between_windows.rs`. + +| Need | Symbol / call | Location | +|---|---|---| +| Open a window | `cx.open_window(opts, \|window, cx\| …) -> WindowHandle` | `gpui app.rs:1136`; pattern `main.rs:134-138` | +| Always-on-top + non-activating + all-Spaces + hover-while-inactive | `WindowKind::PopUp` (NSPanel, `NSPopUpWindowLevel`=101, `CanJoinAllSpaces\|FullScreenAuxiliary`, `NSTrackingActiveAlways`) | `gpui_macos/src/window.rs:714-717,904-927,919` | +| Borderless | `titlebar: None` | `gpui_macos/src/window.rs:689-708` | +| Transparent bg | `WindowBackgroundAppearance::Transparent` | `gpui/src/platform.rs:1693` | +| Root wrap — **only** when you need tooltip/notification/modal layers (it paints an opaque `theme.background`, so the redesigned transparent surfaces skip it) | `Root::new(view, window, cx)` | gpui-component `root.rs:89,513-531`; `main.rs:135-137` | +| Active display + bounds (anchoring) | `cx.primary_display()`/`cx.displays()`; `PlatformDisplay::visible_bounds()` | `app.rs:1192,1197`; `platform.rs:262` | +| Window move/bounds | **none** (`resize()` exists `window.rs:2217` but drifts — use fixed canvas) | `platform.rs:614-660` | +| Run off UI thread (Send) | `cx.background_executor().spawn(fut)` | `executor.rs:89` | +| Run async touching UI | `cx.spawn(async move \|cx\| …)` → `AsyncApp` | `async_context.rs:204` | +| Timer / cancel | `…background_executor().timer(dur)`; **drop `Task` = cancel** | `executor.rs:162` | +| Raw NSWindow (for objc2) | `window.window_handle()` → `RawWindowHandle::AppKit{ ns_view }` → `[ns_view window]` | `window.rs:5933`; `gpui_macos/src/window.rs:1794-1798` | +| objc2 on main thread | `MainThreadMarker::new()` + `objc2_app_kit` (model: dock policy) | `tray.rs:94-104` | +| `canBecomeKeyWindow` hardcoded YES (the P2 problem) | `build_window_class` | `gpui_macos/src/window.rs:364-367` | +| Repeating animation / pulse | `with_animation` + `pulsating_between` (`AnimationExt` **not** in prelude) | `gpui/src/elements/animation.rs:52,247` | +| Expand/collapse morph | gpui-component `Transition` (`.width/.height/.fade/.slide_y`) | gpui-component `animation.rs` | +| Hover-to-expand | `.on_hover(&bool)` (Stateful — needs `.id()`) + `cx.notify()` | `gpui` div | +| Tooltip + shortcut chip | `Tooltip::new(..).action(&A, ctx).build(..)`; `Kbd`/`Kbd::binding_for_action` | gpui-component `tooltip.rs`, `kbd.rs` | +| Spinner | `Spinner` | gpui-component `spinner.rs` | +| Settings (add fields) | `Settings` struct + `Default` (`#[serde(default)]`); `save_best_effort()` | `settings.rs:43-61,108` | +| Feature flag + wiring | `[features] overlay = []`; `#[cfg(feature="overlay")] mod overlay;` + `overlay::install(cx,&settings)` | `Cargo.toml:60`; `main.rs:15-16,142-143` | +| Pure-logic unit-test pattern | `fuzzy()` + `#[cfg(test)] mod tests` | `command_palette.rs:217,754` | + +## §2 — Build / verify loop +- Type-check fast: `cargo check --features overlay`. Lint like CI: `cargo clippy --features overlay --all-targets -- -D warnings` (use `--message-format=short` for self-correction). +- Full gate before declaring any task done: **`just ci`** — and add `--features overlay` (+ `tray,overlay`) to `justfile` `check`/`ci`/`fix` (`:25-42`) and the CI jobs (`.github/workflows/ci.yml` macOS ~44-51, Linux ~82-89). Run the app: `cargo run --features overlay` (add a `run-overlay` recipe). +- `just fix` auto-applies clippy + fmt. Loop `just fix` → `just ci`. + +## §3 — The sequence + +### T0 — P2 SPIKE (do first; gates everything) 🔴 +**Question to answer:** does a GPUI `WindowKind::PopUp` window with `setBecomesKeyOnlyIfNeeded(true)` +on its NSPanel **keep keyboard focus in another app** when you click a button in the overlay? This is +unproven because GPUI renders into a *single* custom NSView (so AppKit's "does any subview need to +become key" logic is untested for GPUI widgets) and GPUI hardcodes `canBecomeKeyWindow=YES` +(`gpui_macos/src/window.rs:364-367`). If it fails, the over-other-apps v1 gate is infeasible as +specced — escalate before building more. + +**Build the minimum (this is also the seed of #1):** +1. Add `overlay = []` to `[features]`; create `src/overlay/mod.rs` with `#[cfg(feature="overlay")]` + wiring in `main.rs` (`mod` + `install(cx)` after the main `open_window`). Gate the window body + `#[cfg(target_os="macos")]`; non-macOS `install` = no-op. +2. Open a `WindowKind::PopUp`, transparent, borderless window ~320×120, anchored bottom-center of the + active display, wrapped in `Root::new`. Render one gpui-component `Button` ("Click me") that + increments a counter on a small `Entity` + a label showing the count (proves the panel still gets + mouse clicks). +3. Write `fn harden_panel(window: &Window)` (macOS): `window.window_handle()` → + `RawWindowHandle::AppKit{ ns_view }` → get `[ns_view window]` → call + `setBecomesKeyOnlyIfNeeded(true)`. Run on the main thread (`MainThreadMarker`, like `tray.rs:99`). + Nil-guard the detached-view case. Never store the native pointer. If objc2-app-kit lacks a safe + wrapper, use a single `// SAFETY:` + scoped `#[allow(unsafe_code)]` block. +4. **Instrument an objective signal** (so the verdict isn't only eyeballed): log, right after a button + click, the panel's `isKeyWindow` and `NSApp.isActive` (or + `NSWorkspace.frontmostApplication.localizedName`). Expectation on PASS: panel `isKeyWindow == false` + and the frontmost app stays the other app. + +**Test protocol (human-in-the-loop, ~5 min):** +1. Open TextEdit, click into a document so it has a blinking caret; type a few chars. +2. `cargo run --features overlay`. Overlay appears bottom-center; TextEdit stays frontmost. Type — text must still land in TextEdit (the window opened `focus:false`, non-activating). +3. **Click the overlay's "Click me" button.** Then type again. + - **PASS:** TextEdit keeps its caret and your keystrokes still land there; logs show panel `isKeyWindow == false`; the counter still incremented (button got the click). + - **FAIL:** TextEdit loses the caret / keystrokes stop landing / logs show the panel became key. + +**If FAIL — escalation ladder (try in order, document each):** +1. Confirm `harden_panel` actually ran on the real panel (log the class name; expect `GPUIPanel`/NSPanel). +2. Instance-swizzle just this panel's `canBecomeKeyWindow → NO` (objc2) and re-test; verify buttons + still click (a never-key panel that still gets mouse events = acceptable, at the cost of no in-HUD + text input — document that limitation). +3. If neither holds, the over-other-apps gesture can't be done purely in-process → **stop and report**: + the v1 #4 gate needs renegotiation (drive via a global hotkey in Phase 3, or make the HUD + display-only when over other apps). Do not silently proceed. + +**T0 acceptance:** a written **SPIKE RESULT** appended to §4 below (PASS/FAIL + the observed behavior + +which mechanism worked); button-click registers; `just ci` green (objc2 clean or scoped-unsafe). + +### T1 — #1 primitive proper (after T0 PASS) +Flesh the skeleton into the full primitive per **CHILD #1**: `OverlayState`/`OverlayAnchor` enums in +`src/overlay/state.rs` (+ ≥3 unit tests), the window factory + bottom-center/anchor math, `OverlayHandle` +Global holding `WeakEntity` + the `WindowHandle`, settings fields (`overlay_enabled`, +`overlay_anchor`) with `save_best_effort`. **Acceptance:** CHILD #1 criteria 1,2,5,7. + +### T2 — #1 status-push spine + lifecycle +The `WeakEntity` push from a background task (mock task flips state on a timer); close overlay +on main-window close; clear the Global on overlay close; pushes after close no-op (`upgrade()→None`); +don't `cx.activate(true)` on summon. **Acceptance:** CHILD #1 criteria 3,4 + lifecycle. + +### T3 — #1 CI matrix + scenario tests +Add `--features overlay` to justfile + both CI jobs (Linux = compile-only no-op). Run the scenario +checklist (multi-monitor, fullscreen Space, hot-unplug, sleep/wake, `tray,overlay`). **Acceptance:** +CHILD #1 criteria 6,7 green. + +> **`[redesign 2026-06-10]`** T4/T5 below are superseded by the rewritten CHILD #2 / #3. +> T4 is now the top-right **rail** (status icons + divider + 3 square action buttons); T5 +> is the bottom-center **recording pill** (record button + `space`-while-focused toggle + +> red pulse), **not** the HUD state machine. Both render as separate `Root`-less +> transparent windows. The verbatim steps below are kept as the original plan's history. + +### T4 — #2 status-icon row (strictly generic) +Top-right vertical row of icons, each a generic `JobStatus` (from `docs/background-jobs.md` §2), +animating while running via `with_animation`/`pulsating_between`, settling when done; driven by mock +generic jobs through the T2 spine. **No agent/LLM-specific UI.** **Acceptance:** CHILD #2. + +### T5 — #3 Wispr HUD pill +`HudState { Dormant, Expanded, Active{label,amplitudes}, Banner{text} }` + split render methods; morph +via `Transition`; hover-expand; toolbar Buttons + `Tooltip`+`Kbd` chips; waveform bars + pulse dot; +banner. Stay within the dead-zone budget (≤460×160). **Acceptance:** CHILD #3 (incl. criterion 6, +gated on T6). + +### T6 — #4 P3 hardening + finalize +Finalize `harden_panel` (P2 from T0) and the P3 footprint/transparent-click handling (size-to-content +within budget; verify a click on a transparent overlay pixel reaches the app below for the chosen +option). **Acceptance:** CHILD #4 criteria 1-4. + +## §4 — SPIKE RESULT + +**Status (2026-06-09): SCAFFOLD READY — focus verdict pending one ~5-min human run.** Everything +machine-verifiable is green; the one thing that needs a real macOS GUI session (does clicking a HUD +button over TextEdit keep TextEdit's caret) has not been observed in this headless build environment, +so it is **not** marked PASS. No result was fabricated. + +### What is built and compiles green (verified) +- **P2 mechanism wired:** `src/overlay/harden.rs::harden_panel()` recovers the `NSPanel` from gpui's + raw `NSView` (`HasWindowHandle` → `RawWindowHandle::AppKit` → `NSView::window()` → + `Retained::downcast::()`) and calls `panel.setBecomesKeyOnlyIfNeeded(true)` on the main + thread (`MainThreadMarker`), storing no native pointer. Called inside the `open_window` build closure. +- **objc2 fact-corrections vs. the spec draft:** the call chain uses **safe** objc2-app-kit 0.3.2 + wrappers (`window()`, `downcast`, `setBecomesKeyOnlyIfNeeded`) — **no `msg_send!`**. Exactly **one** + scoped `unsafe` per fn (`Retained::retain(ns_view_ptr)`), each with `// SAFETY:` + `#[allow(unsafe_code)]`. + `clippy -D warnings` stays green across the matrix; `unsafe_code` count = 2 (both in harden.rs, both + the raw-handle bridge — none elsewhere). +- **Objective instrumentation:** `log_focus_state()` logs the panel's `isKeyWindow` and + `NSWorkspace.frontmostApplication().localizedName()` after a click, wired into the HUD toolbar's + record button `on_click` — so the verdict is logged, not eyeballed. +- **No new crate / no lock churn:** the only Cargo.lock delta is a single `raw-window-handle` edge on + the `deck` package (rwh 0.6.2 was already in the graph via gpui); macOS-only, optional, under `overlay`. + +### Run protocol to fill the verdict (do this on macOS) +1. Open TextEdit, click into a document so it has a blinking caret; type a few chars. +2. `cargo run --features overlay` (the overlay opens bottom-center; default anchor = the HUD pill). + TextEdit must stay frontmost and keep accepting your keystrokes (window opened `focus:false`, + non-activating `PopUp`). +3. Hover the dormant handle → the toolbar expands. **Click the record button.** Then type again. + - **PASS:** TextEdit keeps its caret and keystrokes still land there; logs show + `panel isKeyWindow = false` and `frontmost app = "TextEdit"`; the click still registered. + - **FAIL:** TextEdit loses the caret / keystrokes stop / logs show the panel became key → + follow the §3 T0 escalation ladder (confirm `harden_panel` ran on the real `GPUIPanel`; then + try a per-instance `canBecomeKeyWindow → NO` swizzle; if neither holds, renegotiate the #4 gate). + +### Recommendation +The mechanism is the spec's recommended one and is now real, safe, and compiling. Proceed to the human +focus run to convert this to PASS/FAIL; treat the over-other-apps acceptance (CHILD #3 criterion 6, +CHILD #4 criteria 1–2) as **provisional** until that run is recorded here. + +--- + +# §5 — Configuration & Settings UI + +**Decision (2026-06-09, with the maintainer).** The v1 wiring shipped a confusing **double-gate**: +`--features overlay` (build) **and** an `overlay_enabled` setting that defaulted **off** with **no UI**, +so compiling the feature showed nothing — a dead runtime gate that only annoyed whoever works on the +template, with no benefit to forkers. It is also inconsistent with `--features tray`, which gates on the +build flag alone. This section locks the replacement. **The gating fix (default-on + env override) is +implemented; the in-app Settings UI with live apply is the remaining deferred work.** + +> **Redesign update (2026-06-10).** There is no longer one overlay window with a +> user-chosen anchor — there are **two fixed surfaces** (top-right rail + bottom-center +> pill). So the **anchor is no longer a user preference**: the `overlay_anchor` setting, +> its `DECK_OVERLAY_ANCHOR` env override, and the planned **anchor picker** are all +> **removed**. `OverlayAnchor` survives only as an **internal positioning helper** in +> `src/overlay/state.rs` (rail = `TopRight`, pill = `BottomCenter`). `overlay_enabled` + +> `DECK_OVERLAY=0/1` are retained as the master on/off for both surfaces. The deferred +> work below is reframed from "anchor picker" to **per-surface show/hide** (toggle the +> rail and the pill independently), still deferred. + +### Locked model +- **The Cargo feature is the ONLY compile-time gate.** `--features overlay` decides whether the overlay + code + its macOS-only `objc2` / `raw-window-handle` deps compile — exactly like `--features tray`. + The feature flag never doubles as a *runtime* enable gate. +- **Runtime preferences are real, persisted, and applied live** (the "complete, forkable feature" + pattern, not a dead gate): + - `overlay_enabled: bool` — **defaults `true`** (so `cargo run --features overlay` shows the overlay + immediately, mirroring how `--features tray` just works). It is the single persisted "show overlay" + preference (covers both surfaces), flipped by a **Settings UI toggle**. Toggling it + **opens/closes the live overlay windows** — no restart. + - ~~`overlay_anchor: OverlayAnchor`~~ — **removed (redesign).** The two surfaces have + fixed anchors; there is no user anchor choice. `OverlayAnchor` is now an internal + `state.rs` helper only. +- **Env override for quick dev/test runs (and a fork hook), no JSON editing** — read in `overlay::install`: + - `DECK_OVERLAY=1|0|true|false` — force the overlay on/off for this run. + - ~~`DECK_OVERLAY_ANCHOR=…`~~ — **removed (redesign):** no anchor to pick. + Unset → use the persisted setting. Unrecognized → warn on stderr + fall back. The parser is pure + unit-tested. + +### Implemented (the gating fix) +1. **`settings.rs`** — `overlay_enabled` defaults `true`, so `cargo run --features overlay` shows the + overlay immediately; no JSON editing. The Cargo feature is the only *build* gate. +2. **`overlay/mod.rs`** — `install` computes the effective `enabled` flag from the setting, overridable by + the `DECK_OVERLAY` env var above (pure `parse_*` helper + tests). +3. **`main.rs`** — the `#[cfg(feature = "overlay")] overlay::install(...)` call honors the + (default-on) `overlay_enabled` for the initial open. + +### Deferred (the in-app Settings UI + live apply) +4. **`overlay/mod.rs`** — add runtime controls reusing the existing window factory + `harden_panel`: + `show(cx)` / `hide(cx)` (or one `apply_settings(cx, &Settings)`). `hide` closes the overlay windows + + clears the handle(s); `show` re-runs the factory + re-stores the global(s). **Re-apply `harden_panel` + on every (re)open** (also the multi-monitor re-anchor case). The existing `on_window_closed` teardown + must still hold; the overlay still never calls `cx.quit()` / `cx.activate()`. Optional follow-up: + **per-surface on/off** — show/hide the rail and the pill independently (e.g. `show_rail` / `show_pill` + bools) now that they are separate windows. +5. **`settings_view.rs`** — add an **Overlay** section, compiled only under `#[cfg(feature = "overlay")]`, + with a "Show overlay" toggle (and, as the per-surface follow-up, separate rail/pill toggles — **no + anchor picker**). On change: persist via `save_best_effort()` (off the hot path) **and** call the live + `show`/`hide`. Wire through `Shell` like the existing theme/accent controls (mirror how `set_accent` + already calls into a global / `tray::set_accent`). The env override above stays as the headless/dev path. +6. **Tests/CI** — matrix unchanged; add a pure unit test for any new reducer-style state if introduced. + +### Why this is the right template pattern +Forkers get exactly one obvious switch — the Cargo feature — to include/exclude the capability **and its +deps**, plus a complete, copyable example of a persisted, Settings-driven, **live-applied** runtime +preference: the precise shape they will reuse for their own toggleable surfaces. No dead gates, and +consistent with `--features tray`. diff --git a/justfile b/justfile index 1dc4b39..291e10f 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,10 @@ run-release: run-tray: cargo run --features tray +# Run with the floating overlay surface (transparent always-on-top window). +run-overlay: + cargo run --features overlay + # Format the code. fmt: cargo fmt @@ -25,6 +29,8 @@ fmt: check: cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets --features tray -- -D warnings + cargo clippy --locked --all-targets --features overlay -- -D warnings + cargo clippy --locked --all-targets --features tray,overlay -- -D warnings # The full CI gate, locally — the whole Definition of Done in one command. Run this before you # call a change done; it mirrors .github/workflows/ci.yml so green here == green in CI. @@ -32,13 +38,18 @@ ci: cargo fmt --all --check cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets --features tray -- -D warnings + cargo clippy --locked --all-targets --features overlay -- -D warnings + cargo clippy --locked --all-targets --features tray,overlay -- -D warnings cargo test --locked + cargo test --locked --features overlay # Auto-fix everything fixable: clippy's machine-applicable suggestions + formatting. # Re-run `just ci` afterwards to confirm green. (--allow-dirty so it works mid-edit.) fix: cargo clippy --fix --allow-dirty --allow-staged --all-targets cargo clippy --fix --allow-dirty --allow-staged --all-targets --features tray + cargo clippy --fix --allow-dirty --allow-staged --all-targets --features overlay + cargo clippy --fix --allow-dirty --allow-staged --all-targets --features tray,overlay cargo fmt # Bump the git GPUI stack to the latest upstream commits, then rebuild. diff --git a/src/main.rs b/src/main.rs index 1db956a..f0e4b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,8 @@ //! bundle identifier, swap `assets/icon.png`, then start editing the views. mod command_palette; +#[cfg(feature = "overlay")] +mod overlay; mod settings; mod settings_view; mod shell; @@ -58,6 +60,10 @@ fn main() { let settings = Settings::load(); #[cfg(feature = "tray")] let accent = settings.accent; + // Snapshot the overlay-relevant settings BEFORE `settings` moves into the + // Shell closure (mirrors how `accent` is captured above for tray). + #[cfg(feature = "overlay")] + let overlay_settings = settings.clone(); theme::install(cx, settings.accent, settings.theme_mode.to_gpui()); // 3. Keyboard shortcuts. `secondary` = ⌘ on macOS, Ctrl on Linux / @@ -131,17 +137,26 @@ fn main() { ..Default::default() }; - cx.open_window(options, move |window, cx| { - let view = cx.new(|cx| Shell::new(settings, window, cx)); - cx.new(|cx| Root::new(view, window, cx)) - }) - .expect("failed to open window"); + let main_window = cx + .open_window(options, move |window, cx| { + let view = cx.new(|cx| Shell::new(settings, window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }) + .expect("failed to open window"); // Optional: native menu-bar tray icon + dock hiding (`--features tray`). // The tray icon uses the saved accent and restyles live when changed. #[cfg(feature = "tray")] tray::install(cx, accent); + // Optional: floating overlay surface (`--features overlay`). Threads the + // main window handle so closing it tears the overlay down too. Must NOT + // call cx.activate (that would re-activate Deck and steal focus). + #[cfg(feature = "overlay")] + overlay::install(cx, &overlay_settings, main_window); + #[cfg(not(feature = "overlay"))] + let _ = main_window; + cx.activate(true); }); } diff --git a/src/overlay/harden.rs b/src/overlay/harden.rs new file mode 100644 index 0000000..51f1f8a --- /dev/null +++ b/src/overlay/harden.rs @@ -0,0 +1,105 @@ +//! macOS panel hardening (P2): stop the overlay panel stealing key-window focus on +//! click, optionally drop the panel's OS window shadow (so a `rounded_full` pill isn't +//! framed by the window's rounded-rectangle shadow), plus T0 spike instrumentation. +//! See `docs/overlay.md` CHILD #4. +//! +//! The bridge recovers the live NSView from gpui's `HasWindowHandle` raw handle, +//! walks to its NSWindow, downcasts to NSPanel, and flips `becomesKeyOnlyIfNeeded`. +//! Everything but the raw-pointer `Retained::retain` recovery uses safe objc2-app-kit +//! wrappers; the single unsafe is fenced with `// SAFETY:` + scoped `#[allow]`. + +#[cfg(target_os = "macos")] +pub fn harden_panel(window: &gpui::Window, hide_shadow: bool) { + harden_panel_macos(window, hide_shadow); +} + +#[cfg(not(target_os = "macos"))] +pub fn harden_panel(_window: &gpui::Window, _hide_shadow: bool) {} + +#[cfg(target_os = "macos")] +fn harden_panel_macos(window: &gpui::Window, hide_shadow: bool) { + use objc2::rc::Retained; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSPanel, NSView}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + if MainThreadMarker::new().is_none() { + return; + } + // GOTCHA: `Window` has an inherent `window_handle() -> AnyWindowHandle` that + // shadows the rwh trait method. We need the trait one (the raw NSView), so call + // it through `HasWindowHandle` explicitly. + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view_ptr = appkit.ns_view.as_ptr().cast::(); + // SAFETY: `appkit.ns_view` is gpui's live MacWindow native NSView (gpui_macos + // HasWindowHandle), valid for this window's lifetime, which outlives this + // synchronous main-thread call. `retain` takes a +1 reference without stealing + // gpui's ownership; the Retained drops at scope end. We store no raw pointer. + // NSView/NSWindow are MainThreadOnly; the marker check above guarantees we are on + // the main thread. + #[allow(unsafe_code)] + let Some(view) = (unsafe { Retained::retain(ns_view_ptr) }) else { + return; + }; + let Some(ns_window) = view.window() else { + return; + }; + let Ok(panel) = ns_window.downcast::() else { + return; + }; + panel.setBecomesKeyOnlyIfNeeded(true); + if hide_shadow { + // Drop the macOS window shadow for this panel. gpui gives every transparent + // window a near-opaque backing so the OS still draws a shadow (gpui_macos + // window.rs: "avoid broken shadow"), but that shadow is a rounded *rectangle* + // the size of the window — it shows as a mismatched frame behind a `rounded_full` + // pill. Killing it lets the pill's own rendered `shadow_lg` be the only depth cue. + // The rail passes `false` and keeps its window shadow. + panel.setHasShadow(false); + } +} + +/// Spike instrumentation (T0): log the panel's key state + the frontmost app so the +/// PASS/FAIL verdict is objective, not eyeballed. Call from a HUD button's on_click. +#[cfg(target_os = "macos")] +pub fn log_focus_state(window: &gpui::Window) { + use objc2::rc::Retained; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSPanel, NSView, NSWorkspace}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + if MainThreadMarker::new().is_none() { + return; + } + // Use the rwh trait method explicitly (see `harden_panel_macos`). + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view_ptr = appkit.ns_view.as_ptr().cast::(); + // SAFETY: same invariant as harden_panel_macos above. + #[allow(unsafe_code)] + let Some(view) = (unsafe { Retained::retain(ns_view_ptr) }) else { + return; + }; + let Some(ns_window) = view.window() else { + return; + }; + if let Ok(panel) = ns_window.downcast::() { + eprintln!("overlay spike: panel isKeyWindow = {}", panel.isKeyWindow()); + } + let ws = NSWorkspace::sharedWorkspace(); + if let Some(app) = ws.frontmostApplication() { + eprintln!("overlay spike: frontmost app = {:?}", app.localizedName()); + } +} + +#[cfg(not(target_os = "macos"))] +pub fn log_focus_state(_window: &gpui::Window) {} diff --git a/src/overlay/mod.rs b/src/overlay/mod.rs new file mode 100644 index 0000000..df47a6f --- /dev/null +++ b/src/overlay/mod.rs @@ -0,0 +1,283 @@ +//! Floating overlay surfaces (`--features overlay`). +//! +//! Two small, genuinely-transparent, borderless, always-on-top windows +//! (`WindowKind::PopUp`): a top-right job-status + action-button **rail** and a +//! bottom-center recording **pill**. macOS-only in v1; Linux compiles to a no-op +//! (no LayerShell yet). See `docs/overlay.md` for the full design. +//! +//! Each window's `open_window` build closure returns the view entity DIRECTLY (no +//! gpui-component `Root` wrapper) so the window stays transparent — `Root` paints an +//! opaque theme background, which was the old "dark box". The simplified surfaces +//! need no tooltips/notifications/modals, so `Root` is unnecessary. +//! +//! Spine (the generic background-job pattern, `docs/background-jobs.md`): push state +//! into the rail from a background task via a `WeakEntity` stashed in a +//! GPUI `Global`, then `cx.notify()`. `upgrade()` returning `None` is the natural +//! no-op after the overlay closes — no panic, no leaked task. + +mod harden; +mod pill; +mod rail; +mod state; +mod status; + +#[cfg(target_os = "macos")] +use gpui::AppContext; +#[cfg(target_os = "macos")] +use state::OverlayAnchor; + +// The recording toggle action. Declared cross-platform (acceptance criterion #8: the +// action + its keybinding must compile on Linux); the *handler* only does work on +// macOS, where the pill window exists. +gpui::actions!(deck, [ToggleRecording]); + +// Globals holding a weak handle to each live overlay view (for background pushes + +// the recording toggle) plus the window's root handle (held for lifecycle/teardown). +// macOS-only: Linux never opens the windows, so constructing these would be dead code. +#[cfg(target_os = "macos")] +struct RailHandle { + rail: gpui::WeakEntity, + // `#[allow(dead_code)]`: held for lifecycle/teardown — the `on_window_closed` + // subscription owns its own `WindowHandle` copy and drives the actual teardown, + // so this stashed handle isn't read back yet. Mirrors the old `OverlayHandle.window`. + #[allow(dead_code)] + window: gpui::WindowHandle, +} + +#[cfg(target_os = "macos")] +impl gpui::Global for RailHandle {} + +#[cfg(target_os = "macos")] +struct PillHandle { + pill: gpui::WeakEntity, + // `#[allow(dead_code)]`: held for lifecycle/teardown (see `RailHandle.window`). + #[allow(dead_code)] + window: gpui::WindowHandle, +} + +#[cfg(target_os = "macos")] +impl gpui::Global for PillHandle {} + +/// Install the overlay surfaces if enabled. Threads the MAIN window handle so closing +/// the main window tears the overlay down with it. +pub fn install( + cx: &mut gpui::App, + settings: &crate::settings::Settings, + main_window: gpui::WindowHandle, +) { + // Effective config = the persisted setting, overridable per-run by an env var so + // you can flip the overlay without editing settings.json (handy while developing, + // and a ready-made hook a fork can keep, extend, or delete): + // DECK_OVERLAY=1|0|true|false force the overlay on/off + let enabled = env_override_bool("DECK_OVERLAY").unwrap_or(settings.overlay_enabled); + + if !enabled { + let _ = main_window; + return; + } + + // Recording toggle: click the pill's record button (primary), OR press `space` + // while Deck is the focused app. The binding is context-scoped so it never eats a + // space typed into a text field. Registered cross-platform (it must compile on + // Linux); the handler only finds a pill on macOS, where one exists. + cx.bind_keys([gpui::KeyBinding::new( + "space", + ToggleRecording, + Some("!Input && !NumberInput && !SearchPanel"), + )]); + cx.on_action(|_: &ToggleRecording, cx: &mut gpui::App| { + #[cfg(target_os = "macos")] + if let Some(pill) = cx.try_global::().and_then(|h| h.pill.upgrade()) { + pill.update(cx, |p, cx| p.toggle(cx)); + } + #[cfg(not(target_os = "macos"))] + let _ = cx; + }); + + #[cfg(target_os = "macos")] + install_macos(cx, main_window); + #[cfg(not(target_os = "macos"))] + { + // Linux/Windows: compile-only no-op (no LayerShell in v1). + let _ = (cx, main_window); + } +} + +/// Read a boolean env override, warning (and falling back to the setting) on an +/// unrecognized value so a typo isn't swallowed silently. +fn env_override_bool(key: &str) -> Option { + let raw = std::env::var(key).ok()?; + let parsed = parse_bool_override(&raw); + if parsed.is_none() { + eprintln!("deck: ignoring {key}={raw:?} (expected 1/0/true/false)"); + } + parsed +} + +/// Pure parse of a truthy/falsy override string. Unknown → `None` (caller falls back). +fn parse_bool_override(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "yes" => Some(true), + "0" | "false" | "off" | "no" => Some(false), + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn install_macos(cx: &mut gpui::App, main_window: gpui::WindowHandle) { + // Anchor both surfaces to the display the MAIN window is on (the active display), + // falling back to the primary. `visible_bounds()` excludes the dock/menu bar — so a + // BottomCenter pill clears the dock — and it carries the display's real origin + // (macOS `bounds()` zeroes the origin, which would otherwise force every overlay + // onto the primary display). + let active_display = main_window + .update(cx, |_, window, cx| window.display(cx)) + .ok() + .flatten(); + let Some(display) = active_display.or_else(|| cx.primary_display()) else { + return; + }; + let vis = display.visible_bounds(); + + // Windows sized to their content (not the old 460x160), shrinking the + // transparent-pixel dead-zone around each surface. + let rail_canvas = gpui::size(gpui::px(76.0), gpui::px(300.0)); + // The pill is content-sized and centered in this window with its OWN `shadow_lg`; + // the window's OS shadow is disabled (see `harden_panel(.., true)`), so this window + // is invisible except the pill. Leave margin around the pill for its rendered shadow. + let pill_canvas = gpui::size(gpui::px(190.0), gpui::px(64.0)); + let rail_origin = OverlayAnchor::TopRight.origin(vis, rail_canvas, gpui::px(16.0)); + let pill_origin = OverlayAnchor::BottomCenter.origin(vis, pill_canvas, gpui::px(16.0)); + + // Shared window flags: transparent, borderless, non-activating PopUp panels that + // never resize/minimize and never appear in the dock or window list. + let window_opts = |origin, size| gpui::WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds { origin, size })), + titlebar: None, + focus: false, + kind: gpui::WindowKind::PopUp, + is_movable: false, + is_resizable: false, + is_minimizable: false, + display_id: Some(display.id()), + window_background: gpui::WindowBackgroundAppearance::Transparent, + ..Default::default() + }; + + // RAIL window — return the view entity DIRECTLY (no `Root` wrapper) so the window + // stays transparent; the view paints only the frosted panel. + let rail_opts = window_opts(rail_origin, rail_canvas); + let Ok(rail_handle) = cx.open_window(rail_opts, |window, cx| { + let rail = cx.new(|cx| rail::Rail::new(window, cx)); + let wh = window + .window_handle() + .downcast::() + .expect("overlay rail window root is Rail"); + cx.set_global(RailHandle { + rail: rail.downgrade(), + window: wh, + }); + // Keep the rail's window shadow (its `rounded_xl` panel matches the window's + // rounded-rect shadow, so there's no mismatched frame). + crate::overlay::harden::harden_panel(window, false); + rail + }) else { + return; + }; + + // PILL window — same transparent, no-`Root` treatment. + let pill_opts = window_opts(pill_origin, pill_canvas); + let Ok(pill_handle) = cx.open_window(pill_opts, |window, cx| { + let pill = cx.new(|cx| pill::RecordingPill::new(window, cx)); + let wh = window + .window_handle() + .downcast::() + .expect("overlay pill window root is RecordingPill"); + cx.set_global(PillHandle { + pill: pill.downgrade(), + window: wh, + }); + // Drop the pill window's OS shadow — the pill is `rounded_full` and renders its + // own `shadow_lg`, so the window's rounded-rect shadow would just be a mismatched + // frame behind it. + crate::overlay::harden::harden_panel(window, true); + pill + }) else { + // Keep install transactional: the pill failed to open, so tear down the rail we + // just opened (and its global). The redesign shows both surfaces together or + // neither — never a lone rail. + let _ = rail_handle.update(cx, |_, window, _| window.remove_window()); + if cx.has_global::() { + let _ = cx.remove_global::(); + } + return; + }; + + // Lifecycle. `on_window_closed` is a single global subscription that fires for ANY + // window close with its `WindowId`, so we filter by id: + // - rail/pill window closed -> clear that global (later pushes upgrade() -> None); + // - main window closed -> close both overlay windows too. + // The overlay NEVER keeps the app alive and NEVER calls cx.quit()/cx.activate(). + let rail_id = rail_handle.window_id(); + let pill_id = pill_handle.window_id(); + let main_id = main_window.window_id(); + cx.on_window_closed(move |cx, closed_id| { + if closed_id == main_id { + // Close both overlay windows; ignore the Result (already-closed is fine). + let _ = rail_handle.update(cx, |_, window, _| window.remove_window()); + let _ = pill_handle.update(cx, |_, window, _| window.remove_window()); + } else if closed_id == rail_id { + if cx.has_global::() { + let _ = cx.remove_global::(); + } + } else if closed_id == pill_id && cx.has_global::() { + let _ = cx.remove_global::(); + } + }) + .detach(); + + // Demo push task: proves the WeakEntity + notify spine off the UI thread. A + // background timer hops back to the main thread, upgrades the rail's weak handle, + // advances the generic job statuses, and notifies. The pill has NO demo — it + // toggles on click/space only. Exits cleanly once the rail is gone (no leak). + cx.spawn(async move |cx: &mut gpui::AsyncApp| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + let done = cx.update(|cx| { + let Some(rail) = cx.try_global::().and_then(|h| h.rail.upgrade()) + else { + return true; + }; + rail.update(cx, |r, cx| { + crate::overlay::status::demo_advance_jobs(&mut r.jobs); + cx.notify(); + }); + false + }); + if done { + // Rail gone -> task exits (no leak). + break; + } + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::parse_bool_override; + + #[test] + fn parse_bool_override_accepts_common_spellings_and_rejects_junk() { + for s in ["1", "true", "TRUE", " on ", "yes"] { + assert_eq!(parse_bool_override(s), Some(true), "{s:?}"); + } + for s in ["0", "false", "Off", "no"] { + assert_eq!(parse_bool_override(s), Some(false), "{s:?}"); + } + assert_eq!(parse_bool_override("maybe"), None); + assert_eq!(parse_bool_override(""), None); + } +} diff --git a/src/overlay/pill.rs b/src/overlay/pill.rs new file mode 100644 index 0000000..1139e07 --- /dev/null +++ b/src/overlay/pill.rs @@ -0,0 +1,131 @@ +//! Bottom-center recording pill (`--features overlay`). Anchored bottom-center. +//! +//! A single frosted, borderless pill — content-sized and centered, floating via its own +//! `shadow_lg`. The window's OS shadow is disabled (`mod.rs` → `harden_panel(.., true)`) +//! so the window is invisible and the `rounded_full` pill isn't framed by a mismatched +//! rounded-rectangle window shadow. Inside: a subtle light circular button + an inline +//! label. Idle shows an empty circle next to "Start"; while `recording` a solid red dot +//! fills the circle next to a bold "Recording" (no animation — a steady, calm "on" +//! state). The WHOLE pill is the click target; `recording` also flips via the `space` +//! ToggleRecording action wired in `mod.rs`. + +use gpui::{ + div, px, App, Context, FocusHandle, Focusable, FontWeight, InteractiveElement, IntoElement, + ParentElement, Render, StatefulInteractiveElement, Styled, Window, +}; +use gpui_component::{h_flex, ActiveTheme}; + +pub struct RecordingPill { + pub focus_handle: FocusHandle, + pub recording: bool, +} + +impl RecordingPill { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + recording: false, + } + } + + /// Pure reducer (unit-tested). Flips the recording flag. + pub fn toggled(recording: bool) -> bool { + !recording + } + + /// Apply the toggle to self + notify. Called from on_click AND the ToggleRecording action. + pub fn toggle(&mut self, cx: &mut Context) { + self.recording = Self::toggled(self.recording); + cx.notify(); + } +} + +impl Focusable for RecordingPill { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RecordingPill { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Copy colors out so the `&Theme` borrow ends before the pill's `cx.listener` + // re-borrows cx as `&mut` (Hsla is Copy). Mirrors rail.rs / the old hud.rs. + let theme = cx.theme(); + let surface = theme.popover; + let foreground = theme.foreground; + let muted = theme.muted_foreground; + let danger = theme.danger; + + // Subtle, light circular button (theme-aware faint tint — never the old heavy + // dark disc). It holds a solid red dot ONLY while recording; idle it's empty. + let mut circle = div() + .size(px(20.0)) + .rounded_full() + .bg(foreground.opacity(0.1)) + .flex() + .items_center() + .justify_center(); + if self.recording { + // Solid red dot — steady, no animation (a calm, clear "on" state). + circle = circle.child(div().size(px(10.0)).rounded_full().bg(danger)); + } + + // Inline label on the same baseline as the circle: bold "Recording" while active, + // muted "Start" at rest. + let (label_text, label_color) = if self.recording { + ("Recording", foreground) + } else { + ("Start", muted) + }; + let mut label = div().text_sm().text_color(label_color); + if self.recording { + label = label.font_weight(FontWeight::BOLD); + } + let label = label.child(label_text); + + // Transparent full-window wrapper; the content-sized frosted pill sits centered. + // The pill renders its OWN `shadow_lg` for depth, and the window's OS shadow is + // disabled in `mod.rs` (`harden_panel(.., true)`) — so the window is invisible + // and there's no mismatched rounded-rect frame behind the `rounded_full` pill, + // just the pill floating. The pill is the click + P2 focus-spike target: clicking + // over another app must NOT steal its key focus — we log the verdict. + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + h_flex() + .id("overlay-pill") + .items_center() + .justify_center() + .gap_2() + .px_3() + .py_1p5() + .rounded_full() + .bg(surface.opacity(0.9)) + .shadow_lg() + .cursor_pointer() + .hover(move |s| s.bg(surface.opacity(0.95))) + .on_click(cx.listener(|this, _ev, window, cx| { + crate::overlay::harden::log_focus_state(window); + this.toggle(cx); + })) + .child(circle) + .child(label), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggled_flips_recording() { + assert!(RecordingPill::toggled(false)); + assert!(!RecordingPill::toggled(true)); + // Toggling twice returns to the original `false`. + assert!(!RecordingPill::toggled(RecordingPill::toggled(false))); + } +} diff --git a/src/overlay/rail.rs b/src/overlay/rail.rs new file mode 100644 index 0000000..4320ce8 --- /dev/null +++ b/src/overlay/rail.rs @@ -0,0 +1,179 @@ +//! Top-right job-status + action-button rail (`--features overlay`). Anchored top-right. +//! +//! A small frosted vertical panel with three stacked sections: one status icon per +//! background job (pulsing while `Running`, settling on `Done`/`Failed`), a thin +//! divider, then three square action buttons. The job icons are the generic +//! background-job proof (see `status.rs`); the buttons demonstrate clickable hover +//! controls (a `pinned` toggle that stays visibly filled, plus two momentary +//! buttons). The window is transparent — paint only the frosted panel. + +use crate::overlay::status::JobStatus; +use gpui::{ + div, px, Animation, AnimationExt, App, Context, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, Window, +}; +use gpui_component::{v_flex, ActiveTheme, Icon, IconName}; +use std::time::Duration; + +pub struct Rail { + pub focus_handle: FocusHandle, + pub jobs: Vec, + /// Toggle button #1 ("pin"-like). Stays visibly filled while true. + pub pinned: bool, +} + +impl Rail { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + // Seed three idle jobs so the rail shows status icons from the very first + // frame; the demo spine in `mod.rs` then advances them (Idle -> Running -> + // Done/Failed). Without this the rail is iconless until the first 2s tick. + jobs: vec![JobStatus::Idle; 3], + pinned: false, + } + } +} + +impl Focusable for Rail { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Rail { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Copy colors out so the `&Theme` borrow ends before the buttons' `cx.listener` + // closures re-borrow cx as `&mut` (Hsla is Copy). Mirrors pill.rs / status_row.rs. + let theme = cx.theme(); + let border = theme.border; + let surface = theme.popover; + let muted = theme.muted_foreground; + let primary = theme.primary; + let success = theme.success; + let danger = theme.danger; + + // Per-button tints derived from the locals above. Neutral rest fill, slightly + // stronger on hover/press; the pinned toggle gets a primary tint while active. + let neutral_rest = muted.opacity(0.15); + let neutral_hover = muted.opacity(0.3); + let neutral_press = muted.opacity(0.45); + let pinned_rest = primary.opacity(0.25); + let pinned_hover = primary.opacity(0.35); + let pinned_press = primary.opacity(0.45); + + // SECTION 1 — one status icon per job. A `Running` job's icon pulses via + // `with_animation` + `pulsating_between`; settled states render static in a + // distinct theme color. Running vs static are different element types, so each + // is pushed into `jobs_col` individually (the animated arm is an opaque element; + // never attach `.id()`/`.on_click()` after `with_animation`). + let mut jobs_col = v_flex().items_center().gap_2(); + for (idx, job) in self.jobs.iter().enumerate() { + let (name, color) = match job { + JobStatus::Idle => (IconName::Dash, muted), + JobStatus::Running { .. } => (IconName::LoaderCircle, primary), + JobStatus::Done(_) => (IconName::CircleCheck, success), + JobStatus::Failed(_) => (IconName::CircleX, danger), + }; + + let icon = Icon::new(name).text_color(color); + + if job.is_running() { + // Pulse the running icon's opacity to signal live work. A unique, + // per-row id keeps each animation's element state independent. + jobs_col = jobs_col.child( + icon.with_animation( + ("rail-job", idx), + Animation::new(Duration::from_secs(1)) + .repeat() + .with_easing(gpui::pulsating_between(0.4, 1.0)), + |icon, delta| icon.opacity(delta), + ), + ); + } else { + jobs_col = jobs_col.child(icon); + } + } + + // Button 1 — toggle "pin": filled while active so the state is visible. + let (pin_icon, pin_rest, pin_hover, pin_press) = if self.pinned { + (IconName::StarFill, pinned_rest, pinned_hover, pinned_press) + } else { + (IconName::Star, neutral_rest, neutral_hover, neutral_press) + }; + let pin_button = div() + .id("rail-pin") + .size(px(34.0)) + .rounded_lg() + .flex() + .items_center() + .justify_center() + .bg(pin_rest) + .hover(move |s| s.bg(pin_hover)) + .active(move |s| s.bg(pin_press)) + .cursor_pointer() + .on_click(cx.listener(|this, _ev, _window, cx| { + this.pinned = !this.pinned; + eprintln!("overlay rail: pin -> {}", this.pinned); + cx.notify(); + })) + .child(Icon::new(pin_icon).text_color(primary)); + + // Button 2 — momentary "eye": one-line stderr marker on click. + let eye_button = div() + .id("rail-eye") + .size(px(34.0)) + .rounded_lg() + .flex() + .items_center() + .justify_center() + .bg(neutral_rest) + .hover(move |s| s.bg(neutral_hover)) + .active(move |s| s.bg(neutral_press)) + .cursor_pointer() + .on_click(cx.listener(|_this, _ev, _window, _cx| { + eprintln!("overlay rail: eye clicked"); + })) + .child(Icon::new(IconName::Eye).text_color(muted)); + + // Button 3 — momentary "bell": one-line stderr marker on click. + let bell_button = div() + .id("rail-bell") + .size(px(34.0)) + .rounded_lg() + .flex() + .items_center() + .justify_center() + .bg(neutral_rest) + .hover(move |s| s.bg(neutral_hover)) + .active(move |s| s.bg(neutral_press)) + .cursor_pointer() + .on_click(cx.listener(|_this, _ev, _window, _cx| { + eprintln!("overlay rail: bell clicked"); + })) + .child(Icon::new(IconName::Bell).text_color(muted)); + + // Outer frosted panel: translucent popover bg + border + shadow. The window + // itself is transparent, so everything outside this rounded panel shows through. + v_flex() + .items_center() + .gap_2() + .p_2() + .rounded_xl() + .border_1() + .border_color(border) + .bg(surface.opacity(0.85)) + .shadow_lg() + .child(jobs_col) + // SECTION 2 — divider: a thin horizontal line separating jobs from actions. + .child(div().h(px(1.0)).w(px(40.0)).bg(border)) + // SECTION 3 — the three square action buttons. + .child( + v_flex() + .gap_2() + .child(pin_button) + .child(eye_button) + .child(bell_button), + ) + } +} diff --git a/src/overlay/state.rs b/src/overlay/state.rs new file mode 100644 index 0000000..5b6665f --- /dev/null +++ b/src/overlay/state.rs @@ -0,0 +1,131 @@ +//! Overlay anchor placement math. +//! +//! Defines the `OverlayAnchor` positioning helper and its `origin` geometry, which +//! places the fixed-size overlay canvas at the chosen corner of the active display, +//! fully inside it and inset by a margin. + +use gpui::{Bounds, Pixels, Point, Size}; + +/// Where an overlay surface is pinned on the active display. Internal positioning +/// helper only (no longer a user setting): the rail uses `TopRight`, the pill +/// `BottomCenter`. `DECK_OVERLAY=0/1` remains the master on/off in `mod.rs`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OverlayAnchor { + TopRight, + BottomCenter, +} + +impl OverlayAnchor { + /// Top-left origin for a `canvas` of the given size within `display` visible bounds, + /// inset by `margin`. + /// + /// The returned point is the window origin (top-left) such that the fixed-size + /// `canvas` sits at the chosen anchor and stays fully inside `display`: + /// + /// - `TopRight`: pinned to the top-right corner — `margin` from the top and right. + /// - `BottomCenter`: horizontally centered, sitting `margin` above the bottom + /// edge (so it clears the dock). + pub fn origin( + self, + display: Bounds, + canvas: Size, + margin: Pixels, + ) -> Point { + match self { + OverlayAnchor::TopRight => { + let x = display.right() - canvas.width - margin; + let y = display.top() + margin; + gpui::point(x, y) + } + OverlayAnchor::BottomCenter => { + // `Pixels` has `Mul` but no `Div`, so halve with `* 0.5`. + let x = display.left() + (display.size.width - canvas.width) * 0.5; + let y = display.bottom() - canvas.height - margin; + gpui::point(x, y) + } + } + } +} + +#[cfg(test)] +mod tests { + // `super::*` brings the module's `OverlayAnchor` plus the `Bounds`/`Pixels`/`Point`/`Size` + // imports into scope; only the free constructors `px`/`size`/`point` need pulling in here. + use super::*; + use gpui::{point, px, size}; + + /// A 1440x900 display anchored at the screen origin, the canonical fixture. + fn display() -> Bounds { + Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(1440.0), px(900.0)), + } + } + + fn canvas() -> Size { + size(px(460.0), px(160.0)) + } + + const MARGIN: f32 = 16.0; + + #[test] + fn bottom_center_is_centered_and_above_bottom() { + let origin = OverlayAnchor::BottomCenter.origin(display(), canvas(), px(MARGIN)); + + // Horizontally centered: (1440 - 460) / 2 = 490. + assert_eq!(origin.x, px(490.0)); + // `margin` above the bottom: 900 - 160 - 16 = 724. + assert_eq!(origin.y, px(724.0)); + } + + #[test] + fn top_right_is_inset_from_top_and_right() { + let origin = OverlayAnchor::TopRight.origin(display(), canvas(), px(MARGIN)); + + // Right edge inset: 1440 - 460 - 16 = 964. + assert_eq!(origin.x, px(964.0)); + // `margin` from the top. + assert_eq!(origin.y, px(MARGIN)); + } + + #[test] + fn both_anchors_keep_canvas_fully_inside_display() { + let d = display(); + let c = canvas(); + for anchor in [OverlayAnchor::TopRight, OverlayAnchor::BottomCenter] { + let origin = anchor.origin(d, c, px(MARGIN)); + + // Top-left stays inside the display top-left. + assert!(origin.x >= d.left(), "{anchor:?}: x {:?} < left", origin.x); + assert!(origin.y >= d.top(), "{anchor:?}: y {:?} < top", origin.y); + + // Bottom-right of the canvas stays inside the display bottom-right. + assert!( + origin.x + c.width <= d.right(), + "{anchor:?}: right {:?} > {:?}", + origin.x + c.width, + d.right() + ); + assert!( + origin.y + c.height <= d.bottom(), + "{anchor:?}: bottom {:?} > {:?}", + origin.y + c.height, + d.bottom() + ); + } + } + + #[test] + fn origin_respects_nonzero_display_offset() { + // A secondary display offset to the right and down; origin must shift with it. + let d = Bounds { + origin: point(px(100.0), px(50.0)), + size: size(px(1440.0), px(900.0)), + }; + let origin = OverlayAnchor::TopRight.origin(d, canvas(), px(MARGIN)); + + // Right edge: (100 + 1440) - 460 - 16 = 1064; top: 50 + 16 = 66. + assert_eq!(origin.x, px(1064.0)); + assert_eq!(origin.y, px(66.0)); + } +} diff --git a/src/overlay/status.rs b/src/overlay/status.rs new file mode 100644 index 0000000..f807e8c --- /dev/null +++ b/src/overlay/status.rs @@ -0,0 +1,147 @@ +//! Generic async-job status — pure logic (no UI, no I/O). +//! +//! `JobStatus` plus the `upsert_job`/`demo_advance_jobs` reducers that drive the +//! background-job spine. The rail (`rail.rs`) renders one icon per job from the +//! resulting `&[JobStatus]`; this module owns only the state transitions. The +//! status surface is the *visual proof of the background-job spine* — strictly +//! generic async-job status, with **no** agent/LLM-specific UI, labels, or demo +//! (see `docs/overlay.md` CHILD #2). The `JobStatus` variants are FROZEN — the +//! demo spine in `mod.rs` constructs them, so they must not be renamed or removed. + +use gpui::SharedString; + +/// Status of a single generic background job, rendered as one icon in the rail. +/// Strictly generic — no agent/LLM-specific framing (see `docs/overlay.md` CHILD #2). +/// +/// The variant set is FROZEN: the demo spine in `mod.rs` and the `upsert_job` +/// reducer below construct these, so do not rename or remove them. +#[derive(Clone, Debug, PartialEq)] +pub enum JobStatus { + Idle, + Running { note: SharedString }, + Done(SharedString), + Failed(SharedString), +} + +impl JobStatus { + /// Whether this job is actively running. Drives the per-icon pulse animation. + pub fn is_running(&self) -> bool { + matches!(self, JobStatus::Running { .. }) + } +} + +/// Insert-or-replace the job at `idx`, extending the vec with `JobStatus::Idle` +/// placeholders if `idx` is past the current end. Pure reducer — no UI, no I/O. +/// This is the single mutation point the background-job spine drives; rendering +/// then derives entirely from the resulting `&[JobStatus]`. +pub fn upsert_job(jobs: &mut Vec, idx: usize, next: JobStatus) { + if idx >= jobs.len() { + jobs.resize(idx + 1, JobStatus::Idle); + } + jobs[idx] = next; +} + +/// Demo driver for the background-job spine: starting from empty, seed ~3 jobs and +/// cycle each one Idle -> Running -> Done on successive calls. Integration wires this +/// into `mod.rs`'s `TopRight` branch so the rail visibly animates without any +/// agent/LLM framing — it is purely the generic async-job proof. +/// +/// The advance is order-preserving and idempotent at the terminal state: the first +/// not-yet-`Done` job steps forward one stage; once all are `Done` it is a no-op. +pub fn demo_advance_jobs(jobs: &mut Vec) { + const DEMO_JOB_COUNT: usize = 3; + + // First call (or any time the vec is short): seed the placeholders. + if jobs.len() < DEMO_JOB_COUNT { + jobs.resize(DEMO_JOB_COUNT, JobStatus::Idle); + return; + } + + // Advance the first job that has not finished yet, one stage at a time. This + // staggers the jobs so the rail shows a mix of Idle/Running/Done as it fills in. + for idx in 0..jobs.len() { + let next = match &jobs[idx] { + JobStatus::Idle => Some(JobStatus::Running { + note: SharedString::from("working"), + }), + // The last seeded job fails, so the rail demonstrates the failure state + // (danger color) too; the earlier jobs complete successfully. + JobStatus::Running { .. } if idx + 1 == jobs.len() => { + Some(JobStatus::Failed(SharedString::from("error"))) + } + JobStatus::Running { .. } => Some(JobStatus::Done(SharedString::from("ok"))), + // Already settled — look at the next job. + JobStatus::Done(_) | JobStatus::Failed(_) => None, + }; + if let Some(next) = next { + upsert_job(jobs, idx, next); + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_job_inserts_replaces_and_extends() { + let mut jobs: Vec = Vec::new(); + + // Insert at the next slot. + upsert_job(&mut jobs, 0, JobStatus::Running { note: "a".into() }); + assert_eq!(jobs, vec![JobStatus::Running { note: "a".into() }]); + + // Replace in place. + upsert_job(&mut jobs, 0, JobStatus::Done("a".into())); + assert_eq!(jobs, vec![JobStatus::Done("a".into())]); + + // Extend past the end — the gap fills with `Idle` placeholders. + upsert_job(&mut jobs, 2, JobStatus::Failed("c".into())); + assert_eq!( + jobs, + vec![ + JobStatus::Done("a".into()), + JobStatus::Idle, + JobStatus::Failed("c".into()), + ] + ); + } + + #[test] + fn demo_advance_seeds_then_cycles_a_job_idle_running_done() { + let mut jobs: Vec = Vec::new(); + + // First call seeds the placeholders; nothing is running yet. + demo_advance_jobs(&mut jobs); + assert_eq!(jobs.len(), 3); + assert!(jobs.iter().all(|j| *j == JobStatus::Idle)); + + // Idle -> Running for the first job. + demo_advance_jobs(&mut jobs); + assert!(jobs[0].is_running()); + assert_eq!(jobs[1], JobStatus::Idle); + + // Running -> Done for the first job (the rest stay untouched this step). + demo_advance_jobs(&mut jobs); + assert_eq!(jobs[0], JobStatus::Done("ok".into())); + + // Driving it to completion eventually settles every job (no panic, terminal + // state is a no-op). The last seeded job settles to `Failed`, the rest `Done`. + for _ in 0..16 { + demo_advance_jobs(&mut jobs); + } + assert!(jobs + .iter() + .all(|j| matches!(j, JobStatus::Done(_) | JobStatus::Failed(_)) && !j.is_running())); + assert!(matches!(jobs.last(), Some(JobStatus::Failed(_)))); + } + + #[test] + fn is_running_is_true_only_for_running() { + assert!(JobStatus::Running { note: "x".into() }.is_running()); + assert!(!JobStatus::Idle.is_running()); + assert!(!JobStatus::Done("x".into()).is_running()); + assert!(!JobStatus::Failed("x".into()).is_running()); + } +} diff --git a/src/settings.rs b/src/settings.rs index 4bf019f..8d84990 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -39,7 +39,8 @@ impl ThemeModePref { } /// Everything the app remembers between launches. Add fields freely — the -/// `#[serde(default)]` makes older config files forward-compatible. +/// `#[serde(default)]` makes older config files forward-compatible (and lets serde +/// silently ignore stale keys like a removed `overlay_anchor`). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct Settings { @@ -47,6 +48,11 @@ pub struct Settings { pub accent: Accent, pub display_name: String, pub launch_minimized: bool, + /// Whether to open the floating overlay surface on launch. Defaults `true` so that + /// compiling `--features overlay` shows it immediately (mirrors how `--features tray` + /// just works); the feature flag is the only *build* gate. Override per-run without + /// editing this file via `DECK_OVERLAY=0|1` (see `overlay::install`). + pub overlay_enabled: bool, } impl Default for Settings { @@ -56,6 +62,7 @@ impl Default for Settings { accent: Accent::default(), display_name: String::new(), launch_minimized: false, + overlay_enabled: true, } } }