From 2a204b8d7404310f68355765bdd24498c19991af Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 12 Aug 2026 15:39:35 +0100 Subject: [PATCH] Pad the masthead against window controls on both edges The hidden_inset_tall masthead only padded WindowChrome.insets.left, so it cleared the macOS traffic lights and nothing else. Windows puts its min/max/close cluster on the right instead and reports it as insets.right, which onChrome dropped on the floor. That left the update dot and the gear button laid out underneath the DWM caption buttons. It also made those buttons flicker. The Windows host resamples the app pixel 8px leading of the cluster on every present and pushes it through DWMWA_CAPTION_COLOR plus DWMWA_USE_IMMERSIVE_DARK_MODE, which picks the button glyph palette by luminance. With the gear sitting there the sample landed on an antialiased glyph edge rather than flat header, so it flipped between readings, each flip forced a caption repaint, and the light/dark verdict flipped with it. Map both horizontal insets into chrome_leading / chrome_trailing and spacer both ends of the row, which is the shape the SDK documents on WindowChrome. The row's existing gap keeps the sampled pixel on flat header. onboarding and lock already end in a growing spacer, so they were never affected, and Linux is unchanged because the inset is honestly zero there. --- CLAUDE.md | 1 + src/main.zig | 10 ++++++++-- src/model.zig | 29 +++++++++++++++++++++++++++-- src/views/player-top.native | 6 ++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f2f7e83..ffb9ff6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ Data flow at runtime: timers poll `/api/now-playing`, `/api/state`, `/api/themes - **A `/` in a scene window title crashes GTK at app_start on Linux.** Keep the branded "SUB/WAVE" slash out of `app.zon` / `shell_windows` titles; it's fine in `runWithOptions.window_title`. - **The SDK cannot resize a live window.** Per-mode window shapes apply at next launch only. +- **A `hidden_inset_tall` masthead must pad `insets.left` AND `insets.right`.** The window-control cluster sits on a different edge per platform — macOS traffic lights lead, Windows min/max/close trail — so `onChrome` maps both into `chrome_leading` / `chrome_trailing` and `player-top.native` spacers both ends. Dropping the trailing one put the gear button under the DWM caption buttons on Windows, and fed the host's per-present caption-colour sampler (which reads the pixel 8px leading of the cluster and pushes it through `DWMWA_CAPTION_COLOR` + `USE_IMMERSIVE_DARK_MODE`) an antialiased glyph edge instead of flat header — the caption buttons flickered. - **The FFT spectrum feed only emits while a window is visibly on screen** (occlusion gate in the SDK). A flat visualizer from an app launched in the background is not a bug — activate the app first. - SDK 0.8.4 still has **no OS media-controls surface** (no MPNowPlayingInfoCenter / MPRemoteCommandCenter / MPRIS / hardware media keys — re-checked at the 0.8.4 upgrade). The substitutes are the tray extra, the in-window keyboard transport, and the background track toast: 0.8.4 added `fx.showNotification`, so a track change while the app is backgrounded posts a desktop notification. It is opt-in (back panel → NOTIFICATIONS) and the whole decision lives in `Model.shouldNotifyTrack` — pure, because the effect is inert and unrecorded under fake execution, so no test can observe the call itself. - SDK 0.8.4 also has **no audio output-device API** — the platform seam is load/play/pause/stop/seek/volume, with no enumeration and no device property, so there is no in-app "play through these speakers" picker to build. Route it at the OS (`pavucontrol`/PipeWire, Windows volume mixer; macOS has nothing native). The upstream request lives in `docs/sdk-audio-device-request.md`. diff --git a/src/main.zig b/src/main.zig index 548053d..32c1308 100644 --- a/src/main.zig +++ b/src/main.zig @@ -163,12 +163,18 @@ fn onLifecycle(event: native_sdk.LifecycleEvent) ?Msg { }; } -// Hidden-titlebar chrome geometry → model (masthead pads around the traffic -// lights). All-zero on standard chrome / fullscreen / non-macOS. +// Hidden-titlebar chrome geometry → model (the masthead pads around the window +// controls). BOTH horizontal edges are mapped because the cluster sits on a +// different one per platform: macOS puts the traffic lights left, Windows puts +// min/max/close right. Dropping `right` left the gear button underneath the DWM +// caption buttons on Windows, which also fed the host's caption-colour sampler +// a glyph edge instead of flat header — see CLAUDE.md's gotchas. All-zero on +// standard chrome and in fullscreen. fn onChrome(chrome: native_sdk.platform.WindowChrome) ?Msg { return Msg{ .chrome_changed = .{ .top = chrome.insets.top, .leading = chrome.insets.left, + .trailing = chrome.insets.right, } }; } diff --git a/src/model.zig b/src/model.zig index cf31567..c9bdf53 100644 --- a/src/model.zig +++ b/src/model.zig @@ -165,10 +165,14 @@ pub const DayTab = struct { label: []const u8, }; -/// Payload for chrome_changed (hidden-titlebar insets, macOS). +/// Payload for chrome_changed (hidden-titlebar insets). The window-control +/// cluster lands on the edge its platform puts it on — macOS reports the +/// traffic lights as `leading`, Windows reports min/max/close as `trailing` — +/// so the masthead pads BOTH and the unused edge is honestly zero. pub const ChromeInsets = struct { top: f32 = 0, leading: f32 = 0, + trailing: f32 = 0, }; // ------------------------------------------------------------------ model @@ -317,9 +321,11 @@ pub const Model = struct { sheet: Sheet = .none, mini_open: bool = false, - // hidden-titlebar chrome insets (macOS traffic lights) + // hidden-titlebar chrome insets (macOS traffic lights lead, Windows + // min/max/close trails) chrome_top: f32 = 0, chrome_leading: f32 = 0, + chrome_trailing: f32 = 0, // sleep timer (wall-clock deadline; 0 = off) sleep_deadline_ms: i64 = 0, @@ -2731,6 +2737,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void { .chrome_changed => |c| { model.chrome_top = c.top; model.chrome_leading = c.leading; + model.chrome_trailing = c.trailing; }, // ------------------------------------------------------ request slip @@ -4357,6 +4364,24 @@ test "update check: newer tag arms the notice, older clears it, failures keep st try testing.expect(m.update_available()); } +test "chrome insets land on both edges, so the masthead clears controls on any platform" { + var fx = Effects.init(testing.allocator); + defer fx.deinit(); + fx.executor = .fake; + var m: Model = .{}; + // macOS shape: traffic lights lead, nothing trails. + update(&m, .{ .chrome_changed = .{ .top = 52, .leading = 78, .trailing = 0 } }, &fx); + try testing.expectEqual(@as(f32, 52), m.chrome_top); + try testing.expectEqual(@as(f32, 78), m.chrome_leading); + try testing.expectEqual(@as(f32, 0), m.chrome_trailing); + // Windows shape: the min/max/close cluster trails instead. Dropping it is + // what put the gear button under the DWM caption buttons, whose colour the + // host samples from the pixel just leading of the cluster every present. + update(&m, .{ .chrome_changed = .{ .top = 32, .leading = 0, .trailing = 138 } }, &fx); + try testing.expectEqual(@as(f32, 0), m.chrome_leading); + try testing.expectEqual(@as(f32, 138), m.chrome_trailing); +} + test "log_dir_value reports the diagnostic log folder" { const m: Model = .{}; // Unopened in the suite, so it reads empty rather than crashing. diff --git a/src/views/player-top.native b/src/views/player-top.native index 0cb849e..73fb430 100644 --- a/src/views/player-top.native +++ b/src/views/player-top.native @@ -25,6 +25,12 @@