Three requirements -- no title bar, working clicks, and window tiling -- and no combination of public Qt flags delivers all three. Each attempt was measured on the box rather than reasoned about, and each one taught something the plan had wrong. FramelessWindowHint, the previous arm, removes WS_THICKFRAME. THAT is what the shell keys tiling on -- not WS_CAPTION, which this window never has in any arrangement -- so Win+Arrow and Aero Snap were dead, along with the DWM shadow and the Win11 rounded corners. "Hide title bar" was not making the app refuse the shortcuts; it had stopped being a window the shell could tile. Adding CustomizeWindowHint restored tiling and stopped Qt painting its own minimise/maximise/close cluster over the QBZ header. It also made Qt answer WM_NCHITTEST with HTNOWHERE for EVERY pixel -- centre, header, player bar and sidebar all returned 0 -- and Windows delivers no mouse input to HTNOWHERE, so the whole app became unclickable while still running: the tray menu still drove playback. Dropping the hint fixed clicks and brought the drawn buttons back, duplicated on top of the ones QBZ already draws itself. So the hint stays and cxx/win_shell.cpp answers the hit test instead. The filter defers to DefWindowProcW, which is what an ordinary sizable caption-less window would return: the eight edge codes near the frame, HTCLIENT everywhere else. It invents no geometry. Its guard is narrow on purpose -- sizable AND caption-less, which is exactly the custom-chrome main window -- so menus, tooltips, popups and any window showing the system title bar keep Qt's own answer. Measured after: HTCLIENT at the centre, in the title band, at the edges and in the corner; Win+Left resized the window to 1720 px, exactly half of a 3440 px display. Dragging is unaffected and stays with QML's startSystemMove(): with no caption there is no HTCAPTION band to inherit, which is the arrangement HeaderBar was already written for. NOT restored, and not restorable from flags: the Win11 Snap Layouts hover flyout. Windows asks for it by hit-testing the maximise button as HTMAXBUTTON, and QBZ's is a QML item inside the client area. Reporting HTMAXBUTTON over that one rect from this filter would do it -- named as future work rather than claimed. cc
1190 lines
65 KiB
QML
1190 lines
65 KiB
QML
// Root window: dark background + a Loader keyed on the bridge's `screen`
|
|
// property ("splash" | "login" | "shell"). Kicks off the Rust boot
|
|
// sequence once the QML tree is complete (the bridge registers its Qt
|
|
// thread handle in that first invokable, so every async UI hop lands).
|
|
//
|
|
// Font: main.rs applies an explicitly selected bundled family before this
|
|
// document is built. With "System" it applies nothing, so both plain Text and
|
|
// Qt Quick Controls inherit the operating-system font chosen by Qt.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "miniplayer"
|
|
import "theme"
|
|
|
|
ApplicationWindow {
|
|
id: window
|
|
// Geometry is RESTORED, not hardcoded (crates/qbz/src/main.rs:8211-8282 is
|
|
// the Slint restore; :1399-1435 is its save). The bridge seeds these at
|
|
// CONSTRUCTION from the shared ui_prefs.json, so the very first frame is
|
|
// already the user's size — a post-boot push would arrive after layout.
|
|
// `windowWidth`/`windowHeight` already carry the never-saved fallback
|
|
// (1180x760: app.slint's preferred size), so there is no local default to
|
|
// disagree with Slint about.
|
|
//
|
|
// This was the title-truncation bug: at the old hardcoded 1280 the
|
|
// now-playing bar sat one responsive tier below the Slint build on the same
|
|
// machine (1280 < 1366 -> side fraction 0.39 instead of 0.30), so the
|
|
// Classic song card received ~446px instead of the larger mid-tier runway
|
|
// and elided titles early. The tier thresholds were right; the window was
|
|
// the wrong size. The card now also measures its live neighbouring controls,
|
|
// so restored geometry and in-bar allocation solve different parts of the
|
|
// same problem rather than relying on a fixed card cap.
|
|
//
|
|
// These stay plain bindings so the size is correct before the window maps.
|
|
// The WM's own resizes write `width`/`height` from C++ — that does not
|
|
// disturb the binding, and nothing ever re-sets the bridge properties, so
|
|
// the value never snaps back under the user.
|
|
//
|
|
// Slint's best-effort monitor clamp (main.rs:8242-8249) is folded INTO the
|
|
// binding rather than applied afterwards: a size saved on the big display
|
|
// must not strand the window on a smaller one, and the clamped number has
|
|
// to be the MAPPED number. Done imperatively in Component.onCompleted (as
|
|
// it was) it ran after componentComplete had already shown the window, so
|
|
// the window flashed at the oversized geometry and then snapped — and the
|
|
// JS assignment broke the binding and fired the save debounce, which is how
|
|
// a 2560px-wide session got permanently rewritten to 1920 after one launch
|
|
// on the laptop panel (for the Slint build too — same file).
|
|
// SHRINK ONLY, and only while the screen itself clears the app floor:
|
|
// Slint skips the clamp on exactly that condition instead of fighting the
|
|
// WM over an impossible minimum. A screen we cannot resolve degrades to
|
|
// "no clamp", like Slint's monitor query returning None.
|
|
width: window.bootScreenWidth >= QbzShell.windowMinWidth
|
|
? Math.min(QbzShell.windowWidth, window.bootScreenWidth)
|
|
: QbzShell.windowWidth
|
|
height: window.bootScreenHeight >= QbzShell.windowMinHeight
|
|
? Math.min(QbzShell.windowHeight, window.bootScreenHeight)
|
|
: QbzShell.windowHeight
|
|
// Screen metrics for that clamp, read from `window.screen` (the window's
|
|
// OWN screen; the attached `Screen` wants an Item). Frozen in
|
|
// Component.onCompleted — see the freeze there for why they must stop
|
|
// tracking.
|
|
property real bootScreenWidth: window.screen ? window.screen.width : 0
|
|
property real bootScreenHeight: window.screen ? window.screen.height : 0
|
|
// app.slint:52-53 (`min-width: 940px / UiScale.factor`), carried through the
|
|
// bridge. The old 800x600 let the window go below Slint's floor, which is
|
|
// exactly where the responsive tiers stop being comparable.
|
|
minimumWidth: QbzShell.windowMinWidth
|
|
minimumHeight: QbzShell.windowMinHeight
|
|
visible: true
|
|
// Last session's maximized state, applied DECLARATIVELY so it is part of
|
|
// the first mapped frame: QQuickWindowQmlImpl defers the show until
|
|
// componentComplete and then honours whatever `visibility` holds, whereas
|
|
// the old Component.onCompleted assignment landed AFTER the map and the
|
|
// window visibly jumped from the floating size to maximized. Slint restores
|
|
// before `window.run()` and never flashes (main.rs:8273-8281).
|
|
// One-shot by construction: `windowMaximized` is seeded at bridge
|
|
// construction and never rewritten, so this never re-evaluates, and
|
|
// HeaderBar's imperative `hostWindow.visibility = ...`
|
|
// (shell/HeaderBar.qml:76) removes the binding outright the first time the
|
|
// user toggles — which is the wanted behaviour, not a leak.
|
|
// Windowed (not AutomaticVisibility) on the false branch: a fresh window
|
|
// must land in its natural state, matching "only ever re-apply a true".
|
|
// The kiosk appliance arm comes FIRST and is one-shot by construction:
|
|
// `kioskFullscreenBoot` is seeded at bridge construction from
|
|
// `QBZ_PROFILE=kiosk` AND `QBZ_KIOSK_FULLSCREEN`, and nothing ever writes
|
|
// it, so this can never re-evaluate and trap a desktop session
|
|
// (incident 2026-07-11: the kiosk shell has no titlebar control and
|
|
// neither Esc nor F11 leave fullscreen). Declarative for the same reason
|
|
// the maximized arm is: QQuickWindowQmlImpl defers the show until
|
|
// componentComplete, so this is part of the first mapped frame instead of
|
|
// a visible jump afterwards.
|
|
visibility: QbzShell.kioskFullscreenBoot
|
|
? Window.FullScreen
|
|
: (QbzShell.windowMaximized ? Window.Maximized : Window.Windowed)
|
|
// "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
|
|
// translucent window was a misread: the Slint MAIN window keeps an
|
|
// OPAQUE swapchain (only the miniplayer blends; crates/qbz/src/main.rs
|
|
// set_surface_prefers_transparent + the Cargo.toml patch comment), and
|
|
// the rounded corners in the Slint screenshots come from the
|
|
// COMPOSITOR, not the app — app.slint's root is opaque surface-main
|
|
// with square corners and a square 1px hairline frame. The system
|
|
// titlebar is the `use_system_title_bar` pref (ui_prefs.json; applied
|
|
// at startup, restart semantics like Slint).
|
|
// 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).
|
|
// WINDOWS takes the macOS shape, not the Linux one, and the difference is
|
|
// the whole feature (W27).
|
|
//
|
|
// `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the
|
|
// shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both
|
|
// stop, along with the DWM drop shadow and the Win11 rounded corners. So
|
|
// "hide title bar" was not making the app refuse the shortcuts -- it had
|
|
// stopped being a window the shell could tile.
|
|
//
|
|
// NOT restored, and it cannot be from flags alone: the Windows 11 Snap
|
|
// Layouts flyout that appears when hovering a maximise button. Windows asks
|
|
// for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise
|
|
// button is a QML item inside the client area, so it answers HTCLIENT.
|
|
// Wiring it would mean reporting HTMAXBUTTON over that one rect from the
|
|
// native filter -- deliberate future work, not something this arm does.
|
|
//
|
|
// Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0,
|
|
// DwmExtendFrameIntoClientArea, the eight resize edges and
|
|
// safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the
|
|
// macOS arm uses. `CustomizeWindowHint` with no button hints then
|
|
// suppresses Qt's own painted title cluster
|
|
// (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS
|
|
// WS_CAPTION, so the shell still treats it as tileable.
|
|
//
|
|
// It also provides no HTCAPTION drag band of its own (:3332-3338: with
|
|
// both isDefaultTitleBar and isCustomized false the whole title block is
|
|
// skipped), which is why HeaderBar's QML `startSystemMove()` stays: it
|
|
// issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips
|
|
// stay enabled too and do not fight the native edges -- Qt answers
|
|
// WM_NCHITTEST first, so a press on the border never reaches QML.
|
|
flags: QbzShell.systemTitleBar
|
|
? Qt.Window
|
|
: (QbzShell.isMacos
|
|
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
|
|
: (QbzShell.isWindows
|
|
// Both halves were MEASURED on the box, and neither is
|
|
// optional:
|
|
//
|
|
// WITHOUT CustomizeWindowHint, Qt populates the default title
|
|
// hints and its customized-title path DRAWS minimise/maximise/
|
|
// close over the QBZ header.
|
|
//
|
|
// WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every
|
|
// pixel -- centre, header, player bar and sidebar all returned
|
|
// 0 -- and Windows sends no mouse input to HTNOWHERE, so the
|
|
// whole app becomes unclickable while still running.
|
|
//
|
|
// No public flag combination gives no-buttons + clicks +
|
|
// the WS_THICKFRAME that tiling needs, so the hint stays and
|
|
// `win_shell::install_hittest_filter()` answers the hit test
|
|
// instead. What this arm must NOT do is drop the hint: that
|
|
// trades the buttons back in.
|
|
//
|
|
// Frameless is not the answer either: it removes
|
|
// WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap
|
|
// actually key on -- not WS_CAPTION, which this window does
|
|
// not have in either arrangement.
|
|
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint
|
|
| Qt.CustomizeWindowHint)
|
|
: (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"
|
|
|
|
// Phase 23: every domain singleton boots (registers its Qt-thread
|
|
// hop; QbzSession.boot additionally fires the app boot sequence).
|
|
// ── The renderer probe ────────────────────────────────────────────────
|
|
// The ONLY place that answers "what is actually drawing". QRhi decides it
|
|
// when the scene graph initialises, so `main::apply_renderer_preference()`
|
|
// — which runs before QGuiApplication — knows only what we ASKED for; a
|
|
// driver that refuses the request lands us somewhere else entirely.
|
|
//
|
|
// The enum is mapped to a NAME here, so Rust never depends on
|
|
// GraphicsInfo's numeric values (a Qt implementation detail). Rust ignores
|
|
// "unknown", the value reported before the graph resolves, so the report
|
|
// fires again for free once it does.
|
|
//
|
|
// This is the feature gate (shader scenes / dynamic background /
|
|
// reduce-motion), NOT the per-item drawing check: items that need to pick
|
|
// a draw path keep reading their own `GraphicsInfo.api`, which is per
|
|
// window and needs no round trip. See src/renderer_qt.rs.
|
|
Item {
|
|
id: rendererProbe
|
|
width: 0
|
|
height: 0
|
|
readonly property int api: GraphicsInfo.api
|
|
function report() {
|
|
var name = "unknown"
|
|
switch (rendererProbe.api) {
|
|
case GraphicsInfo.Software: name = "software"; break
|
|
case GraphicsInfo.OpenGL: name = "opengl"; break
|
|
case GraphicsInfo.Direct3D11: name = "d3d11"; break
|
|
case GraphicsInfo.Direct3D12: name = "d3d12"; break
|
|
case GraphicsInfo.Vulkan: name = "vulkan"; break
|
|
case GraphicsInfo.Metal: name = "metal"; break
|
|
case GraphicsInfo.Null: name = "null"; break
|
|
}
|
|
QbzShell.reportRendererApi(name)
|
|
}
|
|
onApiChanged: report()
|
|
Component.onCompleted: report()
|
|
}
|
|
|
|
// ── The frame-liveness watchdog ───────────────────────────────────────
|
|
// Disarms the startup sentinel that `apply_renderer_preference()` armed
|
|
// for a forced renderer. The proof it waits for is FRAMES, not a window: a
|
|
// backend can create a window and then never present, which from the
|
|
// outside is indistinguishable from a healthy start until the user is
|
|
// looking at a frozen pane — and that is exactly the state the sentinel
|
|
// must NOT accept as success.
|
|
//
|
|
// 10 s rather than the first swap: a renderer that dies two seconds in
|
|
// would otherwise have already been declared healthy, and the next launch
|
|
// would keep re-forcing it. The reference waits for real user input with a
|
|
// 30 s no-touch fallback (`disarm_renderer_sentinel_on_liveness`); frames
|
|
// are the same idea with a signal Qt gives us directly.
|
|
property int _framesSwapped: 0
|
|
onFrameSwapped: window._framesSwapped++
|
|
Timer {
|
|
interval: 10000
|
|
repeat: false
|
|
running: true
|
|
onTriggered: QbzShell.reportFrameLiveness(window._framesSwapped)
|
|
}
|
|
|
|
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()
|
|
// Qobuz Connect (2026-08-01 contract §2/§8). Booted right after
|
|
// QbzSession because QbzSession.boot fires the whole app boot
|
|
// sequence, whose shell entry runs the §11.5 service wiring — the
|
|
// badge/devices publishes it makes must find the Qt-thread hop
|
|
// already registered or they are dropped silently.
|
|
QbzQConnect.boot()
|
|
// Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): boot
|
|
// after QbzSession like every domain singleton. The B2 dispatcher
|
|
// (AppShell-root Keys.onPressed) is the only other QML side.
|
|
QbzHotkeys.boot()
|
|
QbzLink.boot()
|
|
// Search (2026-08-03 cortinilla-parity contract, commit C0): the
|
|
// domain extracted from QbzBridge. Its position among the other
|
|
// domain singletons is not load-bearing — every boot() here runs
|
|
// before the first key or click — but the LINE is: boot() is what
|
|
// registers the Qt-thread hop, and without it `search_bridge::ui()`
|
|
// is a silent no-op, so the dropdown would never repaint and
|
|
// nothing would be logged (TRACK-RULES, singleton boot order).
|
|
QbzSearch.boot()
|
|
QbzShell.boot()
|
|
QbzPlayer.boot()
|
|
QbzQueue.boot()
|
|
QbzHome.boot()
|
|
QbzViz.boot()
|
|
// Immersive mode (2026-08-02 immersive-port contract, block B1).
|
|
// Booted right after QbzViz: the overlay's open funnel drives the
|
|
// viz two-source enable (§4.2), and ambient_qt publishes glow_color/
|
|
// atmosphere_url to this singleton on every track change — a missing
|
|
// boot line would drop those publishes silently (see above).
|
|
QbzImmersive.boot()
|
|
// Shader scenes (2026-08-15 immersive-completion contract, block A1).
|
|
// Booted right after QbzImmersive: its open funnel resets the scene
|
|
// through this singleton's hop, and the viz drain publishes the audio
|
|
// pack here on every tick while immersive is open — a missing boot
|
|
// line makes both silent no-ops (TRACK-RULES, singleton boot order).
|
|
QbzShaderScene.boot()
|
|
// Immersive Suggestions (the same contract, block B4 §4.5) — booted
|
|
// WITH its bridge: every publish the suggestions loader makes rides
|
|
// this hop, so a missing line is the forever-"{}" silent no-op the
|
|
// comment below warns about (trap 2).
|
|
QbzSuggestions.boot()
|
|
QbzLocal.boot()
|
|
QbzLibrary.boot()
|
|
QbzAlbum.boot()
|
|
QbzArtist.boot()
|
|
QbzScene.boot()
|
|
QbzMusician.boot()
|
|
QbzLyrics.boot()
|
|
QbzCast.boot()
|
|
QbzBridge.boot()
|
|
// MyQBZ (two grids + detail + modals), the app-wide Add picker, the
|
|
// Artist-Collection builder, and the Blacklist manager.
|
|
//
|
|
// boot() is what registers each singleton's CxxQtThread hop, so a
|
|
// missing line here is a SILENT no-op forever: the view mounts, every
|
|
// `ui()` publish from Rust is dropped on the floor, the document stays
|
|
// at its "{}" default and NOTHING is logged on either side. QbzBlacklist
|
|
// is the loudest case — its `blacklistLoading` defaults to true, so the
|
|
// manager would spin forever.
|
|
QbzMyQbz.boot()
|
|
QbzMyQbzAdd.boot()
|
|
QbzDisco.boot()
|
|
QbzBlacklist.boot()
|
|
QbzPlaylistPicker.boot()
|
|
// The folder modals. Its boot() also publishes both closed documents,
|
|
// which is what puts the icon-preset and colour-swatch constants on
|
|
// the QML side BEFORE the first open rather than one turn after it.
|
|
QbzFolderEdit.boot()
|
|
// The SHARED playlist editor (rename · description · offline-only ·
|
|
// delete), opened from the manager's three delegates, the sidebar row
|
|
// menu and the playlist detail header's pencil. Its boot() publishes
|
|
// the closed document so the modal's parse sees the full shape.
|
|
QbzPlaylistEdit.boot()
|
|
// Playlist Manager. Its boot() also seeds the default document and
|
|
// kicks the cache-independent folder read, so the SIDEBAR's folder
|
|
// consumers have a list before the manager view has ever been opened.
|
|
QbzPlaylistManager.boot()
|
|
// Playlist Importer. Nothing to seed — the modal is closed until a
|
|
// sidebar row opens it — but WITHOUT this line every publish the
|
|
// importer makes (the whole progress panel, the log, the summary) is
|
|
// dropped on the floor with nothing logged on either side.
|
|
QbzPlaylistImport.boot()
|
|
// "Find available version" (2026-08-17 unavailable-tracks contract §6).
|
|
// Nothing to seed — the modal is closed until a playlist row's context
|
|
// menu opens it — but without this line every publish the controller
|
|
// makes is dropped on the floor: the modal would open empty and keep
|
|
// spinning, since its `loading` flag is only ever taken down by a
|
|
// publish.
|
|
QbzTrackReplace.boot()
|
|
// The disc-metadata modal. Same story: closed until the pane's search
|
|
// button opens it, and without this line the search would spin
|
|
// forever, because only a publish takes `searching` back down.
|
|
QbzDiscMeta.boot()
|
|
// Local album metadata editor. Its controller publishes from worker
|
|
// threads and the app-wide modal survives the album republish on save.
|
|
QbzTagEditor.boot()
|
|
// HiFi Wizard. Nothing to seed — the modal is closed until the
|
|
// Settings > Audio row opens it — but without this line every publish
|
|
// the wizard makes is dropped on the floor: the health verdict, the
|
|
// enumerated DACs, the generated config and the live read-back would
|
|
// all stay at their defaults, and nothing would be logged on either
|
|
// side. The wizard is the loudest case after QbzBlacklist, because its
|
|
// first step ends on "Checking your audio stack…" forever.
|
|
// Linux only: the wizard's row is hidden elsewhere (AudioSettings.qml),
|
|
// so booting it would spin up an audio-stack probe nothing can reach.
|
|
if (QbzShell.isLinux)
|
|
QbzDacWizard.boot()
|
|
// Offline Cache Manager. Nothing to seed — the view is unmounted until
|
|
// the Settings row opens it — but without this line the manager's very
|
|
// first publish is dropped and the view sits on its spinner forever:
|
|
// `managerLoading` DEFAULTS to true precisely so the empty state does
|
|
// not flash, which turns a missing boot() into a permanent spinner
|
|
// with nothing logged on either side.
|
|
QbzOffline.boot()
|
|
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted
|
|
// like every other domain singleton, after QbzSession: `boot()` is what
|
|
// registers the Qt-thread hop, and without it `mini_bridge::ui()` is a
|
|
// SILENT no-op — the mini queue document would never reach QML and
|
|
// nothing would be logged on either side.
|
|
QbzMini.boot()
|
|
// 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 Connections that answer those four
|
|
// signals are below, beside the window verbs they call.
|
|
QbzTray.boot()
|
|
// About / What's New. Its four invokables self-register the hop, so
|
|
// today's menu-driven paths work without this line — but the FIRST
|
|
// publish that is not preceded by an invokable (an avatar prefetch at
|
|
// startup, an auto-show, a "new version" badge) would be a silent
|
|
// no-op. Booting it here keeps it in the same contract as the other 34.
|
|
QbzAbout.boot()
|
|
// Purchases. Unlike QbzAbout above, this one has NO self-registering
|
|
// invokable to fall back on: `purchases_bridge::ui()` is a silent no-op
|
|
// until the Qt-thread hop exists, so without this line every publish is
|
|
// dropped and both purchase screens stay permanently empty with nothing
|
|
// logged. Position is free (only QbzSession and QbzQConnect are ordered).
|
|
QbzPurchases.boot()
|
|
|
|
// Seed the maximized latch ONCE, imperatively — see its declaration for
|
|
// why it must not be a binding on the bridge property.
|
|
window.maximizedLatch = QbzShell.windowMaximized
|
|
|
|
// Freeze the clamp latch. The width/height bindings above read these,
|
|
// and `window.screen` CHANGES when the user drags the window to another
|
|
// monitor — a live dependency would re-run those bindings and snap the
|
|
// window back to the restored size under the user's hands (the bindings
|
|
// survive WM resizes, which come from C++ and do not clear them).
|
|
// Assigning a property from imperative JS removes its binding, so
|
|
// after these two lines the latch is a dead number and the geometry
|
|
// bindings can never re-evaluate. Via locals, not self-assignment, so
|
|
// it cannot be mistaken for a no-op.
|
|
var latchedW = window.bootScreenWidth
|
|
var latchedH = window.bootScreenHeight
|
|
window.bootScreenWidth = latchedW
|
|
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 -------------------------------------------
|
|
// 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
|
|
// no equivalent of "the WM settled", so the debounce stands in for it: a
|
|
// drag fires widthChanged on every frame and only the last one is worth a
|
|
// file write. The floating-only rule, the app minimum and the >0.5px dirty
|
|
// check all live Rust-side in settings_qt::save_window_geometry, so every
|
|
// call site here can stay a single unconditional line.
|
|
//
|
|
// What CANNOT be left to Rust is the maximized flag itself. Qt's
|
|
// `visibility` is ONE enum in which Minimized REPLACES Maximized, while
|
|
// winit — what Slint reads through `is_maximized()` (main.rs:1404-1408) —
|
|
// keeps the two orthogonal and still reports maximized while iconified.
|
|
// Deriving the flag from the live visibility therefore persisted
|
|
// `window_maximized: false` on every minimize, and since the restore only
|
|
// ever re-applies a true (main.rs:8273-8281) BOTH frontends then opened
|
|
// un-maximized. So the flag is LATCHED from the last non-transient
|
|
// visibility, and the transient states persist nothing at all.
|
|
// Seeded imperatively in Component.onCompleted, NOT bound to
|
|
// QbzShell.windowMaximized. cxx-qt generates a NOTIFY for every
|
|
// #[qproperty], so a binding here would be a live reactive edge into the
|
|
// latch: the day anything pushes window_maximized back to QML (syncing the
|
|
// header icon after a WM-side maximize is the obvious future reason), the
|
|
// binding re-fires on any session where the user has not yet toggled
|
|
// maximize and quietly resets the latch to the BOOT value — re-creating
|
|
// exactly the bug below, with no grep signature. Nothing sets it today
|
|
// (`grep set_window_maximized src/` is empty), which is precisely why the
|
|
// invariant belongs here as one local assignment instead of as a
|
|
// cross-file contract nobody will remember.
|
|
property bool maximizedLatch: false
|
|
property bool fullScreenLatch: false
|
|
// A FUNCTION, not a derived property, and that is load-bearing. A
|
|
// `readonly property` computed from `visibility` is dirtied by the very
|
|
// notify signal `onVisibilityChanged` is connected to, and the handler
|
|
// connection runs FIRST, so a read of that property INSIDE the handler is
|
|
// one transition stale. Measured under Qt 6.11 (offscreen, real
|
|
// transitions): the minimize was seen as "not transient" — clearing the
|
|
// latch, the one thing the transient guard exists to prevent — and the
|
|
// following restore as "transient", so the handler returned and never put
|
|
// it back. A maximized session then closed as `window_maximized: false`
|
|
// with the 2556x1436 maximized footprint written into the FLOATING
|
|
// window_width/height (the Rust size gate lets it through precisely
|
|
// because neither flag is set) — Slint #618, the regression
|
|
// settings_qt::save_window_geometry's doc comment says must never happen.
|
|
// A function has no cached value that can be stale: it computes from the
|
|
// enum it is handed, at call time.
|
|
function transientVisibility(vis) {
|
|
return vis === Window.Minimized || vis === Window.Hidden
|
|
}
|
|
|
|
// Last size seen in a REAL windowed frame. The shutdown paths need it
|
|
// because a close from the taskbar happens while MINIMIZED: `window.width`
|
|
// is then whatever the WM left behind, and the debounce may still be
|
|
// holding an unsaved drag that has nowhere left to be deferred to. Only
|
|
// captured once armed, so the boot clamp can never enter it. Zero means
|
|
// "never saw one this session" — below the app floor, so Rust drops the
|
|
// size and still records the flags, which is the honest outcome.
|
|
property real lastFloatingWidth: 0
|
|
property real lastFloatingHeight: 0
|
|
function captureFloatingSize() {
|
|
if (!window.geometryArmed || window.visibility !== Window.Windowed)
|
|
return
|
|
// The enum alone is not enough. An un-maximize delivers the state
|
|
// change and the geometry change in whichever order the platform
|
|
// plugin happens to emit them (xdg_toplevel configure carries both), so
|
|
// on a state-first compositor this runs with `visibility` already
|
|
// Windowed while `width`/`height` are still the MAXIMIZED footprint —
|
|
// and the cache would then hand the shutdown path a screen-sized
|
|
// "floating" size, which Rust accepts because neither flag is set.
|
|
// Reject any frame that is still the full screen; the real floating
|
|
// frame arrives a moment later through onWidthChanged.
|
|
var scr = window.screen
|
|
if (scr && window.width >= scr.width - 1 && window.height >= scr.height - 1)
|
|
return
|
|
window.lastFloatingWidth = window.width
|
|
window.lastFloatingHeight = window.height
|
|
}
|
|
|
|
// Persistence is ARMED late, on purpose. The restore clamp can hand the
|
|
// window a SMALLER size than the file holds (a 2560px session opened on a
|
|
// 1920px panel) and the WM may adjust the first frames too; persisting
|
|
// those settling frames would lose the big-monitor size after one launch on
|
|
// the small one — for the Slint build as well, since the file is shared.
|
|
// Slint re-saves the clamped size by design ("the Resized handler re-saves
|
|
// the result", main.rs:8208); this is the one place the Qt port
|
|
// deliberately improves on it instead of matching it. No real user resize
|
|
// happens inside the first 1.2s of a cold start, and anything the user does
|
|
// after that persists normally.
|
|
property bool geometryArmed: false
|
|
Timer {
|
|
id: geometryArmTimer
|
|
interval: 1200
|
|
running: true
|
|
repeat: false
|
|
onTriggered: window.geometryArmed = true
|
|
}
|
|
|
|
function persistWindowGeometry() {
|
|
if (!window.geometryArmed)
|
|
return
|
|
// Transient: FLUSH the cached floating size, do not drop it and do not
|
|
// defer it. Dropping was the original bug (a drag that debounced into a
|
|
// minimize was lost; Slint writes synchronously from the winit Resized
|
|
// handler and cannot lose it). Re-arming the timer instead was the
|
|
// over-correction: the re-arm is itself what keeps the save
|
|
// outstanding, so a window left minimized to the tray polls at 2.5 Hz
|
|
// forever — measured at 15 wakeups in 6 s, i.e. ~72,000 over a workday.
|
|
// Flushing the cache ends both problems in one line: `window.width` is
|
|
// untrustworthy while iconified, but `lastFloatingWidth` is the last
|
|
// REAL windowed frame, and Rust's floating-only + app-minimum + >0.5px
|
|
// dirty gate turns a zero or stale cache into a no-op by itself.
|
|
if (window.transientVisibility(window.visibility)) {
|
|
QbzShell.saveWindowGeometry(window.lastFloatingWidth,
|
|
window.lastFloatingHeight,
|
|
window.maximizedLatch,
|
|
window.fullScreenLatch)
|
|
return
|
|
}
|
|
QbzShell.saveWindowGeometry(window.width,
|
|
window.height,
|
|
window.maximizedLatch,
|
|
window.fullScreenLatch)
|
|
}
|
|
|
|
// Shutdown variant. Nothing can be deferred once the app is going away, so
|
|
// instead of skipping the write, a transient window persists the last
|
|
// floating size we actually saw. The flags travel either way — they are
|
|
// what the restore reads (main.rs:8273-8281).
|
|
function persistWindowGeometryOnExit() {
|
|
// The 1.2s arming window guards the SIZE against boot-settling frames.
|
|
// It must not swallow the FLAGS: a boot clamp never changes visibility,
|
|
// so a maximize/restore inside the first 1.2s is a genuine user action,
|
|
// and it is the flag — not the size — that both frontends read back
|
|
// (main.rs:8273-8281). Persist the flags with a 0x0 size, which is
|
|
// below the app floor, so Rust records them and drops the size.
|
|
if (!window.geometryArmed) {
|
|
QbzShell.saveWindowGeometry(0, 0,
|
|
window.maximizedLatch,
|
|
window.fullScreenLatch)
|
|
return
|
|
}
|
|
if (!window.transientVisibility(window.visibility)) {
|
|
window.persistWindowGeometry()
|
|
return
|
|
}
|
|
QbzShell.saveWindowGeometry(window.lastFloatingWidth,
|
|
window.lastFloatingHeight,
|
|
window.maximizedLatch,
|
|
window.fullScreenLatch)
|
|
}
|
|
|
|
Timer {
|
|
id: geometrySaveTimer
|
|
interval: 400
|
|
repeat: false
|
|
onTriggered: window.persistWindowGeometry()
|
|
}
|
|
|
|
onWidthChanged: { window.captureFloatingSize(); geometrySaveTimer.restart() }
|
|
onHeightChanged: { window.captureFloatingSize(); geometrySaveTimer.restart() }
|
|
// Maximize/restore/fullscreen: the flag itself is the thing that changed,
|
|
// and the sizes that come with it are deliberately not persisted. A
|
|
// minimize or a hide is NOT a state change to record — it is the same
|
|
// window in a transient mode, so the latch keeps its last real value and
|
|
// the debounce is not even restarted.
|
|
onVisibilityChanged: {
|
|
// Read the enum ONCE into a local and decide everything from it — see
|
|
// `transientVisibility` for why nothing here may consult a property
|
|
// derived from `visibility`.
|
|
var vis = window.visibility
|
|
if (window.transientVisibility(vis))
|
|
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
|
|
// 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
|
|
// it fired it handed Rust the post-transition size with maximized true
|
|
// — and the floating-only gate refuses a size on exactly that
|
|
// condition. The exit path could not rescue it either, since it passes
|
|
// the same latches. Proven by effect: resize 1180->1600, maximize
|
|
// 157ms later, quit — the file kept 1180x760 while Slint, for the same
|
|
// event stream, kept 1600x900. Commit the floating frame we already
|
|
// hold before the latches stop describing it.
|
|
if (window.geometryArmed && geometrySaveTimer.running
|
|
&& !window.maximizedLatch && !window.fullScreenLatch
|
|
&& vis !== Window.Windowed) {
|
|
geometrySaveTimer.stop()
|
|
QbzShell.saveWindowGeometry(window.lastFloatingWidth,
|
|
window.lastFloatingHeight,
|
|
false, false)
|
|
}
|
|
// Maximized and fullscreen are ORTHOGONAL in winit, which is what
|
|
// Slint persists. ASSUMED, NOT MEASURED: on X11
|
|
// `_NET_WM_STATE_MAXIMIZED_*` is expected to survive a fullscreen
|
|
// transition under Mutter/KWin, so `is_maximized()`
|
|
// (main.rs:1404-1408) would keep reporting true from fullscreen. On
|
|
// Wayland a compositor may instead send a states array carrying only
|
|
// `fullscreen`, in which case winit reports false and the two
|
|
// frontends would write OPPOSITE values into the shared
|
|
// window_maximized. Settling this costs one measurement, not an
|
|
// argument: run the Slint build, go maximized -> WM fullscreen, and log
|
|
// `w.window().is_maximized()` from the Resized handler. If it reports
|
|
// false, clear the maximized latch in the FullScreen branch too. Qt
|
|
// collapses both into one enum, so the maximized latch is cleared ONLY
|
|
// by an explicit return to Windowed — never by entering fullscreen.
|
|
// FullScreen arrives from the WM keybind OR from the immersive
|
|
// chrome (immersive/ImmersiveHeader.qml fs toggle — the first
|
|
// FullScreen write in this frontend, 2026-08-02 §5.2); quitting from
|
|
// there must not un-maximize the next launch of either frontend.
|
|
if (vis === Window.Maximized) {
|
|
window.maximizedLatch = true
|
|
window.fullScreenLatch = false
|
|
} else if (vis === Window.FullScreen) {
|
|
window.fullScreenLatch = true
|
|
} else if (vis === Window.Windowed) {
|
|
window.maximizedLatch = false
|
|
window.fullScreenLatch = false
|
|
}
|
|
// An un-maximize delivers its size change and its visibility change in
|
|
// either order; capturing here as well as in onWidthChanged means the
|
|
// floating cache is right whichever arrives second.
|
|
window.captureFloatingSize()
|
|
geometrySaveTimer.restart()
|
|
}
|
|
|
|
// 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: neither `Qt.exit(0)`
|
|
// (what every quit arm calls since 2026-08-04) nor `Qt.quit()` 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
|
|
// session persistence itself is flushed by the Rust exit path; this
|
|
// early geometry flush is still owed before a hide because the
|
|
// process may later be killed without a QML close event. 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) {
|
|
var hide = QbzTray.trayLive && QbzTray.closeToTray
|
|
// The evidence line (2026-08-04): logged RUST-side so it lands in
|
|
// qbz.log — console.log only reaches stderr, which owner reports
|
|
// never carry. It prints both operands and the arm taken, which is
|
|
// the whole diagnosis of any future "Close to tray does nothing".
|
|
QbzTray.closeDecision(hide)
|
|
if (hide) {
|
|
if (closeEvent)
|
|
closeEvent.accepted = false
|
|
window.hideToTray()
|
|
return
|
|
}
|
|
// QUIT — via Qt.exit(0), NEVER Qt.quit(). Qt.quit() delivers
|
|
// QEvent::Quit, and QGuiApplication's handler first closes every
|
|
// top-level window, silently CANCELING the whole quit if any window
|
|
// refuses its close — this very function's hide arm refuses whenever
|
|
// the tray is live with close-to-tray on, and MiniWindow.qml's
|
|
// onClosing refuses unconditionally (its close means "exit the
|
|
// mini"). That veto is how "Quit QBZ" hid the window and left the
|
|
// process running (owner-reported three times; reproduced under VNC
|
|
// 2026-08-04: quit requested + Qt.quit() both logged, event loop
|
|
// still alive 25s later). Qt.exit() stops the event loops directly:
|
|
// no close events, no veto. Geometry is flushed explicitly below,
|
|
// and onAboutToQuit still runs when exec() returns.
|
|
if (closeEvent)
|
|
closeEvent.accepted = true
|
|
QbzTray.armQuitWatchdog()
|
|
window.persistWindowGeometryOnExit()
|
|
Qt.exit(0)
|
|
}
|
|
|
|
// 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.
|
|
// Each handler logs ONE line, and that is not debug litter: this hop is the
|
|
// only part of the tray that Rust cannot see. `tray_qt` logs when it SENDS
|
|
// (e.g. "[tray] quit requested"), so without a line on this side a report of
|
|
// "the tray does nothing" cannot distinguish "the click never left ksni"
|
|
// from "the signal arrived and QML did nothing with it" — which is exactly
|
|
// the ambiguity that cost a diagnosis on 2026-08-04.
|
|
Connections {
|
|
target: QbzTray
|
|
function onWindowShowRequested() {
|
|
console.log("[qml] tray -> showFromTray")
|
|
window.showFromTray()
|
|
}
|
|
function onWindowHideRequested() {
|
|
console.log("[qml] tray -> hideToTray")
|
|
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() {
|
|
console.log("[qml] tray -> quit: persisting geometry")
|
|
window.persistWindowGeometryOnExit()
|
|
console.log("[qml] tray -> quit: calling Qt.exit(0)")
|
|
// Qt.exit(0), NEVER Qt.quit() — see closeOrHide's quit arm: a
|
|
// Qt.quit() here is vetoable by any window's onClosing (the
|
|
// close-to-tray arm, the mini's unconditional refuse), and that
|
|
// veto is exactly how the tray Quit hid the window and left the
|
|
// process alive (2026-08-04, three owner reports). The hard-exit
|
|
// watchdog for this path was armed Rust-side in tray_qt::quit(),
|
|
// before the signal was even emitted.
|
|
Qt.exit(0)
|
|
}
|
|
}
|
|
|
|
// --- The miniplayer window (2026-08-03 miniplayer/tray contract, B2) ---
|
|
//
|
|
// LAZY, with a STRONG reference. Declaring the mini inline would build its
|
|
// whole subtree at startup; a Component + createObject(null) is the Qt
|
|
// shape of the reference's ensure_window() (crates/qbz/src/miniplayer.rs:
|
|
// 121-151), and `miniWindow` is its MINI_STRONG thread-local (:40 "Strong
|
|
// handle keeps the window alive", set :149, NEVER cleared — exit() and
|
|
// close_app() only hide()). A null-parent createObject with no strong
|
|
// reference is JS-GC eligible (contract §16 U-3), and this property is what
|
|
// makes it not.
|
|
//
|
|
// Created on first ENTER, never on boot: the QML tree of a window that has
|
|
// never been opened costs nothing.
|
|
property var miniWindow: null
|
|
Component {
|
|
id: miniWindowComponent
|
|
MiniWindow {}
|
|
}
|
|
function ensureMiniWindow() {
|
|
if (window.miniWindow === null)
|
|
window.miniWindow = miniWindowComponent.createObject(null)
|
|
return window.miniWindow
|
|
}
|
|
|
|
// `QbzMini.open` is the ONE mini lifecycle flag (contract A-2): Rust's
|
|
// enter/exit funnel writes it, stores the AtomicBool mirror the hotkeys
|
|
// pipeline reads, and THEN notifies — so this handler is the QML half of
|
|
// §4.6's step ordering and the only place either window is shown or hidden
|
|
// for the miniplayer.
|
|
//
|
|
// 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() {
|
|
if (QbzMini.open) {
|
|
var mini = window.ensureMiniWindow()
|
|
if (mini !== null) {
|
|
mini.show()
|
|
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):
|
|
// Shift+I now fires as the REBINDABLE ui.focusMode action through the
|
|
// AppShell dispatcher (QbzHotkeys.keyPressed), whose central text-input
|
|
// gate replaces the `enabled:` expression this block carried.
|
|
// NavGestureLayer.qml owns mouse Back/Forward; no other Shortcut item
|
|
// exists in the tree (trap 11).
|
|
|
|
Loader {
|
|
id: screenLoader
|
|
anchors.fill: parent
|
|
active: QbzSession.screen !== "splash"
|
|
// "kiosk" is a SIBLING of "shell", not a mode inside it — the same
|
|
// shape as the reference (app.slint:38's AppScreen enum, with the two
|
|
// shells as sibling mounts at :145 and :205). Both read the same
|
|
// bridges, so the live toggle swaps them with nothing torn down.
|
|
// An unknown id falls through to "" and the Loader goes blank, which
|
|
// is the documented failure mode for a missing route (nav_qt.rs:14-16)
|
|
// and is what a missing KioskShell.qml would look like.
|
|
source: QbzSession.screen === "login"
|
|
? "LoginScreen.qml"
|
|
: (QbzSession.screen === "shell" ? "shell/AppShell.qml"
|
|
: (QbzSession.screen === "kiosk" ? "shell/KioskShell.qml" : ""))
|
|
// Hand the host window down for drag/maximize/resize (custom chrome).
|
|
onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window
|
|
}
|
|
|
|
// Frameless hairline (app.slint's no-frame 1px edge) — paints at the
|
|
// very rim, over everything. SQUARE (the app draws no corner rounding).
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "transparent"
|
|
border.width: 1
|
|
border.color: "#14ffffff"
|
|
visible: !QbzShell.systemTitleBar
|
|
&& window.visibility !== Window.Maximized && window.visibility !== Window.FullScreen
|
|
}
|
|
|
|
// Edge/corner resize grips (custom chrome — the compositor draws no
|
|
// border). 6px, invisible.
|
|
MouseArea {
|
|
anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
|
|
cursorShape: Qt.SizeHorCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.LeftEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
|
|
cursorShape: Qt.SizeHorCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.RightEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; height: 6
|
|
cursorShape: Qt.SizeVerCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.TopEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 6
|
|
cursorShape: Qt.SizeVerCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.BottomEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.left: parent.left; anchors.top: parent.top; width: 12; height: 12
|
|
cursorShape: Qt.SizeFDiagCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.TopEdge | Qt.LeftEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.right: parent.right; anchors.top: parent.top; width: 12; height: 12
|
|
cursorShape: Qt.SizeBDiagCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.TopEdge | Qt.RightEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.left: parent.left; anchors.bottom: parent.bottom; width: 12; height: 12
|
|
cursorShape: Qt.SizeBDiagCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.BottomEdge | Qt.LeftEdge)
|
|
}
|
|
MouseArea {
|
|
anchors.right: parent.right; anchors.bottom: parent.bottom; width: 12; height: 12
|
|
cursorShape: Qt.SizeFDiagCursor
|
|
enabled: !QbzShell.systemTitleBar
|
|
onPressed: window.startSystemResize(Qt.BottomEdge | Qt.RightEdge)
|
|
}
|
|
|
|
// Splash (SplashScreen.slint): the same 720px dark card as the login
|
|
// screen while the silent session restore resolves.
|
|
//
|
|
// PARITY PASS (2026-08-19). Three things this block had lost against the
|
|
// reference, all of them user-visible:
|
|
// * the LEGAL DISCLAIMER, which SplashScreen.slint:75-86 pins to the
|
|
// bottom of the SCREEN (not inside the card — Tauri put it there and
|
|
// the Slint port kept that on purpose). It was simply absent here, so
|
|
// the one screen a first-run user stares at said nothing about the
|
|
// app not being certified by Qobuz.
|
|
// * the strings, which were bare QML literals. Every other screen goes
|
|
// through QbzSession.tr() with the EXACT msgid of the Slint @tr()
|
|
// call so the existing .po catalogues apply; these three did not, so
|
|
// the splash was English in all eight locales.
|
|
// * the colours and metrics, which were hardcoded hex and magic numbers
|
|
// (#0f0f0f / #1a1a1a / #ffffff / #888888, 52, 104, 8, 32). They happen
|
|
// to match the DEFAULT dark palette and nothing else: on any of the
|
|
// other 34 themes the splash was a black card the rest of the app had
|
|
// stopped being. Tokens now, like LoginScreen.qml.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: splashTheme.surfaceMain
|
|
visible: QbzSession.screen === "splash"
|
|
|
|
QbzTheme { id: splashTheme }
|
|
|
|
// Faked drop shadow (blur 32, offset-y 8) — the same hand-copy
|
|
// LoginScreen.qml:41-49 carries, for the same reason and with the same
|
|
// caveat written there. The reference gives BOTH cards the shadow
|
|
// (SplashScreen.slint:23-25); only the login one had it here.
|
|
Rectangle {
|
|
anchors.horizontalCenter: splashCard.horizontalCenter
|
|
y: splashCard.y + 8
|
|
width: splashCard.width
|
|
height: splashCard.height
|
|
radius: splashTheme.radiusLg
|
|
color: splashTheme.cardShadow
|
|
opacity: 0.5
|
|
}
|
|
|
|
Rectangle {
|
|
id: splashCard
|
|
anchors.centerIn: parent
|
|
width: 720
|
|
height: splashColumn.height + 2 * splashTheme.cardPadding
|
|
radius: splashTheme.radiusLg
|
|
color: splashTheme.surfaceCard
|
|
|
|
Column {
|
|
id: splashColumn
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: splashTheme.cardPadding
|
|
spacing: 0
|
|
Image {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
source: "assets/qbz-logo.png"
|
|
width: 140
|
|
height: 140
|
|
fillMode: Image.PreserveAspectFit
|
|
}
|
|
Item { width: 1; height: splashTheme.spacingSm }
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: QbzSession.tr("QBZ", QbzSession.trRev)
|
|
color: splashTheme.textPrimary
|
|
font.pixelSize: splashTheme.fontWordmark
|
|
font.weight: splashTheme.weightSemibold
|
|
font.letterSpacing: 8
|
|
}
|
|
Item { width: 1; height: 2 }
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: QbzSession.tr("QOBUZ™ PLAYER", QbzSession.trRev)
|
|
color: splashTheme.textMuted
|
|
font.pixelSize: splashTheme.fontSubtitle
|
|
font.letterSpacing: 4
|
|
}
|
|
Item { width: 1; height: splashTheme.spacingXl }
|
|
QbzSpinner {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
size: 30
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legal disclaimer — pinned to the BOTTOM OF THE SCREEN, not inside
|
|
// the card. That placement is the reference's, and its comment says it
|
|
// is Tauri's too (SplashScreen.slint:73-74). Two msgids concatenated
|
|
// with a space, exactly as the reference concatenates them, so both
|
|
// reuse the catalogue entries the login screen already uses.
|
|
Text {
|
|
width: Math.min(parent.width - 80, 720)
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: parent.height - height - 24
|
|
text: QbzSession.tr("This application uses the Qobuz API but is not certified by Qobuz.", QbzSession.trRev)
|
|
+ " "
|
|
+ QbzSession.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.", QbzSession.trRev)
|
|
color: splashTheme.textMuted
|
|
// 10px LITERAL, not fontLegal: the reference hardcodes 10px here
|
|
// (SplashScreen.slint:80) where its login twin uses Typography.legal.
|
|
font.pixelSize: 10
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|