Files
qbz/crates/qbz-qt/qml/Main.qml
T
vicrodh 0e53837582 feat(metadata): restore local tag editor in Qt
Restore the selected-version editor with sidecar-safe defaults, inspected tag layers, guarded MusicBrainz and Discogs lookup, and confirmed verified direct writes. Keep paths Rust-owned and reject stale or injected drafts.

cx
2026-08-24 16:41:06 -06:00

1152 lines
62 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: the Slint app bundles the Inter 18pt faces (app.slint); the same
// TTFs are embedded here via qrc and applied app-wide through the
// ApplicationWindow font (children inherit).
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).
flags: QbzShell.systemTitleBar
? Qt.Window
: (QbzShell.isMacos
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
: (Qt.Window | Qt.FramelessWindowHint))
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
// flag look broken when it was not.
//
// Since 6.9 "ApplicationWindow will automatically add padding to the
// contentItem for any safe area margins reported by the window", so that
// "the contentItem stays inside the safe area of the window, while the
// background item covers the entire window" (Qt Quick Controls docs).
//
// On macOS with the expanded client area that safe area IS the titlebar
// band. So the window background dutifully covered the whole frame while
// `screenLoader` — anchored to the contentItem — was pushed ~28pt down.
// The result reads on screen as a stock title bar with the QBZ header
// below it, which is precisely what the owner kept reporting while the
// AppKit side measured perfect (styleMask 0x800f, titlebar transparent,
// contentH == windowH == 1328).
//
// QBZ draws its own chrome edge to edge on every platform — the header at
// the top, the player bar at the bottom, the sidebar at the left — so it
// takes the safe area over from Qt entirely. `HeaderBar.chromeLeftInset`
// is where that responsibility is discharged: it reads
// `SafeArea.margins.left` to clear the traffic lights.
topPadding: 0
bottomPadding: 0
leftPadding: 0
rightPadding: 0
color: "#1a1a1a"
FontLoader { id: interRegular; source: "assets/fonts/Inter_18pt-Regular.ttf" }
FontLoader { id: interMedium; source: "assets/fonts/Inter_18pt-Medium.ttf" }
FontLoader { id: interSemiBold; source: "assets/fonts/Inter_18pt-SemiBold.ttf" }
FontLoader { id: interBold; source: "assets/fonts/Inter_18pt-Bold.ttf" }
// The quality badge's face in the Tauri build — loading it here registers
// the family app-wide, so QualityBadge can name it without carrying its
// own loader into every row it renders in.
FontLoader { id: lineSeed; source: "assets/fonts/LINESeedJP-Regular.ttf" }
// Settings > Appearance > Typography & Language > Font — the CONTROLS half.
//
// The app's own text does not come from here: a plain `Text` takes the
// APPLICATION font at construction, which main() sets before this document
// is even loaded (see qml/FontPreload.qml for the measurements). What this
// line still owns is Qt Quick Controls, which follow ApplicationWindow.font
// and would otherwise sit on Inter while every label around them moved.
//
// "" = System, i.e. leave Qt's own choice alone, which is what this app
// rendered before the setting existed.
font.family: QbzShell.appFontFamily !== ""
? QbzShell.appFontFamily
: (interRegular.status === FontLoader.Ready ? interRegular.name : "Sans Serif")
// 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()
// 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.
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
// 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) {
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
}
}
}