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):
|
||||
|
||||
Reference in new issue
Block a user