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:
13 files changed
+1069
-38
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ⌖
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user