Files
qbz/crates/qbz-qt/qml/immersive/ImmersiveSearchCortinilla.qml
T
vicrodh b38b0b3d57 feat(qt): instant cached cortinilla paint (default on, ui_prefs opt-out)
The reference shows a skeleton while loading and never paints cached rows
first, and its comments present that as the design. It is not: the instant
paint existed during development and was removed because it GLITCHED -
results visibly jumped between the cached paint and the fresh one. The
owner's reading is that the glitch was a Slint rendering problem rather than
something inherent to stale-while-revalidate, so it comes back here as a
reversible experiment: DEFAULT ON, with a ui_prefs opt-out
(cortinilla_instant_paint) that needs no rebuild to flip. The skeleton path
stays fully implemented as both the opt-out target and the cold-miss path.

What is cached is the FINISHED payload, not the API response. Reviving the
existing CAPA A result cache would have left the expensive half on the table:
a cortinilla payload is the response AFTER ranking, truncation, i18n titles,
the top-result pick and - the part no API cache can shortcut - the local
library query and its derived grouping.

qbz-app gains a generic PayloadCache<T>. The store never inspects T, which is
what lets both frontends share it despite their payload types having diverged
(one raw artwork_url vs an art_url/art_path pair). In-memory only: process
lifetime IS the TTL, because a payload embeds local rows and resolved artwork
paths and a persisted entry would resurrect albums the user has deleted.

Four properties are what should keep it from glitching, and each is a real
constraint rather than a hope:

  - Same shape in both paints. The entry is written AFTER the local sections
    are appended, so there is no mid-apply reflow left to see.
  - Equality-gated replacement. CortinillaData derives PartialEq, so an
    unchanged repeat query produces ZERO second paint.
  - The selection survives - the publish path never writes selectedIndex.
  - The scroll survives - C5's contentY latch. Without it the second paint
    yanks the user to the top, which is the glitch by another name.

There are exactly TWO write points, and the placement is load-bearing: after
pass-1 artwork resolution, and again at the pass-2 republish once downloaded
covers land. Writing earlier caches rows with empty art_path, and since the
QML draws art_path only, a hit would paint cover-less rows AND the gate would
fire on every hit - the exact repaint this exists to avoid.

Invalidation: bound per session, dropped on logout, and cleared from
reload_browse - the single chokepoint every local-library mutation passes
through, since there is no library-changed signal to subscribe to.

Also fixes the immersive skeleton's scope bug, which is the twin of the one
C4b fixed on the desktop and which I missed there: a Timer derives from
QtObject, which has no parent property, so the unqualified parent resolved
out of the block entirely - the timer ran forever, pulse never changed, and
the skeleton never pulsed. Found by qmllint, not by reading. It picks up the
reduceMotion tick with the fix.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C11
(section 5.c, rulings R1/R6, R7-10).

Gate: build clean, 258/258 tests + 2 new in qbz-app, both QML audits OK,
qmllint clean on both cortinillas, offscreen smoke 0 complaints + home
published.

cc
2026-08-03 18:09:04 -06:00

371 lines
15 KiB
QML

// ImmersiveSearchCortinilla — the live as-you-type dropdown under the
// immersive header search field (ImmersiveSearchCortinilla.slint:33-366;
// contract 2026-08-02-immersive-port §12 B5). Mounted by ImmersiveView as
// layer 7 (TOPMOST), anchors.fill.
//
// Like the desktop twin (shell/Cortinilla.qml) this is a plain Item, NOT a
// Popup — a Popup grabs the pointer and kills typing in the search field.
// Differences from the desktop twin (D8): GLASS immersive palette (dark
// translucent panel, white-alpha text) instead of the app theme tokens;
// sections ONLY (Artists / Albums / Playlists — NO top-result hero, NO
// "View more", NO track rows); reads QbzImmersive.immSearch* (never
// QbzSearch.cortinilla*); NO idle auto-close (Slint has no such timer) and
// no view-change dismiss — the immersive host never navigates.
//
// Data: QbzImmersive.immSearchJson (search_qt.rs CortinillaData: query /
// top:null / sections with controller flat indices from 1). Keyboard
// selection rides immSearchSelectedIndex + immSearchScrollY (content-space
// top-y of the selected row, 56/24/4 layout — scrolled into view here).
// Row activation -> QbzImmersive.searchRowActivated(flatIndex); the Rust
// controller closes the dropdown, clears the field and ACTS ON PLAYBACK —
// the user STAYS in immersive.
//
// All colors are Slint RRGGBBAA converted to Qt #AARRGGBB.
import QtQuick
import QtQuick.Effects
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// Keyboard accent (the host feeds QbzShell.ambientAccent so the active
// row matches the atmosphere; the Slint passes it in from the host the
// same way, default #3fd9c8).
property color accent: "#3fd9c8"
// Only render (and only consume events) when the search is open AND the
// live query is long enough to have meaningful results — mirrors the
// controller's >= 2 gate so a 1-char query never flashes a half-empty
// panel (ImmersiveSearchCortinilla.slint:165-167). searchInputText is
// the Qt mirror of Slint's search-query.
visible: QbzImmersive.immSearchOpen
&& QbzImmersive.searchInputText.trim().length >= 2
// --- Geometry (all dims passed/computed here — no parent-binding
// loops) ---------------------------------------------------------------
readonly property int panelWidth: 380
// The TOP of the panel (header-band y + field height + a small gap).
readonly property int anchorY: 58
// The immersive player bar's reserved bottom band (bar height + bottom
// inset + a breathing gap) — the panel's bottom edge stays ABOVE it.
readonly property int bottomReserved: 138
// scroll-cap = window-height - anchor-y - bottom-reserved - 12 (the
// 12 = the panel box's top+bottom padding).
readonly property int scrollCap: root.height - anchorY - bottomReserved - 12
// The centered search field's span (same width math as the header
// field): the header band across that span stays un-scrimmed so the
// field keeps typing/focus.
readonly property int fieldWidth: Math.min(root.width - 48, 420)
readonly property int fieldLeft: (root.width - root.fieldWidth) / 2
// The header band's vertical extent (field 36px + the 6px gap).
readonly property int bandBottom: anchorY - 6
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzImmersive.immSearchJson)
} catch (e) {
return ({})
}
}
readonly property var sections: doc.sections || []
// Keyboard scroll-into-view (the desktop Cortinilla.qml:63-71 idiom):
// the controller publishes the selected row's content-space top-y;
// nudge the Flickable so that row is visible. 56px is the row height.
Connections {
target: QbzImmersive
function onImmSearchScrollYChanged() {
var y = QbzImmersive.immSearchScrollY
if (y < bodyFlick.contentY) {
bodyFlick.contentY = Math.max(0, y)
} else if (y + 56 > bodyFlick.contentY + bodyFlick.height) {
bodyFlick.contentY = y + 56 - bodyFlick.height
}
}
}
// --- Click-outside scrims (ANY click outside the panel dismisses) ----
// Transparent MouseAreas tile the whole window EXCEPT the panel
// (declared later -> on top, keeps its own clicks) and the header band
// across the search field's horizontal span.
// 1) everything BELOW the header band (panels, player bar region)
MouseArea {
visible: root.visible
x: 0
y: root.bandBottom
width: root.width
height: root.height - y
onClicked: QbzImmersive.dismissSearch()
}
// 2) header band, LEFT of the search field
MouseArea {
visible: root.visible
x: 0
y: 0
width: root.fieldLeft
height: root.bandBottom
onClicked: QbzImmersive.dismissSearch()
}
// 3) header band, RIGHT of the search field
MouseArea {
visible: root.visible
x: root.fieldLeft + root.fieldWidth
y: 0
width: root.width - x
height: root.bandBottom
onClicked: QbzImmersive.dismissSearch()
}
// --- The panel ---------------------------------------------------------
// Anchored under the centered search field; its total height is capped
// so the bottom edge stays ABOVE the player bar. A tall result set
// scrolls inside the Flickable; a short one collapses.
// Shadow: 32 / #00000066 / offset-y 8 (the ImmersiveView shadow idiom).
Rectangle {
visible: root.visible
x: (root.width - root.panelWidth) / 2
y: root.anchorY + 8
width: root.panelWidth
height: Math.min(bodyColumn.height + 12, root.scrollCap + 12)
radius: 16
color: "#66000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: 0.5 // 32/64
}
}
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
Rectangle {
id: panel
visible: root.visible
x: (root.width - root.panelWidth) / 2
y: root.anchorY
width: root.panelWidth
height: Math.min(bodyColumn.height + 12, root.scrollCap + 12)
radius: 16
color: "#94000000"
border.width: 1
border.color: "#1affffff"
clip: true
Flickable {
id: bodyFlick
anchors.fill: parent
anchors.topMargin: 6
anchors.bottomMargin: 6
contentWidth: width
contentHeight: bodyColumn.height
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: bodyColumn
width: bodyFlick.width
// --- Loading skeleton (4 rows, glass-white boxes like
// ImmersiveSkeletonRow). Skeleton-only HERE: the instant
// cached paint landed on the DESKTOP cortinilla only. The
// immersive dropdown is a different surface with a much
// smaller payload, so it was left out deliberately rather
// than forgotten. ------------------------------------------
Column {
id: immSkeleton
visible: QbzImmersive.immSearchLoading
width: parent.width
topPadding: 4
property bool pulse: false
// Referenced BY ID, not through `parent`. A Timer derives
// from QtObject, which has no `parent` property, so an
// unqualified `parent` here resolved up the scope chain
// and out of this block entirely — the timer ran forever
// and `pulse` never changed, so the skeleton never
// pulsed. Same fix as the desktop twin.
Timer {
interval: QbzShell.reduceMotion ? 2000 : 700
running: immSkeleton.visible
repeat: true
onTriggered: immSkeleton.pulse = !immSkeleton.pulse
}
Repeater {
model: 4
delegate: Item {
width: bodyColumn.width
height: 56
Rectangle {
x: 10
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: 4
color: "#ffffff"
opacity: immSkeleton.pulse ? 0.32 : 0.08
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Column {
x: 62
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
width: 150
height: 11
radius: 3
color: "#ffffff"
opacity: immSkeleton.pulse ? 0.32 : 0.08
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Rectangle {
width: 96
height: 9
radius: 3
color: "#ffffff"
opacity: immSkeleton.pulse ? 0.32 : 0.08
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
}
}
}
}
// --- Loaded content ----------------------------------------
Column {
visible: !QbzImmersive.immSearchLoading
width: parent.width
// No results: explicit empty-state line.
Item {
visible: root.sections.length === 0
width: parent.width
height: 28
Text {
x: 14
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("No results for", QbzSession.trRev)
+ " “" + (root.doc.query || "") + "”"
color: "#80ffffff"
font.pixelSize: 12
}
}
// Result sections (Artists / Albums / Playlists, then the
// local-album section). NO "View more" — immersive has
// no results page to navigate to.
Repeater {
model: root.sections
delegate: Column {
required property var modelData
width: bodyColumn.width
leftPadding: 6
rightPadding: 6
topPadding: 4
// Section header — title only, 24px, 11px
// semibold #ffffff80.
Item {
width: parent.width
height: 24
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: modelData.title
color: "#80ffffff"
font.pixelSize: 11
font.weight: 600
}
}
Repeater {
model: modelData.rows
delegate: ImmRow { row: modelData }
}
}
}
}
}
}
QbzScrollBar {
target: bodyFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// --- One result row (ImmersiveResultRow :30-102) -----------------------
// 56px, radius 8; keyboard-active #ffffff26 + the 3px accent bar, plain
// hover #ffffff14. Hover is PURELY VISUAL — it deliberately does NOT
// write the selection, so a stray hover can never hijack the keyboard
// selection that Enter activates (same lesson as the main cortinilla).
component ImmRow: Rectangle {
property var row: ({})
width: parent ? parent.width : 0
height: 56
radius: 8
readonly property bool active: QbzImmersive.immSearchSelectedIndex === (row.flatIndex ?? -2)
color: active ? "#26ffffff"
: (rowArea.containsMouse ? "#14ffffff" : "transparent")
// Accent bar on the keyboard-active row (distinct from plain hover).
Rectangle {
visible: parent.active
x: 0
y: 4
width: 3
height: parent.height - 8
radius: 2
color: root.accent
}
// 40x40 thumb — circle for artists, tile otherwise.
Rectangle {
x: 10
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: row.kind === "artist" ? 20 : 4
color: "#14ffffff"
clip: true
RoundedImage {
anchors.fill: parent
source: row.artPath || ""
radius: row.kind === "artist" ? 20 : 4
}
}
Column {
x: 62
width: parent.width - 62 - 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.title || ""
color: "#ccffffff"
font.pixelSize: 14
font.weight: 500
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.subtitle || ""
color: "#80ffffff"
font.pixelSize: 12
elide: Text.ElideRight
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzImmersive.searchRowActivated(row.flatIndex)
}
}
}