feat(qt): window chrome parity — macOS tray, native chrome, appearance wiring

Three Slint-parity pieces that share the chrome surface:

- macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the
  reference — the K2 'macOS is a new implementation' ruling read the
  reference backwards (it avoids muda entirely), and its absence made
  close-to-tray quit the app on the Mac. Plus the custom-chrome window
  mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint /
  NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask
  recompute, traffic-light centring, and the SafeArea inset in HeaderBar.
- Appearance rows that rendered, persisted and drove nothing now push
  live: hide/show title bar, window-controls cluster side, track in the
  window title, invert swipe navigation, auto-theme source selection
  (wallpaper/image/system) with regeneration on source change.
- use_system_title_bar now defaults per-OS (custom chrome on macOS),
  matching the reference — the hard-coded 'true' also leaked into the
  Slint build through the shared ui_prefs.json.

cc
This commit is contained in:
vicrodh committed 2026-08-10 17:15:18 -06:00
1 parent b46924bcc0
commit 5e1eb46a71
13 files changed
+1069 -38

No files matched your search

+1
View File
@@ -6624,6 +6624,7 @@ dependencies = [
"ksni", "ksni",
"libc", "libc",
"log", "log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"open", "open",
+37
View File
@@ -172,6 +172,13 @@ cxx-qt-build = "0.7"
# behind cfg, so `cargo build` cannot see it. First real check is a macOS # behind cfg, so `cargo build` cannot see it. First real check is a macOS
# build — the Mac mini (192.168.0.69) is where that happens. # build — the Mac mini (192.168.0.69) is where that happens.
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
# DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`,
# `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and
# `rc::Retained` all live at the objc2 root. Pinned to 0.5, the SAME
# generation objc2-app-kit 0.2 targets — mixing generations in one binary
# registers duplicate objc classes, which is the failure the reference
# documents at length (qbz/Cargo.toml:150-156).
objc2 = "0.5"
# `MainThreadMarker` — in THIS generation it is exported by objc2-foundation, # `MainThreadMarker` — in THIS generation it is exported by objc2-foundation,
# not by the objc2 root (objc2-foundation-0.2.2/src/lib.rs:152, ungated). The # not by the objc2 root (objc2-foundation-0.2.2/src/lib.rs:152, ungated). The
# reference imports it from the same place. There is no direct `objc2` # reference imports it from the same place. There is no direct `objc2`
@@ -200,4 +207,34 @@ objc2-app-kit = { version = "0.2", default-features = false, features = [
# rather than by compiling — this arm cannot be built on Linux, and the # rather than by compiling — this arm cannot be built on Linux, and the
# reference's own proven set (crates/qbz/Cargo.toml) lists the same three. # reference's own proven set (crates/qbz/Cargo.toml) lists the same three.
"NSRunningApplication", "NSRunningApplication",
# --- macos_chrome.rs (traffic-light centring) -------------------------
# Every one of these was read out of objc2-app-kit-0.2.2's own sources
# before being added, because this arm cannot be compiled on Linux:
# NSWindow — `NSApplication::mainWindow` is gated on it
# (generated/NSApplication.rs:355), and it carries `frame` +
# `standardWindowButton`. The style-mask / titlebar setters it also
# carries are NO LONGER USED: expanding the client area is Qt's job
# via the 6.9 window flags (see Main.qml), because QCocoaWindow
# recomputes and reassigns the mask behind us.
# NSButton + NSControl + NSView — `standardWindowButton` is gated on
# ALL THREE at once (generated/NSWindow.rs:1181), not just NSButton;
# NSControl is NSButton's superclass and NSView is NSControl's.
# NSView also provides the geometry the centring measures with
# (bounds / frame / setFrame / superview / isFlipped /
# convertRect_toView).
"NSWindow",
"NSButton",
"NSControl",
"NSView",
# --- tray_macos.rs (the menu-bar NSStatusItem) ------------------------
# Exactly the set the reference proves on a Mac (qbz/Cargo.toml:172-188);
# NSStatusBarButton is separate from NSStatusItem, and NSEvent is what
# `handle_status_click` reads to tell a right-click from a left-click.
"NSStatusBar",
"NSStatusItem",
"NSStatusBarButton",
"NSMenu",
"NSMenuItem",
"NSImage",
"NSEvent",
] } ] }
+146 -2
View File
@@ -91,7 +91,14 @@ ApplicationWindow {
visibility: QbzShell.kioskFullscreenBoot visibility: QbzShell.kioskFullscreenBoot
? Window.FullScreen ? Window.FullScreen
: (QbzShell.windowMaximized ? Window.Maximized : Window.Windowed) : (QbzShell.windowMaximized ? Window.Maximized : Window.Windowed)
title: "QBZ" // "Show track in window title" (Appearance). 1:1 with app.slint:44 —
// FIXED format, no template, and it falls back to the plain app name
// whenever the setting is off or nothing is loaded. Reactive: the
// binding re-evaluates on every track change and on the toggle itself
// (settings_qt pushes `windowTitleShow` live).
title: QbzShell.windowTitleShow && QbzPlayer.npHasTrack
? QbzPlayer.npTitle + " - " + QbzPlayer.npArtist + " | qbz"
: "QBZ"
// Custom chrome (phase 7/12): frameless but OPAQUE — the phase-7 // Custom chrome (phase 7/12): frameless but OPAQUE — the phase-7
// translucent window was a misread: the Slint MAIN window keeps an // translucent window was a misread: the Slint MAIN window keeps an
// OPAQUE swapchain (only the miniplayer blends; crates/qbz/src/main.rs // OPAQUE swapchain (only the miniplayer blends; crates/qbz/src/main.rs
@@ -101,7 +108,66 @@ ApplicationWindow {
// with square corners and a square 1px hairline frame. The system // with square corners and a square 1px hairline frame. The system
// titlebar is the `use_system_title_bar` pref (ui_prefs.json; applied // titlebar is the `use_system_title_bar` pref (ui_prefs.json; applied
// at startup, restart semantics like Slint). // at startup, restart semantics like Slint).
flags: QbzShell.systemTitleBar ? Qt.Window : (Qt.Window | Qt.FramelessWindowHint) // Three different windows, one per platform/mode:
//
// system title bar → a plain Qt.Window, the OS decorates it.
// macOS custom → Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint.
// Linux/Windows → frameless, and we draw the cluster ourselves.
//
// The macOS arm is FIRST-CLASS QT API (6.9+, and both machines run
// 6.11.1), not a hand-rolled AppKit poke: `ExpandedClientAreaHint`
// expands the client area into the region the titlebar controls occupy,
// `NoTitleBarBackgroundHint` drops the titlebar's background — and the
// native traffic lights STAY. Content then reaches y=0 and the QBZ header
// sits under them.
//
// The previous attempt set `titlebarAppearsTransparent` +
// `titleVisibility` + `NSWindowStyleMaskFullSizeContentView` on the
// NSWindow from Rust, and could not win: `QCocoaWindow::windowStyleMask()`
// RECOMPUTES the mask from Qt's own flags and reassigns it on
// `setWindowFlags()`, on fullscreen enter/exit, and via `setWindowState()`
// — preserving only the fullscreen and unified-toolbar bits, never
// FullSizeContentView. The `visibility` binding below is itself a
// `setWindowState()`, so the bit was wiped moments after being set. That
// is why the log kept saying "traffic lights centred" while the owner kept
// seeing a stock title bar. (Qt forum "Unable to get transparent title bar
// in macOS since Qt 6.4", QTBUG-134797.)
//
// Frameless is NOT an option on macOS: it removes the traffic lights with
// the frame, and the reference never draws its own there
// ("Linux only — macOS keeps the native traffic lights",
// WindowControls.slint:1-2).
flags: QbzShell.systemTitleBar
? Qt.Window
: (QbzShell.isMacos
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
: (Qt.Window | Qt.FramelessWindowHint))
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
// flag look broken when it was not.
//
// Since 6.9 "ApplicationWindow will automatically add padding to the
// contentItem for any safe area margins reported by the window", so that
// "the contentItem stays inside the safe area of the window, while the
// background item covers the entire window" (Qt Quick Controls docs).
//
// On macOS with the expanded client area that safe area IS the titlebar
// band. So the window background dutifully covered the whole frame while
// `screenLoader` — anchored to the contentItem — was pushed ~28pt down.
// The result reads on screen as a stock title bar with the QBZ header
// below it, which is precisely what the owner kept reporting while the
// AppKit side measured perfect (styleMask 0x800f, titlebar transparent,
// contentH == windowH == 1328).
//
// QBZ draws its own chrome edge to edge on every platform — the header at
// the top, the player bar at the bottom, the sidebar at the left — so it
// takes the safe area over from Qt entirely. `HeaderBar.chromeLeftInset`
// is where that responsibility is discharged: it reads
// `SafeArea.margins.left` to clear the traffic lights.
topPadding: 0
bottomPadding: 0
leftPadding: 0
rightPadding: 0
color: "#1a1a1a" color: "#1a1a1a"
FontLoader { id: interRegular; source: "assets/fonts/Inter_18pt-Regular.ttf" } FontLoader { id: interRegular; source: "assets/fonts/Inter_18pt-Regular.ttf" }
@@ -117,6 +183,22 @@ ApplicationWindow {
// Phase 23: every domain singleton boots (registers its Qt-thread // Phase 23: every domain singleton boots (registers its Qt-thread
// hop; QbzSession.boot additionally fires the app boot sequence). // hop; QbzSession.boot additionally fires the app boot sequence).
Component.onCompleted: { Component.onCompleted: {
// DIAGNOSTIC (macOS chrome): the Rust side reports what AppKit ended
// up with; this reports what QML asked for. Together they pin the
// failure to either "the request was wrong" or "Qt ignored it".
if (QbzShell.isMacos) {
console.log("[macos-chrome] QML flags=" + window.flags
+ " systemTitleBar=" + QbzShell.systemTitleBar
+ " expected=" + (Qt.Window | Qt.ExpandedClientAreaHint
| Qt.NoTitleBarBackgroundHint)
// What Qt reports as unsafe, so we know whether
// HeaderBar.chromeLeftInset is using Qt's own number or
// falling back to the reference's 78px floor.
+ " safeArea(t/l/r/b)=" + window.SafeArea.margins.top
+ "/" + window.SafeArea.margins.left
+ "/" + window.SafeArea.margins.right
+ "/" + window.SafeArea.margins.bottom)
}
QbzSession.boot() QbzSession.boot()
// Qobuz Connect (2026-08-01 contract §2/§8). Booted right after // Qobuz Connect (2026-08-01 contract §2/§8). Booted right after
// QbzSession because QbzSession.boot fires the whole app boot // QbzSession because QbzSession.boot fires the whole app boot
@@ -223,6 +305,52 @@ ApplicationWindow {
window.bootScreenHeight = latchedH window.bootScreenHeight = latchedH
} }
// --- macOS chrome ----------------------------------------------------
// The overlay attributes + centring the native traffic lights against the
// 42px header. NOT in Component.onCompleted: it reaches the NSWindow
// through `-[NSApplication mainWindow]`, which is nil until AppKit has
// actually shown and keyed the window, and completion runs before that.
// The first rendered frame is the earliest point where it is guaranteed.
//
// RETRIED UNTIL IT REPORTS SUCCESS. The first version latched "applied"
// before calling, on `afterRendering`. That looked safe — the Rust side is
// idempotent, so latching only avoided per-frame work — but AppKit has no
// `mainWindow` yet at the first rendered frame, so the one attempt failed,
// the latch stuck, and the chrome never applied. On the owner's Mac the
// whole thing degraded to "stock title bar, plus a 78px inset reserved for
// traffic lights that were in another bar", and the log said it exactly
// once: `[macos-chrome] no main window yet`.
//
// A Timer rather than `afterRendering`: this must keep trying for a
// moment, and the frame signal would run the AppKit maths at the display
// rate to do it (the "changes less often than the frame rate" mistake this
// port already paid for in VizSettle). ~5 s of 200 ms attempts is far more
// than a window needs to become key, and it stops on the first success.
Timer {
id: macChromeTimer
interval: 200
repeat: true
running: QbzShell.isMacos
property int attempts: 0
onTriggered: {
attempts++
if (QbzShell.applyMacChrome()) {
running = false
// NO geometry nudge here. An earlier version did a 1px
// height round trip to force Qt to re-derive its content
// rect — obsolete now that the expanded client area comes
// from Qt's own flags, and actively wrong: `height` is a
// BINDING (the restore clamp above), and assigning it from
// imperative JS destroys that binding. This file warns about
// exactly that trap a few lines further down, about the
// maximized latch.
} else if (attempts >= 25) {
running = false
console.warn("[macos-chrome] gave up after " + attempts + " attempts")
}
}
}
// --- Geometry persistence ------------------------------------------- // --- Geometry persistence -------------------------------------------
// Slint saves from the winit Resized/Moved handlers (main.rs:1399-1454) and // Slint saves from the winit Resized/Moved handlers (main.rs:1399-1454) and
// relies on a change guard to survive the no-op events a WM emits. QML has // relies on a change guard to survive the no-op events a WM emits. QML has
@@ -395,6 +523,22 @@ ApplicationWindow {
var vis = window.visibility var vis = window.visibility
if (window.transientVisibility(vis)) if (window.transientVisibility(vis))
return return
// AppKit RE-LAYS OUT the titlebar on fullscreen enter/exit and on the
// maximize round trip, re-parking the traffic lights at their stock
// 28pt-bar position. The reference records that drift and lives with
// it ("the centre can drift until the next launch",
// crates/qbz/src/macos_chrome.rs); Qt hands us this edge for free, so
// re-centre instead of shipping it. Placed AFTER the transient
// early-return on purpose — a minimize is not a re-layout, and while
// the window is hidden AppKit has no main/key window, so the retry
// loop would spin for its full 5s finding nothing.
//
// `applyMacChrome` is idempotent: already-centred lights return on the
// half-point test without touching a frame.
if (QbzShell.isMacos && !QbzShell.systemTitleBar) {
macChromeTimer.attempts = 0
macChromeTimer.restart()
}
// FLUSH BEFORE LATCHING. Leaving Windowed for Maximized/FullScreen with // FLUSH BEFORE LATCHING. Leaving Windowed for Maximized/FullScreen with
// a drag still in the debounce used to lose that drag outright: the // a drag still in the debounce used to lose that drag outright: the
// handler latched the new state and restarted the SAME timer, so when // handler latched the new state and restarted the SAME timer, so when
@@ -301,6 +301,21 @@ Column {
} }
} }
// OWNER RULING 2026-08-05, and a DELIBERATE divergence from the
// reference: Slint hides the whole TITLE BAR and WINDOW CONTROLS groups on
// macOS (AppearanceSettings.slint:723-800). The owner wants the SWITCH
// there — system title bar vs the QBZ header with the native traffic
// lights overlaid on it — so only the two rows that genuinely cannot work
// on macOS are hidden:
//
// Hide title bar — frameless with no controls at all; it would
// take the traffic lights with it, and it is a
// tiling-WM affordance by its own description.
// Window controls position — places the DRAWN cluster, which macOS never
// draws; AppKit owns where the lights sit.
//
// "Show window controls" stays: it gates whether the cluster is drawn at
// all, which is a meaningful answer on every platform.
SettingsSpacer { } SettingsSpacer { }
SettingsDivider { } SettingsDivider { }
SettingsSpacer { } SettingsSpacer { }
@@ -316,6 +331,7 @@ Column {
} }
} }
SettingRow { SettingRow {
visible: !QbzShell.isMacos
label: QbzSession.tr("Hide title bar", QbzSession.trRev) label: QbzSession.tr("Hide title bar", QbzSession.trRev)
description: QbzSession.tr("Frameless window without window controls or header drag (for tiling window manager users)", QbzSession.trRev) description: QbzSession.tr("Frameless window without window controls or header drag (for tiling window manager users)", QbzSession.trRev)
rowEnabled: root.doc.useSystemTitleBar !== true rowEnabled: root.doc.useSystemTitleBar !== true
@@ -326,13 +342,19 @@ Column {
} }
} }
SettingsSpacer { } // The whole group is macOS-hidden now: both of its rows are, so leaving
SettingsDivider { } // the header and separators behind printed an empty titled group.
SettingsSpacer { } SettingsSpacer { visible: !QbzShell.isMacos }
SettingsDivider { visible: !QbzShell.isMacos }
SettingsSpacer { visible: !QbzShell.isMacos }
// ======================= WINDOW CONTROLS ============================= // ======================= WINDOW CONTROLS =============================
GroupHeader { text: QbzSession.tr("WINDOW CONTROLS", QbzSession.trRev) } GroupHeader {
visible: !QbzShell.isMacos
text: QbzSession.tr("WINDOW CONTROLS", QbzSession.trRev)
}
SettingRow { SettingRow {
visible: !QbzShell.isMacos
label: QbzSession.tr("Window controls position", QbzSession.trRev) label: QbzSession.tr("Window controls position", QbzSession.trRev)
description: QbzSession.tr("Place the window control buttons on the left or right side of the title bar", QbzSession.trRev) description: QbzSession.tr("Place the window control buttons on the left or right side of the title bar", QbzSession.trRev)
rowEnabled: !root.tbLocked rowEnabled: !root.tbLocked
@@ -345,6 +367,11 @@ Column {
} }
} }
SettingRow { SettingRow {
// Also hidden on macOS (owner, 2026-08-05, after seeing it there):
// it gates the DRAWN cluster, which macOS never draws, and its own
// description offers a window-manager rationale that has no meaning
// on a Mac. Three rows hidden there now, not two.
visible: !QbzShell.isMacos
label: QbzSession.tr("Show window controls", QbzSession.trRev) label: QbzSession.tr("Show window controls", QbzSession.trRev)
description: QbzSession.tr("Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these.", QbzSession.trRev) description: QbzSession.tr("Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these.", QbzSession.trRev)
rowEnabled: !root.tbLocked rowEnabled: !root.tbLocked
@@ -477,13 +504,26 @@ Column {
} }
} }
SettingsSpacer { } // The group's own separator is gated too, or the panel ends on a divider
SettingsDivider { } // with nothing under it (the reference gates its three spacer/divider
SettingsSpacer { } // elements the same way, AppearanceSettings.slint:1015-1017).
SettingsSpacer { visible: QbzShell.isLinux }
SettingsDivider { visible: QbzShell.isLinux }
SettingsSpacer { visible: QbzShell.isLinux }
// =========================== RENDERER ================================ // =========================== RENDERER ================================
GroupHeader { text: QbzSession.tr("RENDERER", QbzSession.trRev) } // Linux only, 1:1 with the reference: every element of this group —
// including the Preferred-GPU row below — is gated on
// `renderer-setting-visible`, which main.rs:318 seeds from
// `cfg!(target_os = "linux")`. macOS is always Skia/Metal and Windows
// negotiates its own backend, so off Linux the selector offered choices
// that changed nothing (AppearanceSettings.slint:1011-1043).
GroupHeader {
visible: QbzShell.isLinux
text: QbzSession.tr("RENDERER", QbzSession.trRev)
}
SettingRow { SettingRow {
visible: QbzShell.isLinux
label: QbzSession.tr("Rendering backend", QbzSession.trRev) label: QbzSession.tr("Rendering backend", QbzSession.trRev)
description: QbzSession.tr("Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)", QbzSession.trRev) description: QbzSession.tr("Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)", QbzSession.trRev)
QbzSelect { QbzSelect {
@@ -494,6 +534,7 @@ Column {
} }
} }
SettingRow { SettingRow {
visible: QbzShell.isLinux
label: QbzSession.tr("Preferred GPU", QbzSession.trRev) label: QbzSession.tr("Preferred GPU", QbzSession.trRev)
description: QbzSession.tr("Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)", QbzSession.trRev) description: QbzSession.tr("Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)", QbzSession.trRev)
QbzSelect { QbzSelect {
+70 -9
View File
@@ -69,6 +69,40 @@ Rectangle {
QbzTheme { id: theme } QbzTheme { id: theme }
// --- Custom-chrome gating, 1:1 with HeaderBar.slint:594-609 -----------
// Three prefs decide what the header owns. They were all persisted and
// all ignored until 2026-08-04.
//
// chromeDragEnabled — the header IS the title bar: press-to-move and
// double-click-to-maximize. Off under the system
// title bar (the native chrome owns it) and off
// when the user asked for no title bar at all.
// chromeControls — additionally draw min/max/close. Never on macOS:
// there the NATIVE traffic lights float over this
// bar (see chromeLeftInset) and a drawn cluster
// would be a second set of buttons.
readonly property bool chromeDragEnabled:
!QbzShell.systemTitleBar && !QbzShell.hideTitleBar
readonly property bool chromeControls:
root.chromeDragEnabled && !QbzShell.isMacos && QbzShell.showWindowControls
readonly property bool wcOnLeft: QbzShell.wcOnLeft
// 3 x 34px buttons + 2 x 2px spacing (WindowControls.slint).
readonly property int wcClusterWidth: 106
// Left inset for the leading nav: the native traffic-light band on macOS,
// or the drawn cluster when it sits left (HeaderBar.slint:605-607).
//
// On macOS this now ASKS Qt instead of hard-coding 78. With
// `ExpandedClientAreaHint` the client area covers the region the titlebar
// controls occupy, and `SafeArea.margins` reports exactly how much of it
// is unsafe — the traffic-light band, in the window's real metrics rather
// than the reference's estimate. The 78 stays only as the floor for the
// case where the margin reads 0 (nothing to lose, and it is the number
// the Slint build has used all along).
readonly property int chromeLeftInset:
QbzShell.isMacos && !QbzShell.systemTitleBar
? Math.max(root.SafeArea.margins.left, 78)
: (root.chromeControls && root.wcOnLeft ? root.wcClusterWidth + 6 : 0)
// The section-nav dropdown (catalog + panel + open/close behaviour). // The section-nav dropdown (catalog + panel + open/close behaviour).
// Zero-size and NOT inside any Row — its origin must stay this root's // Zero-size and NOT inside any Row — its origin must stay this root's
// origin, because the trigger coordinates are mapped into it. The panel // origin, because the trigger coordinates are mapped into it. The panel
@@ -84,8 +118,9 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
// Inert under the system titlebar (the native chrome owns // Inert under the system titlebar (the native chrome owns
// drag/double-click) — phase 12 titlebar toggle. // drag/double-click) and when the title bar is hidden outright —
enabled: !QbzShell.systemTitleBar // HeaderBar.slint's `chrome-drag-enabled`.
enabled: root.chromeDragEnabled
property bool dragStarted: false property bool dragStarted: false
onPressed: dragStarted = false onPressed: dragStarted = false
onPositionChanged: { onPositionChanged: {
@@ -250,7 +285,9 @@ Rectangle {
// --- Left controls --------------------------------------------------- // --- Left controls ---------------------------------------------------
Row { Row {
id: leftControls id: leftControls
x: theme.spacingMd // HeaderBar.slint:825 — `Spacing.md + chrome-left-inset`, so the nav
// clears the traffic lights (macOS) or the left-placed cluster.
x: theme.spacingMd + root.chromeLeftInset
y: (root.height - height) / 2 y: (root.height - height) / 2
height: 36 height: 36
spacing: 6 spacing: 6
@@ -782,8 +819,16 @@ Rectangle {
// --- Right controls: status badge + app menu -------------------------- // --- Right controls: status badge + app menu --------------------------
Row { Row {
id: rightControls id: rightControls
// Shifts left of the drawn window controls (3x34 + 2x2 = 106px). // Shifts left of the drawn window controls (3x34 + 2x2 = 106px) — but
x: root.width - width - theme.spacingMd + 2 - (QbzShell.systemTitleBar ? 0 : 110) // ONLY when that cluster is actually drawn AND on the right. It used
// to key off `systemTitleBar` alone, so macOS (which never draws the
// cluster) reserved 110px of empty space at the right edge, and a
// LEFT-placed cluster reserved it on the wrong side. The reference
// gates the same padding on its `lin-chrome` predicate — the drawn
// cluster's own condition, not the titlebar mode
// (KioskShell.slint:245-248).
x: root.width - width - theme.spacingMd + 2
- (root.chromeControls && !root.wcOnLeft ? 110 : 0)
y: (root.height - height) / 2 y: (root.height - height) / 2
height: 36 height: 36
spacing: 4 spacing: 4
@@ -923,11 +968,27 @@ Rectangle {
} }
} }
// --- Drawn window controls (WindowControls.slint, right placement: // --- Drawn window controls (WindowControls.slint) ---------------------
// minimize · maximize · close; close gets the danger-red hover) ------ // Children are declared in the RIGHT order (minimize · maximize · close,
// the Windows/KDE one; close gets the danger-red hover). Left placement
// takes the corner the traffic lights use on macOS and flips to THEIR
// order — close · maximize · minimize (WindowControls.slint:41-79).
//
// The flip is `layoutDirection`, not a reordered copy of the three
// buttons: a Row laying out right-to-left puts the first child at the
// right edge, so min·max·close reads close·max·min on screen. The
// reference needs two mirrored `if` blocks because Slint has no such
// property; duplicating them here would be duplicating the hover, the
// icon swap and the close choreography three times over.
//
// `y` is deliberately untouched by the placement: the cluster stays
// vertically centred on the header in BOTH positions (owner requirement,
// and 1:1 with the reference — both of its blocks set the same
// `y: (height - preferred-height) / 2`).
Row { Row {
visible: !QbzShell.systemTitleBar visible: root.chromeControls
x: root.width - width - 8 layoutDirection: root.wcOnLeft ? Qt.RightToLeft : Qt.LeftToRight
x: root.wcOnLeft ? 8 : root.width - width - 8
y: (root.height - height) / 2 y: (root.height - height) / 2
height: 26 height: 26
spacing: 2 spacing: 2
+10 -6
View File
@@ -157,12 +157,16 @@ MouseArea {
// Qt and Slint share this sign convention — both add the delta to // Qt and Slint share this sign convention — both add the delta to
// the content/viewport offset — so the mapping ports 1:1. // the content/viewport offset — so the mapping ports 1:1.
// //
// The Slint build lets users who run without natural scrolling flip // Users who run WITHOUT natural scrolling flip the mapping with
// this with AppearanceState.invert-swipe-navigation. That pref has // `invert-swipe-navigation` (Slint AppearanceState). The Appearance
// no counterpart on QbzShell yet, so this port hard-codes the // row for it has always been drawn; until 2026-08-04 this file
// pref's DEFAULT (false / not inverted). No settings row is drawn // hard-coded the pref's default and carried a comment claiming no
// for it: a toggle with nothing behind it would be a no-op control. // row existed — the toggle really was a no-op, just not for the
if (gestures.swipeAccum < 0) { // reason the comment gave.
const back = QbzShell.invertSwipeNavigation
? gestures.swipeAccum > 0
: gestures.swipeAccum < 0
if (back) {
if (QbzShell.canBack) if (QbzShell.canBack)
QbzShell.navigateBack() QbzShell.navigateBack()
} else { } else {
+201
View File
@@ -0,0 +1,201 @@
//! macOS custom chrome: keep the NATIVE traffic lights and centre them in
//! QBZ's own header bar.
//!
//! Port of `crates/qbz/src/macos_chrome.rs`. The reference's framing, which
//! is also the owner's ask (2026-07-21, "centre them, like the official Qobuz
//! app does"): with the overlay window attributes the native lights float
//! over QBZ's header, but AppKit parks them at the STANDARD titlebar height
//! (~28pt) while the header is 42px tall, so they sit ~7pt above the header
//! controls' vertical centre.
//!
//! WHY THIS EXISTS AT ALL — the Qt port's divergence, fixed here:
//! `Main.qml` used to add `Qt.FramelessWindowHint` on every platform when the
//! custom title bar was on. On macOS that removes the traffic lights
//! outright, so the port drew its OWN min/max/close cluster there — the one
//! thing the reference explicitly never does
//! (`qbz-ui/ui/shell/WindowControls.slint:1-2`: "Linux only — macOS keeps the
//! native traffic lights"). The window is now left framed on macOS and made
//! transparent/full-size instead, which is the AppKit idiom and what winit
//! does for the Slint build.
//!
//! **NOT COMPILED OR TESTED ON LINUX.** Every objc2 name below was read out
//! of objc2-app-kit-0.2.2's own generated sources before being written (the
//! feature gates are listed in `Cargo.toml`), which narrows the blind spot to
//! runtime behaviour — it does not remove it. The 2026-08-04 Hide-Dock round
//! is the precedent: careful reading caught one of three errors, the platform
//! compiler caught the other two.
/// Vertical centre target in AppKit points, measured from the window's top
/// edge: half of the header height (42px — Qt logical px == AppKit points on
/// macOS). Keep in sync with `qml/theme/QbzTheme.qml`'s `headerHeight` and
/// with the reference's `Layout.header-height`.
#[cfg(target_os = "macos")]
const HEADER_CENTRE_PT: f64 = 21.0;
/// Apply the overlay attributes and centre the lights.
///
/// **Returns whether the work was actually done**, and the caller must retry
/// while it is `false`. That signature is the fix for the 2026-08-05 failure:
/// `Main.qml` latched "applied" BEFORE calling, so the single early attempt —
/// fired on the first rendered frame, when AppKit has not yet made the window
/// `main` — permanently disabled the chrome. The symptom was a stock macOS
/// title bar sitting above the QBZ header, with the 78px traffic-light inset
/// reserved in a header the lights never reached. The log said so exactly
/// once: `[macos-chrome] skipped: no main window yet`.
///
/// Idempotent, so retrying is free: it returns early when the buttons are
/// already centred, and the three window attributes are set to the same
/// values every time.
///
/// No-op (returning `true`, since there is nothing to wait for) with the
/// system title bar, where AppKit owns the layout. **Main-thread only**: its
/// caller is a `#[qinvokable]`, which runs on the Qt GUI thread, and on macOS
/// that IS the AppKit main thread.
#[cfg(target_os = "macos")]
pub(crate) fn apply_and_center() -> bool {
use objc2_app_kit::{NSApplication, NSView, NSWindowButton, NSWindowTitleVisibility};
use objc2_foundation::MainThreadMarker;
if crate::settings_qt::use_system_title_bar() {
return true;
}
let Some(mtm) = MainThreadMarker::new() else {
log::warn!("[macos-chrome] skipped: not on the AppKit main thread");
return false;
};
let app = NSApplication::sharedApplication(mtm);
// `mainWindow` is nil until AppKit has ordered the window front AND made
// it main, which is LATER than the first rendered frame — that gap is the
// whole bug this function's return value exists to survive. `keyWindow`
// covers the focused-but-not-yet-main moment; anything still unresolved is
// a `false` and the caller tries again.
//
// Deliberately NOT falling back to the `windows` array: iterating an
// NSArray adds objc2 API surface that cannot be compile-checked from
// Linux, and the retry already covers the timing this would.
//
// SAFETY: both are plain accessors; the marker above proves we are on the
// main thread, which is the only requirement AppKit places on them.
let Some(ns_window) = (unsafe { app.mainWindow().or_else(|| app.keyWindow()) }) else {
log::warn!("[macos-chrome] no main/key window yet — will retry");
return false;
};
// --- DIAGNOSTIC: what did Qt actually do to this window? --------------
//
// Read-only. It exists because everything above this line is now correct
// ON PAPER — Qt 6.9's `windowStyleMask()` sets FullSizeContentView from
// `ExpandedClientAreaHint`, `setWindowFlags()` sets
// `titlebarAppearsTransparent` from `NoTitleBarBackgroundHint`, both
// enums resolve in QML (measured: 4194304 / 8388608), the binding is
// declarative and nothing overwrites it — and the owner still sees a
// stock title bar. One of those "shoulds" is false, and this says which
// one instead of the next guess.
//
// The decisive number is the LAST pair: with FullSizeContentView active
// the content view is as tall as the window frame. If it is ~28pt short,
// the bit never landed no matter what the mask claims.
{
let mask = ns_window.styleMask().0;
let content_h = ns_window
.contentView()
.map(|v| v.frame().size.height)
.unwrap_or(-1.0);
log::info!(
"[macos-chrome] DIAG styleMask=0x{mask:x} fullSizeContentView={} \
titlebarTransparent={} windowH={:.1} contentH={:.1}",
(mask & (1 << 15)) != 0,
unsafe { ns_window.titlebarAppearsTransparent() },
ns_window.frame().size.height,
content_h,
);
}
// NO STYLE-MASK POKING HERE. Expanding the client area and dropping the
// titlebar background is `Main.qml`'s job now, through the Qt 6.9 window
// flags `ExpandedClientAreaHint | NoTitleBarBackgroundHint`.
//
// Doing it from here was unwinnable: `QCocoaWindow::windowStyleMask()`
// recomputes the mask from Qt's flags and reassigns it on
// `setWindowFlags()`, on fullscreen transitions and via `setWindowState()`,
// preserving only the fullscreen and unified-toolbar bits — so
// `FullSizeContentView` was erased moments after being set, by Qt's own
// `visibility` handling. The symptom was maddening precisely because the
// OTHER two attributes (transparent titlebar, hidden title) survived: the
// log reported success, the lights moved, and the bar stayed.
//
// What is left for this module is what Qt does NOT do.
//
// 1. Hide the title TEXT. Neither hint touches `titleVisibility` — the
// reference gets it from winit's `with_title_hidden` — so without this
// the window title draws over the expanded content. It is a plain
// NSWindow property, NOT a style-mask bit, which is why Qt's mask
// recomputation cannot erase it (the trap that sank the first attempt).
ns_window.setTitleVisibility(NSWindowTitleVisibility::NSWindowTitleHidden);
// 2. Centre the lights: AppKit parks them for a ~28pt titlebar, and the
// QBZ header is 42px.
// --- Centre the lights.
//
// They are three NSButtons inside ONE container view; shifting the
// container moves all three and preserves their spacing.
let Some(close) = ns_window.standardWindowButton(NSWindowButton::NSWindowCloseButton) else {
// The attributes above DID apply; only the centring has nothing to
// work with, so this counts as done — retrying would not help.
log::warn!("[macos-chrome] no close button: attributes applied, centring skipped");
return true;
};
let win_h = ns_window.frame().size.height;
// Close-button centre in window base coordinates (origin bottom-left, +y
// up), measured from the button's own BOUNDS — its frame lives in the
// superview's space and double-counts the in-container offset. The
// reference records a 2026-07-22 regression from exactly that mistake:
// measuring `frame` and assuming a bottom-left superview shifted the
// lights UP instead of down.
let measure_centre_from_top = |btn: &NSView| -> f64 {
let r = btn.convertRect_toView(btn.bounds(), None);
win_h - (r.origin.y + r.size.height / 2.0)
};
let before = measure_centre_from_top(&close);
let move_down = HEADER_CENTRE_PT - before; // visual pts, +ve = down
if move_down.abs() < 0.5 {
return true; // already centred (idempotent re-entry)
}
// SAFETY: main-thread view geometry on a live window, as above.
let Some(container) = (unsafe { close.superview() }) else {
return true;
};
// The container's frame is in ITS superview's coordinate space, and
// AppKit flips the titlebar hierarchy (+y down when flipped).
let parent_flipped = unsafe { container.superview() }
.map(|sv| sv.isFlipped())
.unwrap_or(false);
let original = container.frame();
let mut frame = original;
frame.origin.y += if parent_flipped { move_down } else { -move_down };
unsafe { container.setFrame(frame) };
// Re-measure: if the shift landed FURTHER from target than where it
// started (a coordinate-model surprise), undo it. Stock placement is an
// acceptable fallback; a wrongly shifted one is not.
let after = measure_centre_from_top(&close);
if (after - HEADER_CENTRE_PT).abs() > (before - HEADER_CENTRE_PT).abs() {
unsafe { container.setFrame(original) };
log::warn!(
"[macos-chrome] traffic-light shift reverted: centre {before:.1}pt -> {after:.1}pt from top (target {HEADER_CENTRE_PT}pt, parent flipped: {parent_flipped})"
);
return true;
}
log::info!(
"[macos-chrome] traffic lights centred: {before:.1}pt -> {after:.1}pt from top (target {HEADER_CENTRE_PT}pt, parent flipped: {parent_flipped})"
);
true
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn apply_and_center() -> bool {
true
}
+10
View File
@@ -241,6 +241,16 @@ mod mini_qt;
mod tray_qt; mod tray_qt;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod tray_linux; mod tray_linux;
// macOS menu-bar tray (NSStatusItem), ported 2026-08-05. The K2 ruling that
// kept macOS out was reasoned from a premise that reads the reference
// backwards — see the module header.
#[cfg(target_os = "macos")]
mod tray_macos;
// macOS custom chrome: the overlay window attributes + centring the native
// traffic lights in the 42px header. Portable module (a no-op stub off
// macOS) rather than a gated `mod` line, so the QbzShell invokable that
// calls it needs no cfg of its own.
mod macos_chrome;
// MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04 // MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04
// after smoking the tray: "no aparece por ejemplo en el widget de now playing // after smoking the tray: "no aparece por ejemplo en el widget de now playing
// de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see // de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see
+84 -3
View File
@@ -392,15 +392,28 @@ fn save_streaming_quality(key: &str) {
// takes effect on the next launch (restart semantics, 1:1 Slint). // takes effect on the next launch (restart semantics, 1:1 Slint).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// PER-OS default, and it has to be: Linux keeps the system decorations,
/// macOS defaults to the overlay (custom) mode where the native traffic
/// lights float over the QBZ header.
///
/// This port hard-coded `true` on every platform. Because `ui_prefs.json` is
/// SHARED with the Slint build, that did not just break Qt's own macOS
/// chrome — once the key was written it also flipped the Slint binary to the
/// system title bar, since an explicit value overrides its per-OS default.
/// 1:1 with `crates/qbz/src/ui_prefs.rs:645-647`.
fn default_use_system_title_bar() -> bool {
!cfg!(target_os = "macos")
}
pub fn use_system_title_bar() -> bool { pub fn use_system_title_bar() -> bool {
let Some(path) = prefs_path() else { let Some(path) = prefs_path() else {
return true; return default_use_system_title_bar();
}; };
std::fs::read_to_string(path) std::fs::read_to_string(path)
.ok() .ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok()) .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("use_system_title_bar").and_then(|q| q.as_bool())) .and_then(|v| v.get("use_system_title_bar").and_then(|q| q.as_bool()))
.unwrap_or(true) .unwrap_or_else(default_use_system_title_bar)
} }
/// Flip + persist the pref; returns the new value (for the menu state). /// Flip + persist the pref; returns the new value (for the menu state).
@@ -410,7 +423,7 @@ pub fn use_system_title_bar() -> bool {
/// menu is told the pref is unchanged rather than being handed a flip the file /// menu is told the pref is unchanged rather than being handed a flip the file
/// never got. /// never got.
pub fn toggle_system_title_bar() -> bool { pub fn toggle_system_title_bar() -> bool {
toggle_pref_bool("use_system_title_bar", true).unwrap_or_else(|| { toggle_pref_bool("use_system_title_bar", default_use_system_title_bar()).unwrap_or_else(|| {
let current = use_system_title_bar(); let current = use_system_title_bar();
log::warn!( log::warn!(
"[qbz-qt] use_system_title_bar toggle skipped (prefs unreadable) — staying {current}" "[qbz-qt] use_system_title_bar toggle skipped (prefs unreadable) — staying {current}"
@@ -419,6 +432,51 @@ pub fn toggle_system_title_bar() -> bool {
}) })
} }
/// Hide the custom title bar entirely: no drawn controls and — the part that
/// is easy to miss — NO DRAG SURFACE. The reference reads it into
/// `chrome-drag-enabled` (`qbz-ui/ui/shell/HeaderBar.slint:594-596`), which
/// gates the press-to-move TouchArea and, through `chrome-controls`, the
/// drawn cluster as well. Only meaningful while the custom chrome is active;
/// the Appearance row disables itself under the system title bar.
///
/// Until 2026-08-04 this pref was written by the Settings row and read by
/// nobody in this port — the owner's "renders, persists, drives nothing".
pub fn hide_title_bar() -> bool {
pref_bool("hide_title_bar", false)
}
/// Flip the two-finger swipe mapping (fingers right = back by default, the
/// natural-scrolling convention). For users running WITHOUT natural
/// scrolling — Slint `AppearanceState.invert-swipe-navigation`.
pub fn invert_swipe_navigation() -> bool {
pref_bool("invert_swipe_navigation", false)
}
/// Put the playing track in the OS window title. The format is FIXED
/// ("{track} - {artist} | qbz", `qbz-ui/ui/app.slint:44`); the reference's
/// template row is commented out there and this port cut it too.
pub fn window_title_show() -> bool {
pref_bool("window_title_show", false)
}
/// Draw the in-app min/max/close cluster at all (Slint
/// `AppearanceState.show-window-controls`, consumed at
/// `HeaderBar.slint:599`). "Disable if your window manager handles these."
pub fn show_window_controls() -> bool {
pref_bool("show_window_controls", true)
}
/// Cluster on the LEFT instead of the right. The pref carries the STRING
/// ("left" | "right"); the settings document carries the index into
/// [`WC_POSITION_VALUES`], so the two representations meet here.
///
/// Left placement is not just a different x: the reference also flips the
/// button ORDER to the macOS one (close · max · min vs min · max · close —
/// `qbz-ui/ui/shell/WindowControls.slint:41-79`).
pub fn wc_on_left() -> bool {
pref_str("wc_position", "right") == "left"
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ui_prefs.json (app-wide ambient background, phase 14) — the Slint // ui_prefs.json (app-wide ambient background, phase 14) — the Slint
// `app_background` enum key: "off" | "ambient" | "blurred" // `app_background` enum key: "off" | "ambient" | "blurred"
@@ -1924,6 +1982,8 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
} }
"invert-swipe-navigation" => { "invert-swipe-navigation" => {
save_pref("invert_swipe_navigation", serde_json::json!(value)); save_pref("invert_swipe_navigation", serde_json::json!(value));
// LIVE — NavGestureLayer reads it per gesture.
crate::shell_bridge::ui(move |mut b| b.as_mut().set_invert_swipe_navigation(value));
Ok(Apply::None) Ok(Apply::None)
} }
"in-app-toasts" => { "in-app-toasts" => {
@@ -1936,6 +1996,8 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
} }
"window-title-show" => { "window-title-show" => {
save_pref("window_title_show", serde_json::json!(value)); save_pref("window_title_show", serde_json::json!(value));
// LIVE — Main.qml's `title` binding re-evaluates (app.slint:44).
crate::shell_bridge::ui(move |mut b| b.as_mut().set_window_title_show(value));
Ok(Apply::None) Ok(Apply::None)
} }
"use-system-title-bar" => { "use-system-title-bar" => {
@@ -1948,10 +2010,15 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
} }
"hide-title-bar" => { "hide-title-bar" => {
save_pref("hide_title_bar", serde_json::json!(value)); save_pref("hide_title_bar", serde_json::json!(value));
// LIVE (no restart): this one only layers in-app — it drops the
// drawn cluster AND the drag surface, per the reference's
// `chrome-drag-enabled` (HeaderBar.slint:594-596).
crate::shell_bridge::ui(move |mut b| b.as_mut().set_hide_title_bar(value));
Ok(Apply::None) Ok(Apply::None)
} }
"show-window-controls" => { "show-window-controls" => {
save_pref("show_window_controls", serde_json::json!(value)); save_pref("show_window_controls", serde_json::json!(value));
crate::shell_bridge::ui(move |mut b| b.as_mut().set_show_window_controls(value));
Ok(Apply::None) Ok(Apply::None)
} }
"show-volume-steppers" => { "show-volume-steppers" => {
@@ -2225,6 +2292,12 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
return; return;
}; };
save_pref("wc_position", serde_json::json!(v)); save_pref("wc_position", serde_json::json!(v));
// LIVE — HeaderBar re-anchors the cluster and flips its order.
// The reference gets this for free (its settings view writes the
// shared AppearanceState directly, main.rs:11265-11271 is
// persist-only); here the push is the whole wiring.
let on_left = *v == "left";
crate::shell_bridge::ui(move |mut b| b.as_mut().set_wc_on_left(on_left));
} }
"mini-default-view" => { "mini-default-view" => {
let Some(v) = MINI_VIEW_VALUES.get(index) else { let Some(v) = MINI_VIEW_VALUES.get(index) else {
@@ -2276,6 +2349,14 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
return; return;
}; };
save_pref("auto_theme_source", serde_json::json!(v)); save_pref("auto_theme_source", serde_json::json!(v));
// Regenerate NOW if the auto theme is the one on screen. The
// reference regenerates on activation, on source change, on image
// pick and on the explicit button (auto_theme.rs header); this is
// the "on source change" arm, and without it the row persisted a
// source that only took effect on the next launch.
if crate::theme_qt::current_slug() == "auto" {
crate::theme_qt::publish_theme();
}
} }
other => log::warn!("[qbz-qt] unknown settings select key: {other}"), other => log::warn!("[qbz-qt] unknown settings select key: {other}"),
} }
+64
View File
@@ -112,6 +112,39 @@ pub mod qbz_shell {
// The PERSISTED pref as it stands NOW (the app-menu check state; // The PERSISTED pref as it stands NOW (the app-menu check state;
// flips live when the user toggles, applies on the next launch). // flips live when the user toggles, applies on the next launch).
#[qproperty(bool, system_title_bar_pref)] #[qproperty(bool, system_title_bar_pref)]
// Hide the custom title bar: kills the drawn cluster AND the drag
// surface (Slint `chrome-drag-enabled`, HeaderBar.slint:594-596).
// LIVE, unlike `system_title_bar` — nothing negotiates with the
// compositor here, it is pure in-app layering.
#[qproperty(bool, hide_title_bar)]
// The drawn cluster's visibility and side (Slint
// `show-window-controls` / `wc-position-index`, HeaderBar.slint:
// 597-609). Both LIVE: the reference re-anchors from these same
// properties with no restart, which is why its Rust arm is
// persist-only (main.rs:11265-11271) — Slint's settings view writes
// the shared state object directly. Qt has no such shared object, so
// the push has to come from here or the row goes dead.
#[qproperty(bool, show_window_controls)]
#[qproperty(bool, wc_on_left)]
// Put the playing track in the OS window title (app.slint:44). LIVE.
#[qproperty(bool, window_title_show)]
// Flip the two-finger swipe mapping for users who run WITHOUT
// natural scrolling (Slint `invert-swipe-navigation`,
// AppShell.slint:298-312). LIVE — the gesture layer reads it per
// gesture, nothing is cached.
#[qproperty(bool, invert_swipe_navigation)]
// macOS keeps the NATIVE traffic lights (the overlay window
// attributes), so the drawn cluster is never mounted there and the
// header reserves a left inset for the lights instead — Slint
// `AppearanceState.is-macos`, HeaderBar.slint:598 and :605-607.
#[qproperty(bool, is_macos)]
// The RENDERER group is Linux-only in the reference: macOS is always
// Skia/Metal and Windows negotiates its own backend, so the selector
// would offer choices that change nothing there
// (`main.rs:318` seeds `renderer-setting-visible` from the same
// `cfg!`, and AppearanceSettings.slint:1011-1043 gates the whole
// group AND the Preferred-GPU row on it).
#[qproperty(bool, is_linux)]
// --- Main-window geometry ----------------------------------------- // --- Main-window geometry -----------------------------------------
// The restored LOGICAL size + maximized flag from the shared // The restored LOGICAL size + maximized flag from the shared
@@ -295,6 +328,17 @@ pub mod qbz_shell {
#[qinvokable] #[qinvokable]
fn large_cycle_spectrum(self: Pin<&mut QbzShell>); fn large_cycle_spectrum(self: Pin<&mut QbzShell>);
/// macOS only (a no-op elsewhere): apply the overlay window
/// attributes and vertically centre the native traffic lights in the
/// header. Called from `Main.qml` on the first rendered frame —
/// AppKit has no main window before that, and the call is idempotent
/// so a retry costs nothing. See `macos_chrome.rs`.
/// Returns TRUE once the chrome is actually applied; the caller
/// retries while it is false (AppKit has no main window on the first
/// rendered frame).
#[qinvokable]
fn apply_mac_chrome(self: Pin<&mut QbzShell>) -> bool;
// --- Theme (phase 19) --------------------------------------------- // --- Theme (phase 19) ---------------------------------------------
/// Appearance > Theme row: persist the picked slug + republish /// Appearance > Theme row: persist the picked slug + republish
/// `themeJson` (live switch). /// `themeJson` (live switch).
@@ -435,6 +479,13 @@ pub struct QbzShellRust {
sidebar_folder_popup_json: QString, sidebar_folder_popup_json: QString,
system_title_bar: bool, system_title_bar: bool,
system_title_bar_pref: bool, system_title_bar_pref: bool,
hide_title_bar: bool,
show_window_controls: bool,
wc_on_left: bool,
window_title_show: bool,
invert_swipe_navigation: bool,
is_macos: bool,
is_linux: bool,
window_width: f32, window_width: f32,
window_height: f32, window_height: f32,
window_maximized: bool, window_maximized: bool,
@@ -499,6 +550,15 @@ impl Default for QbzShellRust {
), ),
system_title_bar: crate::settings_qt::use_system_title_bar(), system_title_bar: crate::settings_qt::use_system_title_bar(),
system_title_bar_pref: crate::settings_qt::use_system_title_bar(), system_title_bar_pref: crate::settings_qt::use_system_title_bar(),
hide_title_bar: crate::settings_qt::hide_title_bar(),
show_window_controls: crate::settings_qt::show_window_controls(),
wc_on_left: crate::settings_qt::wc_on_left(),
window_title_show: crate::settings_qt::window_title_show(),
invert_swipe_navigation: crate::settings_qt::invert_swipe_navigation(),
// Compile-time, not a pref: the reference sets `is-macos` from
// the same `cfg!` (main.rs seeds AppearanceState at startup).
is_macos: cfg!(target_os = "macos"),
is_linux: cfg!(target_os = "linux"),
window_width, window_width,
window_height, window_height,
window_maximized: crate::settings_qt::window_maximized(), window_maximized: crate::settings_qt::window_maximized(),
@@ -689,6 +749,10 @@ impl qbz_shell::QbzShell {
crate::large_cycle_spectrum(); crate::large_cycle_spectrum();
} }
pub fn apply_mac_chrome(self: Pin<&mut Self>) -> bool {
crate::macos_chrome::apply_and_center()
}
pub fn theme_set(self: Pin<&mut Self>, slug: QString) { pub fn theme_set(self: Pin<&mut Self>, slug: QString) {
crate::theme_set(slug.to_string()); crate::theme_set(slug.to_string());
} }
+22 -3
View File
@@ -77,12 +77,31 @@ fn custom_colors() -> ThemeColors {
qbz_theme::theme_from_base(&base) qbz_theme::theme_from_base(&base)
} }
/// Build the generator's source from the persisted pref — 1:1 with the
/// reference's `auto_theme::source_from_prefs` (`crates/qbz/src/auto_theme.rs:
/// 20-26`), including the "anything unknown means System" fallback.
///
/// This port used to hard-code `AutoSource::System`, which left the
/// Appearance "Source" row (System Colors / Wallpaper Sync / Custom Image)
/// selectable, persisted, and completely inert: picking Wallpaper Sync
/// regenerated the SAME system palette.
fn auto_source() -> qbz_theme::AutoSource {
match crate::settings_qt::pref_str("auto_theme_source", "system").as_str() {
"wallpaper" => qbz_theme::AutoSource::Wallpaper,
"image" => {
qbz_theme::AutoSource::Image(crate::settings_qt::pref_str("auto_theme_image_path", ""))
}
_ => qbz_theme::AutoSource::System,
}
}
fn auto_colors() -> ThemeColors { fn auto_colors() -> ThemeColors {
match qbz_theme::generate_auto_theme(&qbz_theme::AutoSource::System) { match qbz_theme::generate_auto_theme(&auto_source()) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
// Headless / no DE portal: keep the row selectable but fall back // Headless / no DE portal, or a picked image that has since been
// to the default dark tokens (POC-NOTE). // moved: keep the row selectable and fall back to the default
// dark tokens.
log::warn!("[qbz-qt] auto theme generation failed ({e}); falling back to OLED"); log::warn!("[qbz-qt] auto theme generation failed ({e}); falling back to OLED");
qbz_theme::palette(qbz_theme::default_theme_id()) qbz_theme::palette(qbz_theme::default_theme_id())
} }
+323
View File
@@ -0,0 +1,323 @@
//! macOS menu-bar tray (`NSStatusItem`), hand-rolled on objc2 0.5.
//!
//! Port of `crates/qbz/src/tray/macos.rs`. Until 2026-08-05 this port had no
//! macOS tray at all — `tray_qt::init`'s non-Linux arm was a single log line —
//! and the owner's smoke found the two symptoms that come from that ONE cause:
//! the "Enable tray icon" setting was on and no icon appeared, and
//! "Close to tray" was on and closing quit the app. The second is not a
//! separate bug: `should_hide_on_close` is `close_to_tray && handle().is_some()`,
//! and with no tray the handle is `None`, so closing correctly falls through to
//! quit. One tray, two symptoms.
//!
//! ## Why hand-rolled instead of `tray-icon`
//!
//! The `Cargo.toml` note that deferred this said macOS "would be a NEW
//! implementation, not a port", on the grounds that the reference only
//! hand-rolls because Slint's winit backend bundles `muda`. That reads the
//! reference backwards: its `macos.rs` exists precisely to AVOID muda, so it
//! depends on nothing winit provides and ports here almost verbatim. The
//! premise was true (Qt has no muda conflict) and the conclusion did not
//! follow.
//!
//! ## What changed vs the reference
//!
//! It got SIMPLER. The Slint version carries a process-global `CTX` holding a
//! `Runtime`, a `slint::Weak<AppWindow>` and a `tokio::runtime::Handle`,
//! because its dispatch helpers need all three. This port's helpers
//! (`tray_qt::toggle_window`, `quit`, `dispatch_*`) are free functions that
//! marshal themselves, so the whole `CTX` disappears and `dispatch_tag` is a
//! plain match.
//!
//! Everything here is main-thread only: the `NSStatusItem`, the menu and the
//! `QbzTrayMenuTarget` instance are `!Send` (`thread_local!`). [`create`] must
//! be reached through `tray_bridge::ui`, which queues onto the Qt GUI thread —
//! on macOS that IS the AppKit main thread.
//!
//! Click behaviour matches the reference and the Tauri tray: the menu is NOT
//! permanently attached to the status item. The status button gets its own
//! target-action firing on both left and right mouse-up; LEFT toggles the
//! window, RIGHT (or control-click) pops the menu transiently.
//!
//! **NOT COMPILED ON LINUX.** Every objc2 name below is taken from the
//! reference, which builds on a Mac, and the feature set in `Cargo.toml` is
//! copied from its own `Cargo.toml` rather than deduced.
use std::cell::RefCell;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
use objc2::{declare_class, msg_send_id, mutability, sel, ClassType, DeclaredClass};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSEventMask, NSEventModifierFlags, NSEventType,
NSImage, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
};
use objc2_foundation::{MainThreadMarker, NSData, NSInteger, NSSize, NSString};
// 44px assets (= 22pt @2x menu bar). Filename trap, shared with Linux:
// `tray-dark-*` holds the WHITE glyph, `tray-light-*` holds the BLACK glyph.
const ICON_COLOR: &[u8] = include_bytes!("../icons/tray-color-44.png");
const ICON_WHITE: &[u8] = include_bytes!("../icons/tray-dark-44.png");
const ICON_BLACK: &[u8] = include_bytes!("../icons/tray-light-44.png");
// Menu item tags -> actions.
const TAG_PLAY_PAUSE: NSInteger = 1;
const TAG_NEXT: NSInteger = 2;
const TAG_PREVIOUS: NSInteger = 3;
const TAG_SHOW_HIDE: NSInteger = 4;
const TAG_QUIT: NSInteger = 5;
thread_local! {
// Kept alive for the tray's lifetime; dropping the status item removes it
// from the menu bar. All three are `!Send`, main-thread only.
static STATUS_ITEM: RefCell<Option<Retained<NSStatusItem>>> = const { RefCell::new(None) };
static MENU_TARGET: RefCell<Option<Retained<QbzTrayMenuTarget>>> = const { RefCell::new(None) };
// The menu is NOT permanently attached to the status item (that would make
// a left-click pop it). It lives here and is only flashed onto the status
// item for the duration of a right/control-click pop-up.
static MENU: RefCell<Option<Retained<NSMenu>>> = const { RefCell::new(None) };
}
declare_class!(
struct QbzTrayMenuTarget;
unsafe impl ClassType for QbzTrayMenuTarget {
type Super = NSObject;
type Mutability = mutability::MainThreadOnly;
// Distinct from the reference's class name is NOT required — the two
// binaries never share a process — but the name is kept identical so
// a crash log from either build reads the same.
const NAME: &'static str = "QbzTrayMenuTarget";
}
impl DeclaredClass for QbzTrayMenuTarget {
type Ivars = ();
}
unsafe impl QbzTrayMenuTarget {
#[method(onMenuItem:)]
fn on_menu_item(&self, sender: Option<&NSMenuItem>) {
let tag = sender.map(|s| unsafe { s.tag() }).unwrap_or(0);
dispatch_tag(tag);
}
// Fires on left AND right mouse-up of the status-bar button (see
// `sendActionOn` in `create`). We inspect the current event to route:
// right-click / control-click -> pop the menu; plain left -> toggle.
#[method(onStatusButton:)]
fn on_status_button(&self, _sender: Option<&AnyObject>) {
handle_status_click();
}
}
);
impl QbzTrayMenuTarget {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = mtm.alloc().set_ivars(());
unsafe { msg_send_id![super(this), init] }
}
}
/// Route a clicked menu item's tag to the shared dispatch helpers.
///
/// No context to look up — unlike the reference, every helper here marshals
/// itself onto whatever thread it needs.
fn dispatch_tag(tag: NSInteger) {
log::info!("[tray] menu item activated: tag={tag}");
match tag {
TAG_PLAY_PAUSE => crate::tray_qt::dispatch_play_pause(),
TAG_NEXT => crate::tray_qt::dispatch_next(),
TAG_PREVIOUS => crate::tray_qt::dispatch_previous(),
TAG_SHOW_HIDE => crate::tray_qt::toggle_window(),
TAG_QUIT => crate::tray_qt::quit(),
other => log::debug!("[tray] unhandled menu tag {other}"),
}
}
/// Status-bar button click router. Reads the current AppKit event: a
/// right-click or control-click pops the menu, a plain left-click toggles the
/// window. Main thread only (it is an AppKit action callback).
fn handle_status_click() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let (is_right, is_ctrl) = match app.currentEvent() {
Some(ev) => {
let ty = unsafe { ev.r#type() };
let mods = unsafe { ev.modifierFlags() };
(
ty == NSEventType::RightMouseUp,
mods.contains(NSEventModifierFlags::NSEventModifierFlagControl),
)
}
None => (false, false),
};
if is_right || is_ctrl {
pop_up_menu(mtm);
} else {
crate::tray_qt::toggle_window();
}
}
/// Pop the tray menu transiently. Non-deprecated replacement for
/// `popUpStatusItemMenu:`: flash the menu onto the status item, simulate a
/// click (which opens it modally), then detach it so a left-click does not
/// open it. Main thread only.
fn pop_up_menu(mtm: MainThreadMarker) {
STATUS_ITEM.with(|s| {
let Some(status_item) = s.borrow().as_ref().cloned() else {
return;
};
MENU.with(|m| {
if let Some(menu) = m.borrow().as_ref() {
unsafe { status_item.setMenu(Some(menu)) };
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe { button.performClick(None) };
}
unsafe { status_item.setMenu(None) };
}
});
});
}
/// Resolve the icon bytes + whether to render it as a macOS template image
/// (template = adapts to the light/dark menu bar automatically).
/// - "color" -> full vinyl, not a template
/// - "mono-light" -> white glyph (`tray-dark`), not a template
/// - "mono-dark" -> black glyph (`tray-light`), not a template
/// - "auto"/other -> black glyph as a template, so macOS adapts it
fn icon_for(theme: &str) -> (&'static [u8], bool) {
match theme {
"color" => (ICON_COLOR, false),
"mono-light" => (ICON_WHITE, false),
"mono-dark" => (ICON_BLACK, false),
_ => (ICON_BLACK, true),
}
}
/// Build an `NSImage` from PNG bytes, marking it a template image when asked.
fn make_image(bytes: &[u8], is_template: bool) -> Option<Retained<NSImage>> {
let data = NSData::with_bytes(bytes);
let image = NSImage::initWithData(NSImage::alloc(), &data)?;
unsafe { image.setTemplate(is_template) };
// The PNG assets are 44px (22pt @2x). Without an explicit point size the
// menu bar renders them at native pixel size -> a giant icon. Pin to the
// standard menu-bar glyph box (18pt; the bar is 22pt tall).
unsafe { image.setSize(NSSize::new(18.0, 18.0)) };
Some(image)
}
/// Apply the resolved icon to the status item's button.
fn apply_icon(status_item: &NSStatusItem, theme: &str, mtm: MainThreadMarker) {
let (bytes, is_template) = icon_for(theme);
let Some(image) = make_image(bytes, is_template) else {
log::error!("[tray] failed to decode menu-bar icon");
return;
};
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe { button.setImage(Some(&image)) };
}
}
/// Build the menu-bar item + menu. MUST run on the main thread — reach it
/// through `tray_bridge::ui`, never directly from the tokio runtime.
///
/// Returns whether the item was created, so `tray_qt::init` can set `TRAY` (and
/// therefore `trayLive`) only when there really is a tray. Getting that wrong
/// is what makes close-to-tray hide into nothing.
pub(crate) fn create(theme_override: &str) -> bool {
let Some(mtm) = MainThreadMarker::new() else {
log::error!("[tray] create called off the main thread");
return false;
};
// The action target. Held alive in a thread_local so the menu's weak
// target reference stays valid.
let target = QbzTrayMenuTarget::new(mtm);
let target_obj: &AnyObject = &target;
let action = sel!(onMenuItem:);
// The menu: 3 transport items, separator, show/hide, separator, quit.
let menu = NSMenu::new(mtm);
let empty_key = NSString::from_str("");
let make_item = |title: &str, tag: NSInteger| -> Retained<NSMenuItem> {
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action),
&empty_key,
)
};
unsafe {
item.setTarget(Some(target_obj));
item.setTag(tag);
item.setEnabled(true);
}
item
};
menu.addItem(&make_item(&qbz_i18n::t("Play/Pause"), TAG_PLAY_PAUSE));
menu.addItem(&make_item(&qbz_i18n::t("Next Track"), TAG_NEXT));
menu.addItem(&make_item(&qbz_i18n::t("Previous Track"), TAG_PREVIOUS));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&make_item(&qbz_i18n::t("Show/Hide Window"), TAG_SHOW_HIDE));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&make_item(&qbz_i18n::t("Quit QBZ"), TAG_QUIT));
// Build the status item and wire the icon.
let status_bar = unsafe { NSStatusBar::systemStatusBar() };
let status_item = unsafe { status_bar.statusItemWithLength(NSVariableStatusItemLength) };
apply_icon(&status_item, theme_override, mtm);
// Do NOT attach the menu permanently (that makes any click open it). Give
// the status button its own action that fires on left AND right mouse-up;
// `handle_status_click` decides toggle-vs-menu.
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe {
button.setTarget(Some(target_obj));
button.setAction(Some(sel!(onStatusButton:)));
button.sendActionOn(NSEventMask::LeftMouseUp | NSEventMask::RightMouseUp);
}
}
STATUS_ITEM.with(|s| *s.borrow_mut() = Some(status_item));
MENU_TARGET.with(|t| *t.borrow_mut() = Some(target));
MENU.with(|m| *m.borrow_mut() = Some(menu));
// A bare `cargo run` binary is NOT a bundled .app. Without an explicit
// Regular activation policy + activation, macOS treats the app as a
// background process and `[NSApp sendAction:]` may not route the menu item
// target-action. Force Regular + active.
ensure_regular_active_app(mtm);
log::info!("[tray] menu-bar item initialized (theme={theme_override})");
true
}
/// Re-theme the live menu-bar icon. Main thread only.
pub(crate) fn set_icon_theme(theme: &str) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
STATUS_ITEM.with(|s| {
if let Some(status_item) = s.borrow().as_ref() {
apply_icon(status_item, theme, mtm);
}
});
}
/// Force the app to a Regular, active application so macOS dispatches the
/// `NSStatusItem` menu-item actions. Main thread only.
///
/// This deliberately overlaps `tray_qt::set_mac_dock_hidden`, which flips the
/// SAME policy to `.accessory` for the Hide-Dock setting. No conflict: this
/// runs once at tray creation, while the window is up and Regular is correct;
/// the Hide-Dock arm only fires on a close-to-tray afterwards.
fn ensure_regular_active_app(mtm: MainThreadMarker) {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
}
+52 -7
View File
@@ -5,10 +5,19 @@
//! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is //! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is
//! `tray_bridge.rs` (A-21). //! `tray_bridge.rs` (A-21).
//! //!
//! **Linux only (owner ruling K2).** `tray/macos.rs` exists in the Slint tree //! Linux (ksni, `tray_linux.rs`) and macOS (`NSStatusItem`, `tray_macos.rs`).
//! only because Slint's winit backend already bundles `muda`; under Qt that //! Windows has no tray in either tree.
//! premise is gone, so macOS would be a NEW implementation, not a port //!
//! (contract §13-D6). Windows has no tray in either tree. //! RETIRED 2026-08-05 — the note that used to sit here said macOS "would be a
//! NEW implementation, not a port", because the reference only hand-rolls its
//! menu-bar item to dodge the `muda` that Slint's winit backend bundles, and
//! Qt has no such conflict. The premise was right and the conclusion was
//! backwards: `tray/macos.rs` avoids muda entirely, depends on nothing winit
//! provides, and ported here almost verbatim (it got SHORTER — its
//! `CTX`/`slint::Weak` plumbing has no counterpart on this side). The cost of
//! believing it: the owner's macOS smoke found the tray icon missing and
//! close-to-tray quitting the app, which is the one absence wearing two
//! faces.
//! //!
//! ## Three things about this module are not style choices //! ## Three things about this module are not style choices
//! //!
@@ -105,11 +114,20 @@ pub(crate) fn compose_tooltip(np: Option<&NowPlaying>, is_playing: bool) -> (Str
/// Cross-thread handle to the live tray. Cloneable; mutators forward to the /// Cross-thread handle to the live tray. Cloneable; mutators forward to the
/// platform backend (ksni on Linux) and are no-ops when the tray is disabled or /// platform backend (ksni on Linux) and are no-ops when the tray is disabled or
/// on a platform without a live-update path. /// on a platform without a live-update path.
/// 1:1 with `crates/qbz/src/tray/mod.rs:52-102`, minus the macOS arm (K2). /// 1:1 with `crates/qbz/src/tray/mod.rs:52-102`, macOS arm included since
/// 2026-08-05.
///
/// The macOS side carries no per-handle state: its `NSStatusItem` lives in a
/// main-thread `thread_local` (it is `!Send`), so the flag only records that a
/// tray EXISTS. That is the load-bearing part — `handle().is_some()` is what
/// `should_hide_on_close` consults, and it is why an absent macOS tray made
/// close-to-tray quit instead of hiding.
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(crate) struct TrayHandle { pub(crate) struct TrayHandle {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
linux: Option<crate::tray_linux::LinuxTrayHandle>, linux: Option<crate::tray_linux::LinuxTrayHandle>,
#[cfg(target_os = "macos")]
macos: bool,
} }
impl TrayHandle { impl TrayHandle {
@@ -146,7 +164,13 @@ impl TrayHandle {
h.set_icon_theme(theme); h.set_icon_theme(theme);
return; return;
} }
#[cfg(not(target_os = "linux"))] #[cfg(target_os = "macos")]
if self.macos {
// Main-thread only — the status item is a thread_local there.
crate::tray_bridge::ui(move |_b| crate::tray_macos::set_icon_theme(&theme));
return;
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let _ = theme; let _ = theme;
} }
} }
@@ -325,7 +349,28 @@ pub(crate) fn init(theme_override: String, enabled: bool) {
.expect("spawn tray init thread"); .expect("spawn tray init thread");
} }
#[cfg(not(target_os = "linux"))] // macOS: the NSStatusItem must be built on the AppKit main thread, and
// `init` runs on the tokio runtime — so it is queued through the tray
// bridge, whose Qt GUI thread IS the AppKit main thread. (The Linux arm
// above needs the opposite: a thread OUTSIDE tokio, because ksni blocks.)
#[cfg(target_os = "macos")]
{
crate::tray_bridge::ui(move |_b| {
let ok = crate::tray_macos::create(&theme_override);
// Same two arms as Linux: `TRAY` and `tray_live` are set together,
// and a failure must publish `false` or close-to-tray hides the
// window into a tray that is not there.
if ok {
let _ = TRAY.set(TrayHandle { macos: true });
push_tray_live(true);
} else {
log::error!("[tray] macOS menu-bar item could not be created");
push_tray_live(false);
}
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{ {
let _ = theme_override; let _ = theme_override;
log::info!("[tray] no tray backend on this platform"); log::info!("[tray] no tray backend on this platform");