The Developer > Share-logs handoff (open::that on the log file) becomes a filterable in-app view: level filter + substring search run Rust-side over qbz_log's ring and only the survivors cross the bridge (one document, max 500 rows), with auto-tail, copy-all, a GitHub-ready <details> bundle and paste upload. The bundle header now reports the real audio backend via settings_qt::current_backend_label. cc
713 lines
34 KiB
QML
713 lines
34 KiB
QML
// App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's
|
|
// chrome: HeaderBar (top, 42px) / { Sidebar | content frame | queue
|
|
// column } / NowPlayingBarSmall (bottom).
|
|
//
|
|
// The window chrome is surface-card throughout; the content area is a
|
|
// ROUNDED surface-main panel inset 8px left/right/bottom (0 top — it
|
|
// butts the header), the "Slack-style bezel on all four corners" of
|
|
// AppShell.slint:358-390. The recovery affordance and logout live in the
|
|
// header (offline badge flyout + app menu), like the Slint shell.
|
|
//
|
|
// The artwork-derived ambient background IS implemented: AmbientField is
|
|
// mounted below, `ambientOn` derives from QbzShell.ambientMode, and the
|
|
// content frame swaps to surface-main @ 0.22 with a hairline while it runs.
|
|
// (The POC-NOTE that used to sit here said the opposite. It was a fossil —
|
|
// corrected in passing per LESSONS-2026-08-03 L10, which asks that the ones
|
|
// a diff touches be fixed rather than propagated.)
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../immersive"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
// OPAQUE base (the Slint root background is opaque surface-main too —
|
|
// invisible while the ambient field mounts over it; this IS the D4
|
|
// no-track fallback). The translucent chrome comes from each chrome
|
|
// piece's OWN background going surface-card @ 0.5 above the field.
|
|
color: theme.surfaceCard
|
|
|
|
// App-wide dynamic background (phase 14): active when the mode pref is
|
|
// on AND a track is loaded (D4: no track -> opaque theme restored).
|
|
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
|
// The bottom-most visual layer (AppShell.slint:206-242, declared FIRST
|
|
// so every chrome surface paints above it): the ambient album-colored
|
|
// field + the dark dim scrim that keeps chrome text legible
|
|
// (QBZ_BG_DIM-tunable, default 0.35).
|
|
// Window-level history navigation: mouse side buttons + two-finger
|
|
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
|
|
// hit-tests on top of it — a scroll reaches it only when nothing above
|
|
// consumed it, which is what keeps horizontal carousels scrolling instead
|
|
// of navigating. Never consumes a wheel event; accepts only the two side
|
|
// buttons.
|
|
NavGestureLayer {
|
|
anchors.fill: parent
|
|
}
|
|
|
|
AmbientField {
|
|
anchors.fill: parent
|
|
visible: root.ambientOn
|
|
running: root.ambientOn
|
|
}
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.ambientOn
|
|
color: "#000000"
|
|
opacity: QbzShell.ambientDim
|
|
}
|
|
|
|
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
|
|
property var hostWindow: null
|
|
|
|
// §1.4.1 + §4.1 (2026-08-03 hotkeys-port contract): the shell root is
|
|
// BOTH the dispatcher host and the fallback focus item — Qt delivers key
|
|
// events only to activeFocusItem and propagates up its parent chain, and
|
|
// the shell tree had ZERO focus items, so without this a fresh launch
|
|
// has activeFocusItem == null and the pipeline receives NOTHING until
|
|
// the first click (the round-1 BLOCKER; H0 is the RFB proof). The
|
|
// marker lets a modal anywhere in the tree walk up and re-focus this
|
|
// root on close (§1.4.2/§1.4.3).
|
|
focus: true
|
|
readonly property bool isQbzShellRoot: true
|
|
|
|
// The assert half of §1.4.1: declarative `focus: true` alone did NOT
|
|
// survive the splash -> shell Loader swap in a WM-less (VNC) session —
|
|
// activeFocusItem stayed null and the dispatcher received nothing
|
|
// until the first click (measured 2026-08-03, RFB H0 first pass). Grab
|
|
// once at mount; every other lifecycle arm (immersive close, modal
|
|
// closes) hands focus BACK here.
|
|
Component.onCompleted: root.forceActiveFocus()
|
|
|
|
// THE ONE key entry (§1.1 route (b), divergence K1): NOTHING is handled
|
|
// locally. The ordered pipeline (capture steal, search-dropdown Up/Down
|
|
// steal, central text-input gate, Ctrl+A, binding dispatch, the §1.2
|
|
// Escape stack) lives in Rust behind QbzHotkeys.keyPressed; the QML side
|
|
// only computes the gate (§1.4.4). Null activeFocusItem passes the gate
|
|
// (the semantically right case). Every existing Keys. handler accepts
|
|
// only what it owns, and all buttons are MouseArea-based and can never
|
|
// take keyboard focus — verified non-conflicts (§4.1).
|
|
Keys.onPressed: function (event) {
|
|
var w = root.Window.window
|
|
var afi = w !== null ? w.activeFocusItem : null
|
|
var textInputFocused = (afi instanceof TextInput) || (afi instanceof TextEdit)
|
|
event.accepted = QbzHotkeys.keyPressed(event.key, event.modifiers,
|
|
event.text, textInputFocused)
|
|
}
|
|
|
|
// --- Hotkeys routers (§4.3 / §4.6) -------------------------------------
|
|
// nav.search (Ctrl+f, divergence K6 — EXCEEDS Slint, whose focus_search
|
|
// only flipped cortinilla_open with no visible effect on an empty
|
|
// query): the Rust dispatch emits this signal and the field lands
|
|
// focused and ready to type.
|
|
Connections {
|
|
target: QbzHotkeys
|
|
function onFocusSearchRequested() { header.focusSearch() }
|
|
}
|
|
|
|
// The §4.6 Ctrl+A + multi-select Escape-exit seam. Selection state is
|
|
// in-view QML JS (library_bulk.rs:8: "select-all / clear never reach
|
|
// Rust"), so the QbzShell signals route to the mounted view's
|
|
// duck-typed interface: selectAll() / exitMultiSelectMode() /
|
|
// multiSelectOn. Implemented by LibraryView, LocalLibraryView and
|
|
// LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in
|
|
// Rust with no select-all arm); views without multi-select (Artist,
|
|
// Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4).
|
|
Connections {
|
|
target: QbzShell
|
|
function onSelectAllRequested() {
|
|
if (contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.selectAll === "function")
|
|
contentRouter.currentItem.selectAll()
|
|
}
|
|
function onExitMultiSelectRequested() {
|
|
if (contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.exitMultiSelectMode === "function")
|
|
contentRouter.currentItem.exitMultiSelectMode()
|
|
}
|
|
}
|
|
// The reporters: their QbzShell mirrors feed the Rust Ctrl+A
|
|
// consumption predicate (capable) and the §1.2 Escape stack arm 6
|
|
// (active). Duck-typed, so a view without the interface reports
|
|
// capable=false and Ctrl+A falls through to the binding lookup (the
|
|
// Slint select_all_active_surface false case).
|
|
Binding {
|
|
target: QbzShell
|
|
property: "multiSelectCapable"
|
|
value: contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.selectAll === "function"
|
|
restoreMode: Binding.RestoreNone
|
|
}
|
|
Binding {
|
|
target: QbzShell
|
|
property: "multiSelectActive"
|
|
value: contentRouter.currentItem !== null
|
|
&& contentRouter.currentItem.multiSelectOn === true
|
|
restoreMode: Binding.RestoreNone
|
|
}
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
HeaderBar {
|
|
id: header
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
height: theme.headerHeight
|
|
hostWindow: root.hostWindow
|
|
// Square corners (phase 12: the window is opaque; any rounding is
|
|
// the compositor's business).
|
|
}
|
|
|
|
NowPlayingBar {
|
|
id: npb
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
// Mode-aware height (AppShell.slint:396): Small collapses to one
|
|
// header-tall row; New/Classic/Large keep the full 112px.
|
|
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
|
|
// The shared hover-tooltip overlay (declared further down — id
|
|
// references resolve at completion). Only the FULL bar consumes it
|
|
// (the Qobuz Connect button's "Qobuz Connect: On/Off" bubble); the
|
|
// small bar's button has no tooltip in the reference.
|
|
tooltip: tooltipOverlay
|
|
}
|
|
|
|
Sidebar {
|
|
id: sidebar
|
|
anchors.left: parent.left
|
|
anchors.top: header.bottom
|
|
anchors.bottom: npb.top
|
|
// The shared hover-tooltip overlay (declared last, below). The sidebar
|
|
// clips its own overflow, so the collapsed rail's name bubble HAS to be
|
|
// rendered out here — same reason Slint mounts SidebarTooltip at the
|
|
// AppShell level rather than inside Sidebar.slint.
|
|
tooltip: tooltipOverlay
|
|
// Animated 3-state width lives inside the component.
|
|
}
|
|
|
|
// Right-side panel column — Queue and/or Lyrics, stacked vertically in
|
|
// a shared 300px column (Feishin-style, AppShell.slint:684-707). Each
|
|
// is toggled from its bar button and closed from its own X; the column
|
|
// is visible when either is open, animated 0 <-> 300 (160ms).
|
|
Rectangle {
|
|
id: queueColumn
|
|
anchors.right: parent.right
|
|
anchors.top: header.bottom
|
|
anchors.bottom: npb.top
|
|
width: (QbzShell.queueOpen || QbzShell.lyricsOpen) ? theme.queuePanelWidth : 0
|
|
clip: true
|
|
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
|
|
|
Behavior on width {
|
|
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
|
}
|
|
|
|
QueuePanel {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
height: QbzShell.lyricsOpen
|
|
? (QbzShell.queueOpen ? parent.height / 2 : 0)
|
|
: parent.height
|
|
visible: QbzShell.queueOpen
|
|
}
|
|
Rectangle {
|
|
visible: QbzShell.queueOpen && QbzShell.lyricsOpen
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
y: QbzShell.lyricsOpen && QbzShell.queueOpen ? parent.height / 2 : 0
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
LyricsPanel {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: QbzShell.lyricsOpen
|
|
? (QbzShell.queueOpen ? parent.height / 2 - 1 : parent.height)
|
|
: 0
|
|
visible: QbzShell.lyricsOpen
|
|
}
|
|
}
|
|
|
|
// Content frame — the rounded, inset surface-main panel (Radius.md,
|
|
// 8px gaps left/right/bottom, flush to the header).
|
|
Rectangle {
|
|
id: contentFrame
|
|
anchors.left: sidebar.right
|
|
anchors.right: queueColumn.left
|
|
anchors.top: header.bottom
|
|
anchors.bottom: npb.top
|
|
anchors.leftMargin: 8
|
|
anchors.rightMargin: 8
|
|
anchors.bottomMargin: 8
|
|
radius: theme.radiusMd
|
|
// Frosted content panel while the ambient background is active
|
|
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10 hairline).
|
|
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
|
|
border.width: root.ambientOn ? 1 : 0
|
|
border.color: theme.frostBorder
|
|
clip: true
|
|
|
|
// The view-mount chain — every route arm, every hard-won comment and
|
|
// the MyQBZ `kind` discriminator now live in ContentRouter.qml, which
|
|
// the KIOSK shell mounts too (kiosk-port contract §4.2 / D3). The
|
|
// Slint kiosk copies AppShell's mount block verbatim and calls that
|
|
// copy debt in its own header (KioskShell.slint:10-17); this is the
|
|
// named fix, so the two shells cannot drift.
|
|
//
|
|
// `kiosk` defaults false, i.e. this instance routes every id to the
|
|
// desktop view it always did. Reached from elsewhere in this document
|
|
// through `contentRouter.currentItem` (the multi-select seam above) —
|
|
// a QML `id` does not cross documents, so `viewLoader` is no longer
|
|
// nameable here and that public property is the replacement.
|
|
ContentRouter {
|
|
id: contentRouter
|
|
anchors.fill: parent
|
|
}
|
|
}
|
|
|
|
// --- Bezel corners ----------------------------------------------------
|
|
// `clip: true` on contentFrame is a RECTANGULAR scissor in Qt Quick — it
|
|
// is a glScissor / QPainter setClipRect and it does NOT follow `radius`.
|
|
// The radius only shapes that Rectangle's own fill, so anything that
|
|
// paints opaquely at the frame's corners paints straight over the bezel
|
|
// and the panel reads square. Slint has no such split: its `clip: true`
|
|
// on a Rectangle clips to the ROUNDED shape (AppShell.slint:409-414 sets
|
|
// border-radius + clip on the same element and the album atmosphere
|
|
// inside it is cut by the arc), which is why the identical structure is
|
|
// correct there and wrong here. The visible offender is the album/artist
|
|
// header band (controls/HeaderGradient.qml, full-bleed at y:0), but the
|
|
// defect is the frame's, not the band's: the band scrolls, so rounding
|
|
// the band itself would only hold at scroll offset 0 — measured, the
|
|
// corners go square again the moment the page scrolls and the band's body
|
|
// reaches the top edge. The mask therefore belongs here, once, and it
|
|
// covers every present and future view.
|
|
//
|
|
// SIBLINGS of contentFrame, not children of it. Inside the frame they sat
|
|
// at z:0 with the view Loader, so any overlay a VIEW mounts painted over
|
|
// them and the corners read square while it was open — the pane-filling
|
|
// modals are exactly that: DiscoverConfigModal z:3100 (HomeView:1130),
|
|
// GenreFilterPopup z:3000 (Home/Library/DiscoverBrowse/PlaylistBrowse),
|
|
// TrackInfoModal z:3000 (rows/TrackRow.qml:565, and TrackRow mounts
|
|
// inside views). Raising the nubs to z:3200 would only win until the next
|
|
// modal, because ADR-009 fixes in-pane modals at z >= 3000 and says
|
|
// nothing about a ceiling. Out here the question does not arise: the mask
|
|
// describes the FRAME's silhouette, so nothing the frame contains can
|
|
// reach it, whatever z it picks. Window-level overlays declared after
|
|
// this block still paint over the nubs — Cortinilla, the shared text
|
|
// modal, the drag ghost, ArtPreviewOverlay — and that is correct: a
|
|
// window-wide scrim covers the pane corners too. SidebarNowPlayingDock is
|
|
// also later but lives inside the 240px sidebar (x:16, w:208) and never
|
|
// touches the frame.
|
|
//
|
|
// Mechanism: four small Canvas nubs, each filling its corner square with
|
|
// the shell colour and punching the quarter-disc back out.
|
|
//
|
|
// DOCTRINE CORRECTION (2026-07-29). This paragraph used to justify the
|
|
// Canvas by claiming "ShaderEffect / OpacityMask render NOTHING on the
|
|
// software path, and `layer.enabled` would cost an FBO every frame".
|
|
// Both halves are FALSE as stated: effects need shaders, and this port
|
|
// runs on the GPU (OpenGL RHI, measured via QSG_INFO 2026-07-29) — the
|
|
// "renders nothing" note came from an offscreen session, which forces the
|
|
// software renderer by definition. A `layer` FBO is also rendered once and
|
|
// CACHED for static content, not re-rendered per frame. Where a software
|
|
// path is genuinely possible, detect it with `GraphicsInfo.api` rather
|
|
// than assuming it (theme/RoundedImage.qml does exactly that and is the
|
|
// canonical statement of this rule — read its "HOW THE ROUNDING IS DONE"
|
|
// header before touching any masking code).
|
|
//
|
|
// The Canvas here is still the right call, but on its own merits, not that
|
|
// one: the nubs are 4 x 12x12 px rastered ONCE and repainted only when the
|
|
// theme colour or the radius changes, whereas masking the frame would mean
|
|
// an FBO the size of the whole content pane, re-rendered on every window
|
|
// resize and invalidated by anything animating inside the pane.
|
|
//
|
|
// The fill is the shell base (`root.color`) because that is literally
|
|
// what shows through the bezel: the 8px gutter, the HeaderBar above
|
|
// and the NPB gap below are all surface-card, and nothing else paints
|
|
// between `root` and the frame while the corners are on.
|
|
//
|
|
// Gated on the ambient background being OFF, and that is not a
|
|
// shortcut: with the dynamic background active the frame goes
|
|
// translucent (surface-main @ 0.22 + hairline) and the ambient field
|
|
// is SUPPOSED to show through the corners, so an opaque nub would be
|
|
// the regression. The same flag also suppresses the album/artist
|
|
// atmosphere (AlbumView.qml:71 `headerAtmoOn = pref && !ambientOn`,
|
|
// AlbumPageView.slint:168) — the two states are complementary, never
|
|
// both.
|
|
//
|
|
// WHAT COVERS THE AMBIENT-ON CASE, since this block cannot: nothing that
|
|
// paints opaquely may reach the frame's corners while the field is
|
|
// showing through them, and by construction almost nothing does — every
|
|
// view root goes `color: "transparent"` under ambient (HomeView.qml:53 and
|
|
// its twelve siblings), and the ones that stay opaque round their own fill
|
|
// (`radius: 12`, the same trick, applied by the offender instead of by a
|
|
// mask). Sweeping the pane-level overlay set (ADR-009's z >= 3000) turned
|
|
// up ONE that did neither: controls/DiscoverConfigModal.qml's scrim, a
|
|
// full-bleed #bf000000 Rectangle at z:3100.
|
|
// It now carries `radius: theme.radiusMd` too. GenreFilterPopup's backdrop
|
|
// is a bare MouseArea (paints nothing) and TrackInfoModal / CastPicker are
|
|
// parented to the window Overlay, where covering the pane corners is the
|
|
// correct behaviour. A future full-bleed opaque pane child must round
|
|
// itself the same way — there is no mask that can do it for it here.
|
|
BezelCorner { corner: 0; fill: root.color; r: contentFrame.radius
|
|
visible: !root.ambientOn
|
|
x: contentFrame.x; y: contentFrame.y }
|
|
BezelCorner { corner: 1; fill: root.color; r: contentFrame.radius
|
|
visible: !root.ambientOn
|
|
x: contentFrame.x + contentFrame.width - width; y: contentFrame.y }
|
|
BezelCorner { corner: 2; fill: root.color; r: contentFrame.radius
|
|
visible: !root.ambientOn
|
|
x: contentFrame.x + contentFrame.width - width
|
|
y: contentFrame.y + contentFrame.height - height }
|
|
BezelCorner { corner: 3; fill: root.color; r: contentFrame.radius
|
|
visible: !root.ambientOn
|
|
x: contentFrame.x
|
|
y: contentFrame.y + contentFrame.height - height }
|
|
|
|
// The bezel nub itself. Kept as an inline component (no outer `id` is
|
|
// referenced inside it — inline components do not share the document's
|
|
// scope, so the colour and the radius arrive as properties).
|
|
//
|
|
// `corner`: 0 = top-left, 1 = top-right, 2 = bottom-right, 3 = bottom-left.
|
|
// The arc centre is the corner of the r x r square that points INTO the
|
|
// panel, so the punched-out quarter-disc is the panel side and the painted
|
|
// remainder is the sliver outside the arc — the exact pixels Qt's
|
|
// rectangular clip fails to cut.
|
|
component BezelCorner: Canvas {
|
|
id: nub
|
|
property int corner: 0
|
|
property color fill: "#000000"
|
|
property int r: 12
|
|
|
|
width: nub.r
|
|
height: nub.r
|
|
// Purely decorative: never take a click meant for the view underneath.
|
|
enabled: false
|
|
// Same pair as RoundedImage/PlaylistCollage — CPU raster, and Immediate
|
|
// so a repaint cannot land on a pixmap whose Canvas is already gone.
|
|
renderTarget: Canvas.Image
|
|
renderStrategy: Canvas.Immediate
|
|
|
|
onFillChanged: nub.requestPaint()
|
|
onRChanged: nub.requestPaint()
|
|
onCornerChanged: nub.requestPaint()
|
|
|
|
onPaint: {
|
|
var ctx = nub.getContext("2d")
|
|
if (!ctx || nub.r <= 0)
|
|
return
|
|
ctx.reset()
|
|
ctx.clearRect(0, 0, nub.width, nub.height)
|
|
ctx.fillStyle = nub.fill
|
|
ctx.fillRect(0, 0, nub.width, nub.height)
|
|
// destination-out = QPainter's CompositionMode_DestinationOut, so
|
|
// the disc erases the fill instead of drawing over it. Its edge is
|
|
// antialiased against transparency, which is what makes the nub
|
|
// blend into the arc rather than staircase along it.
|
|
ctx.globalCompositeOperation = "destination-out"
|
|
ctx.beginPath()
|
|
ctx.arc(nub.corner === 0 || nub.corner === 3 ? nub.r : 0,
|
|
nub.corner === 0 || nub.corner === 1 ? nub.r : 0,
|
|
nub.r, 0, 2 * Math.PI)
|
|
ctx.fill()
|
|
ctx.globalCompositeOperation = "source-over"
|
|
}
|
|
}
|
|
|
|
// --- Large NPB (mode 3) cover dock (phase 18) -------------------------
|
|
// The L's vertical arm: the square now-playing cover + FFT band, pinned
|
|
// flush to the window bottom-left over the sidebar
|
|
// (SidebarNowPlayingDock.slint, AppShell.slint:747). Only while Large is
|
|
// ACTIVE (mode 3 + sidebar open).
|
|
//
|
|
// The height comes from QbzShell.largeDockHeight (it changes with the
|
|
// band's eye toggle) and Sidebar.qml reserves the same value minus the
|
|
// bar height — pinning here with a literal would desync the two.
|
|
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
|
SidebarNowPlayingDock {
|
|
visible: root.largeActive
|
|
// Equal 16px gutters inside the 240px sidebar; the cover is square,
|
|
// so this width also sets the art size.
|
|
x: 16
|
|
width: 208
|
|
y: parent.height - QbzShell.largeDockHeight
|
|
ambientOn: root.ambientOn
|
|
}
|
|
|
|
// Search cortinilla (phase 15): the live-search dropdown overlay.
|
|
// Declared here, ABOVE the shell but BELOW the four siblings that follow
|
|
// (toast host, immersive, art preview, tooltip) — declaration order is
|
|
// z-order. It is NOT the last child, and does not need to be: immersive
|
|
// dismisses the cortinilla on open, and the other three are transient
|
|
// layers that are meant to sit above it.
|
|
Cortinilla {
|
|
anchors.fill: parent
|
|
headerBar: header
|
|
}
|
|
|
|
// --- Shared text modal (phase 16) ---------------------------------------
|
|
// The AppShell-level modal layer (the Slint AppShell modals mount at
|
|
// window level, ADR-009): the scrim covers the WHOLE window — sidebar /
|
|
// header / NPB included — and the panel centers on the WINDOW, not on
|
|
// the content frame. Views reach it via `openTextModal(title, body)`.
|
|
property bool textModalOpen: false
|
|
property string textModalTitle: ""
|
|
property string textModalBody: ""
|
|
function openTextModal(title, body) {
|
|
textModalTitle = title
|
|
textModalBody = body
|
|
textModalOpen = true
|
|
}
|
|
|
|
Rectangle {
|
|
visible: root.textModalOpen
|
|
anchors.fill: parent
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.textModalOpen = false
|
|
}
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: Math.min(root.width - 80, 560)
|
|
height: Math.min(root.height - 120, 460)
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
MouseArea { anchors.fill: parent }
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.margins: 24
|
|
spacing: 14
|
|
Row {
|
|
width: parent.width
|
|
Text {
|
|
width: parent.width - 28
|
|
text: root.textModalTitle
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Rectangle {
|
|
width: 28
|
|
height: 28
|
|
color: tmCloseArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
radius: 6
|
|
QbzIcon {
|
|
name: "x"
|
|
width: 18
|
|
height: 18
|
|
anchors.centerIn: parent
|
|
tintName: tmCloseArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: tmCloseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.textModalOpen = false
|
|
}
|
|
}
|
|
}
|
|
Flickable {
|
|
width: parent.width
|
|
height: parent.height - 42
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: tmText.implicitHeight
|
|
Text {
|
|
id: tmText
|
|
width: parent.width
|
|
text: root.textModalBody
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Drag ghost (DragGhost.slint, phase 17) -----------------------------
|
|
// The dark pill that follows the cursor while tracks are dragged onto
|
|
// the sidebar: "N tracks" for a group, or title + "artist · album" for
|
|
// one. Visual only (never blocks the pointer).
|
|
Rectangle {
|
|
visible: QbzShell.dragActive
|
|
x: QbzShell.dragX + 10
|
|
y: QbzShell.dragY + 14
|
|
width: ghostCol.width + 28
|
|
height: ghostCol.height + 16
|
|
radius: 8
|
|
color: "#e01e1e28"
|
|
border.width: 1
|
|
border.color: "#1fffffff"
|
|
Column {
|
|
id: ghostCol
|
|
anchors.centerIn: parent
|
|
spacing: 2
|
|
Text {
|
|
text: QbzShell.dragCount > 1
|
|
? QbzShell.dragCount + " " + QbzSession.tr("tracks", QbzSession.trRev)
|
|
: QbzShell.dragTitle
|
|
color: "#ffffff"
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
}
|
|
Text {
|
|
visible: QbzShell.dragCount === 1 && QbzShell.dragSubtitle !== ""
|
|
text: QbzShell.dragSubtitle
|
|
color: "#8cffffff"
|
|
font.pixelSize: 10
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- MyQBZ global overlays + the shared toast host ---------------------
|
|
// Three window-level overlays, mounted ONCE here because each is reachable
|
|
// from anywhere and must outlive the view that triggered it:
|
|
//
|
|
// AddToMixtapeModal — the "Add to Mixtape/Collection" picker, opened from
|
|
// a TrackRow menu, an album/playlist page, a Local Library bulk bar or
|
|
// the now-playing bar. The view that opened it is often unmounted by
|
|
// the time the user picks a target.
|
|
// MyQbzModals — Create / Edit (rename · description · delete) / DJ
|
|
// Mix, three sibling panels in one file (spec 01 §10/§11). Delete in
|
|
// particular navigates away from the page that raised it.
|
|
// QbzToast — the ONE in-app toast host (toast_qt.rs publishes
|
|
// onto QbzShell.toastJson; the auto-hide timer lives in the QML).
|
|
//
|
|
// NO `z` on any of them, and that is the point. This file's convention is
|
|
// DECLARATION ORDER (see the note at ArtPreviewOverlay below: "LAST child =
|
|
// above every surface"), and the bezel-corner note at :200-264 explains why
|
|
// `z` is the wrong tool out here — ADR-009 pins in-pane modals at z >= 3000
|
|
// with no ceiling, so any number picked here loses to the next one added.
|
|
// Position in the chain is byte-equivalent to Slint, where `Toast { }` sits
|
|
// at AppShell.slint:950: after DragGhost, before TooltipOverlay and
|
|
// ArtPreviewOverlay. Covering the content pane's rounded corners is CORRECT
|
|
// for a window-level overlay — a window-wide scrim covers them too.
|
|
//
|
|
// All three self-gate on their own document, so an unopened overlay is an
|
|
// invisible, non-interactive Item and costs nothing.
|
|
AddToMixtapeModal {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistPickerModal — the "Add to playlist" picker, opened from the
|
|
// queue footer (save the queue as a playlist) and the queue row menu.
|
|
// Same reason it lives out here as its Mixtape sibling: the surface that
|
|
// opened it is often gone by the time the user picks a target, and the
|
|
// queue panel in particular is `visible`-gated.
|
|
PlaylistPickerModal {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistImportModal — the public-playlist importer (Spotify / Apple
|
|
// Music / Tidal / Deezer), opened from the sidebar `...` menu and from the
|
|
// closed-sidebar flyout. It mounts HERE and not in the sidebar by explicit
|
|
// contract (05 §5.8.5): closing it must not cancel an in-flight import, and
|
|
// neither surface that opens it has a lifetime guarantee across a
|
|
// navigation — the importer's own completion arm navigates to the imported
|
|
// playlist while the modal is still up.
|
|
PlaylistImportModal {
|
|
anchors.fill: parent
|
|
}
|
|
MyQbzModals {
|
|
anchors.fill: parent
|
|
}
|
|
// FolderModals — the "New folder" create panel, the full folder editor and
|
|
// their delete confirm (contract D21). Out here for the same reason as its
|
|
// neighbours: it is opened from the SIDEBAR's "..." menu and row menu as
|
|
// well as from the Playlist Manager view, and the sidebar animates to
|
|
// width 0 with clip: true, so a modal parented into it would disappear
|
|
// with it. Self-gates on QbzFolderEdit.createJson / editJson, so while
|
|
// both are closed it is an invisible, non-interactive Item.
|
|
FolderModals {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistEditModal — the ONE playlist editor (rename · description ·
|
|
// offline-only · delete), for both `local:` and Qobuz playlists (contract
|
|
// D20/D21). Out here for the same reason as its neighbours: it is opened
|
|
// from the manager's cards and rows, from the SIDEBAR's row context menu
|
|
// and from the playlist detail header — and its delete arm navigates away
|
|
// from the page that raised it. It replaces the inline Popup that used to
|
|
// live in views/PlaylistView.qml, which could express neither a
|
|
// description nor the offline-only flag. Self-gates on
|
|
// QbzPlaylistEdit.editJson, so while closed it is an invisible,
|
|
// non-interactive Item.
|
|
PlaylistEditModal {
|
|
anchors.fill: parent
|
|
}
|
|
// The hotkeys pair (2026-08-03 hotkeys-port contract §4.4/§4.5, block
|
|
// B3): the read-only cheatsheet (`?` / the HeaderBar menu row) and the
|
|
// editable customize editor. Same declaration-order convention as their
|
|
// neighbours — Slint mounts them with the global modals too
|
|
// (AppShell.slint:908-917, after the other modals, BEFORE the immersive
|
|
// overlay and the toast — so both keep painting above, byte-parity).
|
|
// Both self-gate on QbzHotkeys.cheatsheetOpen / .customizeOpen, so while
|
|
// closed each is an invisible, non-interactive Item.
|
|
KeyboardShortcutsModal {
|
|
anchors.fill: parent
|
|
}
|
|
CustomizeShortcutsModal {
|
|
anchors.fill: parent
|
|
}
|
|
QbzToast {
|
|
anchors.fill: parent
|
|
}
|
|
|
|
// Immersive mode root overlay (2026-08-02 immersive-port contract §5.1):
|
|
// plain Item, LAST-child class like its Slint twin (a Popup would grab
|
|
// the pointer and kill typing in the immersive search field — the
|
|
// cortinilla doctrine, Cortinilla.qml:1-5). Self-gates on
|
|
// QbzImmersive.open, so while closed it is an invisible, non-interactive
|
|
// Item. Sits AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip so
|
|
// those two keep painting above it; covering the content pane's rounded
|
|
// corners is correct for a window-level overlay (:555-563 above).
|
|
ImmersiveView {
|
|
anchors.fill: parent
|
|
}
|
|
|
|
// LAST child = above every surface, exactly like ArtPreviewOverlay.slint's
|
|
// mount in AppShell.slint. Non-interactive, so it never steals the hover
|
|
// that is keeping it open (see the file header).
|
|
ArtPreviewOverlay { }
|
|
|
|
// The shell's ONE hover-tooltip overlay — the port of Slint's
|
|
// TooltipOverlay/SidebarTooltip mechanism, mounted LAST for the reason
|
|
// TooltipOverlay.slint's header gives: "mounted last in AppShell so no
|
|
// neighbour can cover it". Surfaces do not own a popup; they call
|
|
// showRight()/showAbove()/hide() on this instance (see QbzTooltip.qml).
|
|
//
|
|
// WIRED SO FAR: the collapsed sidebar rail (Sidebar.qml). Everything else
|
|
// Slint tooltips is still un-wired — the list is in the handoff notes; each
|
|
// one is a two-line change in its own file, not a change here.
|
|
QbzTooltip {
|
|
id: tooltipOverlay
|
|
anchors.fill: parent
|
|
}
|
|
|
|
// Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) —
|
|
// mounted LAST, mirroring QconnectDevModal.slint's topmost mount in
|
|
// AppShell.slint. Ordering against the tooltip above is a non-issue: this
|
|
// is a Popup parented to Overlay.overlay (the CastPicker pattern), so it
|
|
// renders in the overlay layer above EVERY plain Item in this tree —
|
|
// the tooltip's "LAST child so no neighbour can cover it" invariant is
|
|
// about Items, and no Item was added after it. Self-gates on
|
|
// QbzQConnect.diagOpen, so while closed it is an invisible,
|
|
// non-interactive Item.
|
|
QconnectDevModal { }
|
|
|
|
// The log viewer (Settings > "Share logs", and Developer). Mounted at the
|
|
// AppShell root for the same reason as every other global modal: it is
|
|
// opened FROM Settings but must not live inside its Flickable. Self-gates
|
|
// on the document's `open`, so while closed it is an invisible,
|
|
// non-interactive Item that parses one small JSON string.
|
|
LogViewerModal { }
|
|
}
|