feat(qt): close-to-tray across every exit, and the macOS tray settings rows
Block B6 of the miniplayer/tray contract. B5 landed a tray whose clicks reached Rust and stopped there; this is the other half. qml/Main.qml — hideToTray(), showFromTray() and the ONE close choreography every exit now calls, plus the handlers for the tray bridge's four signals and the mini's close-app signal. The close predicate is both conditions, trayLive && closeToTray: with the tray off a close QUITS, because gating on the setting alone strands the process with no window on a desktop that has no SNI host. close.accepted = false happens before the hide — hiding never arms Qt's quit check, an accepted close does. The miniplayer's enter and exit are rewired onto those two verbs, which is what makes the tray toggle honest: the reference hides the main window behind the tray's back and its second tray click brings the main window up beside a live mini. THE EXITS ARE FIVE, NOT FOUR. The contract enumerated four and gave the grep that finds them; re-run today it returns a fifth, because the kiosk shell grew its own drawn close button after the contract was written. It goes through the same choreography. Four POC-NOTEs die here. showFromTray also un-iconifies. Qt's hide() preserves windowStates where the reference destroys the surface, so a window minimized before it was hidden would otherwise come back minimized — present in the taskbar, invisible on screen, which reads as the tray having done nothing. qml/settings/AppearanceSettings.qml + src/settings_qt.rs — the three macOS rows: the MENU BAR header swap, the macOS wording on Close-to-tray, and the Hide-Dock row, all absent on Linux. minimize_to_tray gets storage and no row, which is the owner's standing decision. And the tray-close-to-tray write arm now pushes to the bridge beside its persist, so toggling it changes the very next close with no restart. src/mini_bridge.rs — close_app and the signal it routes through, so the mini's red X will honour close-to-tray like every other exit. Its button is B3's, so the chain has no caller yet. Driven, not assumed: with the tray live and close-to-tray just pushed false, the app-menu Close row QUIT the process — which can only happen if the push reached the bridge, since the seed had set it true. And Shift+M then Escape returns the main window pixel-identical, zero of 1.6 M pixels changed, with the mini alone on a black framebuffer in between: the main window really is hidden, and Qt's hide/show preserves its geometry with no restore call. cc
This commit is contained in:
6 files changed
+324
-35
No files matched your search
+143
-15
@@ -200,8 +200,8 @@ ApplicationWindow {
|
||||
// Same reason, and it bites harder here: the tray's clicks reach QML
|
||||
// ONLY through this bridge's signals, so an unbooted QbzTray is a tray
|
||||
// whose every click does nothing, with zero evidence in the log
|
||||
// (contract §15 trap 27). The handlers for those signals arrive with
|
||||
// B6, together with the window verbs they call.
|
||||
// (contract §15 trap 27). The Connections that answer those four
|
||||
// signals are below, beside the window verbs they call.
|
||||
QbzTray.boot()
|
||||
|
||||
// Seed the maximized latch ONCE, imperatively — see its declaration for
|
||||
@@ -447,20 +447,131 @@ ApplicationWindow {
|
||||
geometrySaveTimer.restart()
|
||||
}
|
||||
|
||||
// Two shutdown paths, because the header's close button calls Qt.quit()
|
||||
// (shell/HeaderBar.qml) which never raises `closing`, while a WM close or
|
||||
// Alt+F4 does. Both end up in the same dirty check, so the duplicate is
|
||||
// free — and either way a resize in the last 400ms still lands, including
|
||||
// the taskbar close of a minimized window (that is what the exit variant
|
||||
// and the floating cache are for).
|
||||
onClosing: window.persistWindowGeometryOnExit()
|
||||
// The WM close / Alt+F4 exit, routed into the ONE close choreography
|
||||
// (A-26, below). It is the ONLY exit that delivers a close EVENT, and
|
||||
// `closeOrHide` refuses that event before hiding — §3.1 measured that
|
||||
// QGuiApplicationPrivate::maybeLastWindowClosed is reached from exactly two
|
||||
// sites, both inside QWindow::event and both gated on the accepted flag, so
|
||||
// hiding never arms Qt's quit check but an ACCEPTED close does.
|
||||
//
|
||||
// `onAboutToQuit` is still not a duplicate of it: `Qt.quit()` never raises
|
||||
// `closing` (§15 trap 26), so the quit arm of `closeOrHide`, the tray's
|
||||
// `quit_requested` handler and any teardown that skips QML reach the
|
||||
// geometry flush only through here. Where both do run the second write is
|
||||
// free (Rust's >0.5px dirty check drops it), and either way a resize in the
|
||||
// last 400ms still lands, including the taskbar close of a minimized window
|
||||
// (that is what the exit variant and the floating cache are for).
|
||||
onClosing: function (close) { window.closeOrHide(close) }
|
||||
Connections {
|
||||
target: Qt.application
|
||||
function onAboutToQuit() { window.persistWindowGeometryOnExit() }
|
||||
}
|
||||
|
||||
// --- Close-to-tray, and the ONE visibility owner (contract B6) ---------
|
||||
//
|
||||
// THE INVARIANT (§5.8.1, §13-D18, AC-24, stage-0 S0-4): the main window is
|
||||
// hidden and shown ONLY by these two functions, and they are the ONLY
|
||||
// callers of QbzTray.setWindowShown. The reference has a real desync
|
||||
// exactly here — the miniplayer's enter()/exit() call m.hide()/m.show()
|
||||
// directly (crates/qbz/src/miniplayer.rs:186, :207) instead of
|
||||
// tray::hide_window/show_window, so WINDOW_SHOWN goes stale and, while the
|
||||
// mini is up, the first tray left-click hides an already-hidden window and
|
||||
// the second brings the MAIN window back beside the live mini (#559/#618).
|
||||
// A bare window.hide()/window.show() anywhere in this file re-opens that
|
||||
// hole; the miniplayer handler below calls these verbs for that reason.
|
||||
//
|
||||
// ⚠ ONE WRITER THE S0-4 GREP CANNOT SEE, named here so a green grep is not
|
||||
// read as more than it proves: the drawn MINIMIZE buttons call
|
||||
// `hostWindow.showMinimized()` (shell/HeaderBar.qml, shell/KioskShell.qml)
|
||||
// and deliberately do NOT report. That is correct — a minimized window is
|
||||
// still SHOWN, not hidden-to-tray, and owner ruling K5 keeps that button an
|
||||
// unconditional WM minimize. The consequence is real and inherited from the
|
||||
// reference, which tracks the same flag the same way: with the window
|
||||
// ICONIFIED, `WINDOW_SHOWN` is still true, so the next tray left-click
|
||||
// takes the hide arm rather than restoring. `showFromTray()` below at least
|
||||
// un-iconifies, so the SECOND click genuinely brings it back.
|
||||
function hideToTray() {
|
||||
// Both reference close arms flush the session BEFORE hiding, "even when
|
||||
// only hiding — the process may be killed from the tray / shell without
|
||||
// a real quit afterwards" (crates/qbz/src/main.rs:23255-23256). The Qt
|
||||
// port has no session_persist module, so the geometry flush is the
|
||||
// whole of what is owed. On a miniplayer enter it is harmless: it
|
||||
// writes the size the exit is about to restore.
|
||||
window.persistWindowGeometryOnExit()
|
||||
window.hide()
|
||||
QbzTray.setWindowShown(false)
|
||||
}
|
||||
function showFromTray() {
|
||||
window.show()
|
||||
// Un-iconify. Qt's hide() preserves windowStates, unlike the
|
||||
// reference's surface destruction (crates/qbz/src/tray/mod.rs:253-265),
|
||||
// so a window that was MINIMIZED before it was hidden comes back
|
||||
// minimized — visible in the taskbar, invisible on screen, which reads
|
||||
// as "the tray did nothing". Restoring to the maximized latch rather
|
||||
// than to Windowed keeps a maximized session maximized.
|
||||
if (window.visibility === Window.Minimized)
|
||||
window.visibility = window.maximizedLatch ? Window.Maximized
|
||||
: Window.Windowed
|
||||
// raise() + requestActivate() is §13-D12, carried by the VERB so no
|
||||
// call site has to remember it: the reference's tray::show_window
|
||||
// focuses (crates/qbz/src/tray/mod.rs:226-229) while its miniplayer
|
||||
// exit() does not, and the port focuses on both paths.
|
||||
window.raise()
|
||||
window.requestActivate()
|
||||
QbzTray.setWindowShown(true)
|
||||
}
|
||||
|
||||
// THE ONE close choreography (A-26, §5.7). Five exits reach it: this
|
||||
// window's onClosing above, the drawn X and the app-menu Close row in
|
||||
// shell/HeaderBar.qml, the kiosk shell's own drawn X in
|
||||
// shell/KioskShell.qml, and the miniplayer's closeApp through the
|
||||
// QbzMini.closeAppRequested handler below.
|
||||
//
|
||||
// The predicate is BOTH conditions, ported verbatim from
|
||||
// crates/qbz/src/main.rs:23253 — `tray_settings::get().close_to_tray &&
|
||||
// tray::handle().is_some()`. TRAY OFF => CLOSE QUITS: `handle()` is None
|
||||
// when the tray is disabled, suppressed under gamescope or failed to
|
||||
// initialise, and gating on the setting alone would strand the process with
|
||||
// no window and no way back on a desktop with no SNI host (§15 trap 22).
|
||||
//
|
||||
// `closeEvent` is the QQuickCloseEvent on the one path that carries one and
|
||||
// null on the other four.
|
||||
function closeOrHide(closeEvent) {
|
||||
if (QbzTray.trayLive && QbzTray.closeToTray) {
|
||||
if (closeEvent)
|
||||
closeEvent.accepted = false
|
||||
window.hideToTray()
|
||||
return
|
||||
}
|
||||
window.persistWindowGeometryOnExit()
|
||||
Qt.quit()
|
||||
}
|
||||
|
||||
// The tray's four signals (src/tray_bridge.rs:87-100). Rust owns no window
|
||||
// (§2.3), so every tray click hops ksni thread -> tray_qt verb ->
|
||||
// tray_bridge::ui() -> qsignal -> here, and this block is where it finally
|
||||
// reaches one.
|
||||
Connections {
|
||||
target: QbzTray
|
||||
function onWindowShowRequested() { window.showFromTray() }
|
||||
function onWindowHideRequested() { window.hideToTray() }
|
||||
// The tray's left-click degenerates to present() while the mini is open
|
||||
// (§5.8.2, §13-D21): raise the MINI, and do NOT bring the main window up
|
||||
// beside it. No setWindowShown — the mini is not the main window, and
|
||||
// the flag still honestly says the main one is hidden.
|
||||
function onMiniPresentRequested() {
|
||||
var mini = window.ensureMiniWindow()
|
||||
if (mini !== null) {
|
||||
mini.show()
|
||||
mini.raise()
|
||||
mini.requestActivate()
|
||||
}
|
||||
}
|
||||
function onQuitRequested() {
|
||||
window.persistWindowGeometryOnExit()
|
||||
Qt.quit()
|
||||
}
|
||||
}
|
||||
|
||||
// --- The miniplayer window (2026-08-03 miniplayer/tray contract, B2) ---
|
||||
//
|
||||
@@ -492,12 +603,18 @@ ApplicationWindow {
|
||||
// §4.6's step ordering and the only place either window is shown or hidden
|
||||
// for the miniplayer.
|
||||
//
|
||||
// Step 7 of enter() ("m.hide() LAST, avoid a blank flash") and its exit
|
||||
// twin are window.hideToTray() / window.showFromTray() (A-25), which land
|
||||
// with block B6 — so on this commit the mini opens with the main window
|
||||
// still visible behind it. That is §14.2's stated ordering constraint, not
|
||||
// a defect: a bare window.hide() here would be a second visibility owner
|
||||
// and §5.8.1's invariant (stage-0 S0-4) forbids it.
|
||||
// Step 7 of enter() is "hide the main window only after the mini is visible
|
||||
// (avoid a blank flash)" (crates/qbz/src/miniplayer.rs:185-187), so
|
||||
// hideToTray() runs LAST here and showFromTray() runs after the mini is
|
||||
// down. They are the A-25 verbs, never a bare hide()/show(): the mini IS
|
||||
// where the reference's WINDOW_SHOWN desync comes from, and reporting
|
||||
// shown=false/true from these two paths is what makes the tray toggle
|
||||
// honest (§13-D18, stage-0 S0-4).
|
||||
//
|
||||
// Re-entering while already open forces an openChanged (mini_bridge.rs's
|
||||
// enter_now), so this runs again and raises the mini; hideToTray() on an
|
||||
// already-hidden window is a no-op plus one geometry write Rust's dirty
|
||||
// check drops.
|
||||
Connections {
|
||||
target: QbzMini
|
||||
function onOpenChanged() {
|
||||
@@ -508,10 +625,21 @@ ApplicationWindow {
|
||||
mini.raise()
|
||||
mini.requestActivate()
|
||||
}
|
||||
window.hideToTray()
|
||||
} else if (window.miniWindow !== null) {
|
||||
window.miniWindow.hide()
|
||||
window.showFromTray()
|
||||
}
|
||||
}
|
||||
// The mini's own close verb (A-12). QbzMini.closeApp() exits the mini
|
||||
// FIRST — which runs the handler above and brings the main window back
|
||||
// — and only then emits this, so the app's ONE close choreography
|
||||
// decides between hide-to-tray and quit. The brief show-then-hide is
|
||||
// 1:1 with the reference: miniplayer.rs:235-251 hides the mini, shows
|
||||
// the main window and restores its geometry before calling
|
||||
// invoke_close_app, because "the window becomes user-visible when
|
||||
// close-to-tray keeps the app alive" (:244-246).
|
||||
function onCloseAppRequested() { window.closeOrHide(null) }
|
||||
}
|
||||
|
||||
// The immersive toggle Shortcut is GONE (2026-08-03 hotkeys-port §1.3):
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// - The Custom-theme editor (seed-from-current, the dark-polarity toggle and
|
||||
// the base-token swatch grid): the port applies custom_theme.json read-only
|
||||
// and has no ColorPicker primitive.
|
||||
// - "Hide Dock icon when closed to menu bar" (macOS-only in the Slint too).
|
||||
// - The commented-out Slint blocks (immersive background / panels / FPS,
|
||||
// window-title template) stay out, 1:1 the owner's cut.
|
||||
|
||||
@@ -412,8 +411,13 @@ Column {
|
||||
SettingsDivider { }
|
||||
SettingsSpacer { }
|
||||
|
||||
// ========================== SYSTEM TRAY ==============================
|
||||
GroupHeader { text: QbzSession.tr("SYSTEM TRAY", QbzSession.trRev) }
|
||||
// ==================== SYSTEM TRAY (macOS: MENU BAR) =================
|
||||
// The header swaps on the platform, 1:1 with AppearanceSettings.slint:884.
|
||||
GroupHeader {
|
||||
text: root.doc.isMacos === true
|
||||
? QbzSession.tr("MENU BAR", QbzSession.trRev)
|
||||
: QbzSession.tr("SYSTEM TRAY", QbzSession.trRev)
|
||||
}
|
||||
SettingRow {
|
||||
label: QbzSession.tr("Enable tray icon", QbzSession.trRev)
|
||||
description: QbzSession.tr("Show icon in system tray (requires restart)", QbzSession.trRev)
|
||||
@@ -422,9 +426,21 @@ Column {
|
||||
onToggled: function (v) { QbzBridge.settingsBool("tray-enable", v) }
|
||||
}
|
||||
}
|
||||
// A separate "Minimize to tray" toggle is deliberately absent, 1:1 with
|
||||
// AppearanceSettings.slint:897-903: redirecting the window-manager minimize
|
||||
// button to the tray means owning that button, and Wayland forbids a client
|
||||
// from intercepting the compositor's minimize. The reference HIDES the
|
||||
// option rather than showing it disabled, and this row reads "Close to
|
||||
// tray". The setting still round-trips through the store (the
|
||||
// "tray-minimize-to-tray" write arm, src/settings_qt.rs) so a Qt session
|
||||
// cannot drop a value the Slint build wrote.
|
||||
SettingRow {
|
||||
label: QbzSession.tr("Close to tray", QbzSession.trRev)
|
||||
description: QbzSession.tr("Keep playing in the tray instead of quitting when you close the window", QbzSession.trRev)
|
||||
// macOS says "menu bar"; the LABEL stays "Close to tray" on both
|
||||
// platforms (AppearanceSettings.slint:905-908).
|
||||
description: root.doc.isMacos === true
|
||||
? QbzSession.tr("Keep playing in the menu bar instead of quitting when you close the window", QbzSession.trRev)
|
||||
: QbzSession.tr("Keep playing in the tray instead of quitting when you close the window", QbzSession.trRev)
|
||||
rowEnabled: root.doc.trayEnable === true
|
||||
QbzToggle {
|
||||
enabled: root.doc.trayEnable === true
|
||||
@@ -432,6 +448,24 @@ Column {
|
||||
onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) }
|
||||
}
|
||||
}
|
||||
// macOS only: switch the activation policy to .accessory while closed to
|
||||
// the menu bar (no Dock icon). Off = Spotify-style, keep the Dock icon.
|
||||
// `visible` is inherited Item.visible and the parent is a Column, which
|
||||
// skips invisible children — so on Linux this leaves no gap at all. The
|
||||
// enable condition is DOUBLED on the row and on the toggle because
|
||||
// SettingRow's `rowEnabled` only dims its own label column
|
||||
// (controls/SettingRow.qml:26); it does not reach the control.
|
||||
SettingRow {
|
||||
visible: root.doc.isMacos === true
|
||||
label: QbzSession.tr("Hide Dock icon when closed to menu bar", QbzSession.trRev)
|
||||
description: QbzSession.tr("Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)", QbzSession.trRev)
|
||||
rowEnabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
|
||||
QbzToggle {
|
||||
enabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
|
||||
checked: root.doc.trayMacHideDock === true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("tray-mac-hide-dock", v) }
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: QbzSession.tr("Tray icon variant", QbzSession.trRev)
|
||||
description: QbzSession.tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo", QbzSession.trRev)
|
||||
|
||||
@@ -27,9 +27,12 @@
|
||||
// Slint, each needing a surface the port does not have yet: Open Music Link,
|
||||
// Report an Issue, What's New, About QBZ.
|
||||
//
|
||||
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
|
||||
// min/max/close WindowControls) are skipped — the POC keeps NATIVE window
|
||||
// decorations.
|
||||
// The custom window chrome IS built (the note claiming it was skipped was a
|
||||
// fossil): the header itself is the drag surface (`startSystemMove()` below),
|
||||
// and the drawn min/max/close WindowControls Row renders whenever
|
||||
// `use_system_title_bar` is OFF. Native decorations are the OTHER arm of that
|
||||
// same pref, not the port's ceiling. Close routes through Main.qml's
|
||||
// closeOrHide, so it honours close-to-tray like every other exit.
|
||||
//
|
||||
// FILE LENGTH (>500): this is ONE bar with one placement state machine —
|
||||
// `navInSidebar` x `navHeaderCompact` x the three sidebar widths decide which
|
||||
@@ -992,17 +995,22 @@ Rectangle {
|
||||
id: wcCloseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
// POC-NOTE: the Slint app hides-to-tray on close; the POC
|
||||
// has no tray — close quits.
|
||||
onClicked: Qt.quit()
|
||||
// Close-to-tray lives in ONE place — Main.qml's closeOrHide
|
||||
// (2026-08-03 miniplayer/tray contract, A-26 / §5.7) — which
|
||||
// hides to the tray while QbzTray.trayLive && QbzTray.closeToTray
|
||||
// and quits otherwise. A drawn button delivers no close EVENT
|
||||
// (Qt.quit() never raises `closing` either), hence the null.
|
||||
onClicked: if (root.hostWindow) root.hostWindow.closeOrHide(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- App menu (user block + Settings + Log Out + Close) ---------------
|
||||
// POC-NOTE: the Slint menu also carries Open Music Link / Keyboard
|
||||
// Shortcuts / Documentation / Report an Issue / What's New / About QBZ
|
||||
// — omitted here (no views/dialogs to open yet).
|
||||
// --- App menu (user block + Settings + Keyboard Shortcuts +
|
||||
// Documentation + Log Out + Close) -------------------------------------
|
||||
// Still missing against the Slint menu, each needing a surface this port
|
||||
// does not have yet: Open Music Link, Report an Issue, What's New,
|
||||
// About QBZ. (The older note here also listed Keyboard Shortcuts and
|
||||
// Documentation, which have since landed.)
|
||||
Popup {
|
||||
id: appMenu
|
||||
x: root.width - 234 - theme.spacingMd
|
||||
@@ -1142,9 +1150,15 @@ Rectangle {
|
||||
AppMenuItem {
|
||||
name: "x"
|
||||
label: QbzSession.tr("Close", QbzSession.trRev)
|
||||
// POC-NOTE: the Slint app hides to tray / follows the
|
||||
// platform close behavior; the POC just quits.
|
||||
onClicked: Qt.quit()
|
||||
// The same ONE close choreography as the drawn X above
|
||||
// (A-26, §5.7). appMenu.close() FIRST, like the Log Out row:
|
||||
// when close-to-tray only HIDES the window, a popup left open
|
||||
// comes back up with it on the next show.
|
||||
onClicked: {
|
||||
appMenu.close()
|
||||
if (root.hostWindow)
|
||||
root.hostWindow.closeOrHide(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,15 @@ Rectangle {
|
||||
id: wcCloseArea
|
||||
anchors.fill: wcClose
|
||||
hoverEnabled: true
|
||||
onClicked: Qt.quit()
|
||||
// The FIFTH exit. §5.7 of the 2026-08-03 miniplayer/
|
||||
// tray contract enumerates four; this shell grew its
|
||||
// own drawn X after that contract was written, and an
|
||||
// exit still calling Qt.quit() directly is precisely
|
||||
// the half-port the enumeration exists to prevent. Same
|
||||
// choreography as the desktop chrome: Main.qml's
|
||||
// closeOrHide hides to the tray while QbzTray.trayLive
|
||||
// && QbzTray.closeToTray and quits otherwise.
|
||||
onClicked: if (root.hostWindow) root.hostWindow.closeOrHide(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,32 @@ pub mod qbz_mini {
|
||||
#[qinvokable]
|
||||
fn exit(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// Close the whole APP from the mini (contract A-12, §4.6). Exits the
|
||||
/// mini first, then routes to the ONE close choreography by emitting
|
||||
/// `close_app_requested` below.
|
||||
#[qinvokable]
|
||||
fn close_app(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// `close_app`'s second half: `Main.qml` answers with
|
||||
/// `window.closeOrHide(null)`, so the mini's red X honours
|
||||
/// close-to-tray exactly like every other exit.
|
||||
///
|
||||
/// **This signal is an ADDITION to the contract's §2.2 must-add list**,
|
||||
/// and it is logged as one. A-12 says `close_app` "routes to the ONE
|
||||
/// close choreography (§5.7)", but that choreography is a QML function
|
||||
/// on `Main.qml`'s root: `QbzMini` had no signals at all, the mini's
|
||||
/// QML tree holds no reference to the main window (`MiniWindow.qml`
|
||||
/// passes `hostWindow: root`, i.e. the MINI), and none of `QbzTray`'s
|
||||
/// four signals is a "close requested" — routing through
|
||||
/// `quit_requested` would bypass close-to-tray outright. The only
|
||||
/// alternative that adds no symbol is deciding hide-vs-quit in Rust,
|
||||
/// which would put a SECOND copy of the close predicate in the tree
|
||||
/// (the first is QML's `trayLive && closeToTray`) — the
|
||||
/// two-functions-that-should-mirror-each-other smell LESSONS L5 names.
|
||||
/// One signal keeps exactly one copy of the predicate.
|
||||
#[qsignal]
|
||||
fn close_app_requested(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// The MINI window's own key dispatcher (contract A-15, §4.9), 1:1 with
|
||||
/// the reference's `dispatch_mini`
|
||||
/// (`crates/qbz/src/keybindings.rs:623-648`). **No
|
||||
@@ -323,6 +349,19 @@ impl qbz_mini::QbzMini {
|
||||
exit_now(self);
|
||||
}
|
||||
|
||||
/// Contract A-12 — close the APP from the mini's red X.
|
||||
///
|
||||
/// The order is the reference's (`crates/qbz/src/miniplayer.rs:235-251`):
|
||||
/// leave the mini FIRST — which, through `Main.qml`'s `onOpenChanged`,
|
||||
/// hides the mini and brings the main window back — and only then ask for
|
||||
/// the close, because "the window becomes user-visible when close-to-tray
|
||||
/// keeps the app alive" (`:244-246`). `open_changed()` emits synchronously,
|
||||
/// so the main window is already up by the time the signal below is queued.
|
||||
pub fn close_app(mut self: Pin<&mut Self>) {
|
||||
exit_now(self.as_mut());
|
||||
self.close_app_requested();
|
||||
}
|
||||
|
||||
/// Contract A-15 — the mini's key dispatcher, the table at §4.9.
|
||||
///
|
||||
/// Resolution goes through `hotkeys_qt::action_for_mini_key` (A-16), which
|
||||
|
||||
@@ -1275,6 +1275,33 @@ pub struct SettingsDoc {
|
||||
pub tray_enable: bool,
|
||||
#[serde(rename = "trayCloseToTray")]
|
||||
pub tray_close_to_tray: bool,
|
||||
/// STORAGE ONLY — there is no Settings row and no reader (owner ruling K5).
|
||||
/// The Slint sheet documents why the reference hides the toggle rather than
|
||||
/// showing it disabled (`crates/qbz-ui/ui/settings/AppearanceSettings.slint:897-903`):
|
||||
/// redirecting the window-manager minimize button to the tray means owning
|
||||
/// that button, and Wayland forbids a client from intercepting the
|
||||
/// compositor's minimize.
|
||||
///
|
||||
/// **Nothing in QML writes this key** (`grep -rn 'tray-minimize-to-tray' qml/`
|
||||
/// → 0), and that is the point of K5, not an oversight: the field carries
|
||||
/// the value into the settings DOCUMENT so a reader can see it, and the
|
||||
/// write arm exists so the key becomes reachable the day a row is added
|
||||
/// without a second edit. It is NOT protecting the value from loss — the
|
||||
/// per-user store is written by `qbz-app`, not by this document, so a Qt
|
||||
/// session could not drop what the Slint build wrote in any case.
|
||||
#[serde(rename = "trayMinimizeToTray")]
|
||||
pub tray_minimize_to_tray: bool,
|
||||
/// macOS only: while closed to the menu bar, switch the activation policy to
|
||||
/// `.accessory` (no Dock icon). Off keeps the Dock icon, Spotify-style. The
|
||||
/// row is `visible: isMacos`, so on Linux it renders nothing at all.
|
||||
#[serde(rename = "trayMacHideDock")]
|
||||
pub tray_mac_hide_dock: bool,
|
||||
/// Platform flag for the three macOS-shaped bits of the SYSTEM TRAY group:
|
||||
/// the "MENU BAR" header swap, the Close-to-tray description arm and the
|
||||
/// Hide-Dock row's `visible`. Compile-time, exactly like the Slint's
|
||||
/// `AppearanceState.is-macos`.
|
||||
#[serde(rename = "isMacos")]
|
||||
pub is_macos: bool,
|
||||
#[serde(rename = "trayIconThemes")]
|
||||
pub tray_icon_themes: Vec<String>,
|
||||
#[serde(rename = "trayIconThemeIndex")]
|
||||
@@ -1665,6 +1692,19 @@ pub async fn publish_snapshot() {
|
||||
.get_settings()
|
||||
.map(|t| t.close_to_tray)
|
||||
.unwrap_or(true),
|
||||
// Both shared defaults are `false` (`qbz-app/src/settings/tray.rs:59-63`),
|
||||
// so unlike the two reads above these fall back to `false` — the
|
||||
// fallback must match the struct's own default, which is the whole
|
||||
// point of the S1 fix directly above.
|
||||
tray_minimize_to_tray: tray()
|
||||
.get_settings()
|
||||
.map(|t| t.minimize_to_tray)
|
||||
.unwrap_or(false),
|
||||
tray_mac_hide_dock: tray()
|
||||
.get_settings()
|
||||
.map(|t| t.mac_hide_dock)
|
||||
.unwrap_or(false),
|
||||
is_macos: cfg!(target_os = "macos"),
|
||||
tray_icon_themes: TRAY_ICON_LABELS
|
||||
.iter()
|
||||
.map(|l| qbz_i18n::t(l))
|
||||
@@ -1930,8 +1970,34 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
|
||||
.set_enable_tray(value)
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|_| Apply::None),
|
||||
"tray-close-to-tray" => tray()
|
||||
.set_close_to_tray(value)
|
||||
"tray-close-to-tray" => {
|
||||
let persisted = tray().set_close_to_tray(value).map_err(|e| e.to_string());
|
||||
// A-21b(c), and the FIFTH live-update row of §5.6. Without this the
|
||||
// `QbzTray.closeToTray` property never leaves the value it was
|
||||
// seeded with and the toggle is COSMETIC until the next launch —
|
||||
// the "renders and persists and drives nothing" defect this whole
|
||||
// diff exists to end. The line above is the storage; this is the
|
||||
// running app, and after it the very next close behaves the new way.
|
||||
//
|
||||
// Gated on the persist: a failed write (no active session) must not
|
||||
// tell the running app a value the store does not hold.
|
||||
if persisted.is_ok() {
|
||||
crate::tray_bridge::ui(move |mut t| {
|
||||
t.as_mut().set_close_to_tray(value);
|
||||
});
|
||||
}
|
||||
persisted.map(|_| Apply::None)
|
||||
}
|
||||
// STORAGE ONLY (owner ruling K5): no Settings row, no reader. The value
|
||||
// round-trips through the per-user store the Slint build shares instead
|
||||
// of a Qt session dropping it — see the doc field for why the reference
|
||||
// hides the toggle.
|
||||
"tray-minimize-to-tray" => tray()
|
||||
.set_minimize_to_tray(value)
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|_| Apply::None),
|
||||
"tray-mac-hide-dock" => tray()
|
||||
.set_mac_hide_dock(value)
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|_| Apply::None),
|
||||
// --- Integrations (phase 19) --------------------------------------
|
||||
|
||||
Reference in new issue
Block a user