Five geometry defects, all latent while the Qobuz-only panel held at most 14
rows and never scrolled, and all reachable the moment the local sections land.
- The height cap hardcoded 42 for the now-playing bar in every mode. The
bar is 42 only in NPB mode 2; everywhere else it is 112, so the panel
could run up to 70px into it. The cap is now mode-aware, reading the same
theme tokens AppShell uses. The header height literals in the scrims and
the panel anchor became theme.headerHeight at the same time.
- The click-outside scrims left a gap for the search field computed without
the 60px the field gives up when the section nav sits in the header, so
with nav-in-header there was a 30px dead strip on each side of the field
that neither dismissed the panel nor reached the input. The scrim now
tracks HeaderBar's own width rule exactly.
- Rows were 12px wider than their block: a QML Column's padding does not
size its children, and parent.width ignored it. clip hid the overflow,
but the highlight ran flush to the right edge while staying inset 6px on
the left.
- flat_index_content_y started at 6.0, mirroring the panel's 6px top
padding - which is a Flickable VIEWPORT margin, not content space. Every
row was reported 6px low, so scroll-into-view over-scrolled going down
and clipped the row's top going up. The immersive twin, written later
against the same layout, already started at 0.0.
Two unit tests now pin the arithmetic. There were none for the desktop
arm, which is why the 6.0 survived; the immersive arm had its equivalent
and was correct.
- The scrollbar used the primitive's default 14px gutter where the
reference overrides it to 10.
Plus the "Enter" affordance glyph (U+23CE + one space -> U+21B5 + two, as in
the reference) and the panel drop shadow, which reuses the immersive twin's
idiom - a mirror rectangle blurred by MultiEffect, skipped when there are no
shaders - rather than introducing a second shadow mechanism. NavFlyout still
has none and says so; that is its own parity pass.
AppShell's comment claiming the cortinilla is the "LAST child so it renders
above every surface" is corrected: four siblings are declared after it, and
that is fine.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4
(D-BUG-3/4/5/7, rows A5/A7/A10/A12/A13/A19/A28/A2, UT-1).
Gate: build clean, 258/258 tests (2 new), both QML audits OK, offscreen
smoke 0 complaints + home published.
cc
424 lines
18 KiB
QML
424 lines
18 KiB
QML
// Search "cortinilla" — the live as-you-type dropdown under the header
|
|
// search box (search/Cortinilla.slint, phase 15). A plain Item overlay
|
|
// mounted as a LAST child of AppShell (NOT a Popup — a Popup grabs the
|
|
// pointer and would kill typing in the header field, the exact reason the
|
|
// Slint uses a plain Rectangle too).
|
|
//
|
|
// Payload: QbzSearch.cortinillaJson (search_qt.rs CortinillaData: query /
|
|
// top / sections with controller flat indices). Keyboard selection rides
|
|
// selectedIndex + cortinillaScrollY (content-space top-y of the selected
|
|
// row — scrolled into view here). Row activation bubbles to the
|
|
// QbzSearch.cortinilla* invokables; the field clear goes through
|
|
// root.header.clearSearch().
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Effects
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Item {
|
|
id: root
|
|
anchors.fill: parent
|
|
// Open AND the live query long enough to have meaningful results — the
|
|
// same >= 2 gate the controller applies, so a 1-char query never flashes
|
|
// a half-empty panel (Cortinilla.slint:190-191). cortinillaQuery is
|
|
// published synchronously per keystroke, so this tracks what the user
|
|
// has typed rather than what last finished loading.
|
|
visible: QbzSearch.cortinillaOpen && QbzSearch.cortinillaQuery.length >= 2
|
|
|
|
// The host HeaderBar (for clearSearch on row activation).
|
|
property var headerBar: null
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property var doc: parseDoc()
|
|
function parseDoc() {
|
|
try {
|
|
return JSON.parse(QbzSearch.cortinillaJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var sections: doc.sections || []
|
|
readonly property bool hasTop: doc.top !== undefined && doc.top !== null && (doc.top.id || "") !== ""
|
|
readonly property bool empty: !hasTop && sections.length === 0 && !QbzSearch.cortinillaLoading
|
|
|
|
// The centered header search box width (HeaderBar's responsive rule) —
|
|
// the click-through gaps below leave it free.
|
|
// Must track HeaderBar's own rule EXACTLY (HeaderBar.qml:642), including
|
|
// the 60px the field gives up when the section nav sits in the header —
|
|
// the scrims below leave this span free, and a span 60px too wide leaves
|
|
// a 30px dead strip on each side of the field that neither dismisses the
|
|
// panel nor reaches the input.
|
|
readonly property int searchBoxWidth:
|
|
(root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60)
|
|
readonly property int panelWidth: 320
|
|
// Cap so the panel NEVER covers the now-playing bar
|
|
// (Cortinilla.slint:263-275). The bar term is NPB-MODE AWARE: mode 2 is
|
|
// the small bar, every other mode is the large one. A hardcoded 42 here
|
|
// let the panel run up to 70px into a 112px bar — latent while the
|
|
// Qobuz-only panel held <= 14 rows, reachable the moment the local
|
|
// sections make it scroll.
|
|
readonly property int npbHeight:
|
|
QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
|
|
readonly property int scrollCap:
|
|
root.height - theme.headerHeight - 6 - root.npbHeight - 16 - 12
|
|
|
|
// --- Idle auto-close (4.5s without hover/activity, Cortinilla.slint) --
|
|
property bool panelHovered: false
|
|
Timer {
|
|
id: idleClose
|
|
interval: 4500
|
|
repeat: false
|
|
running: root.visible && !root.panelHovered
|
|
onTriggered: QbzSearch.cortinillaDismiss()
|
|
}
|
|
Connections {
|
|
target: QbzSearch
|
|
// Activity restarts the countdown (keystroke or arrow move). The
|
|
// keystroke probe is the QUERY, not the payload: the reference
|
|
// restarts per keystroke, and now that the debounce lives in Rust a
|
|
// burst of typing publishes one payload but many queries.
|
|
function onCortinillaQueryChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
|
|
function onCortinillaSelectedIndexChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
|
|
// Keyboard scroll-into-view: the controller publishes the selected
|
|
// row's content-space top-y; nudge the Flickable so it is visible.
|
|
function onCortinillaScrollYChanged() {
|
|
var y = QbzSearch.cortinillaScrollY
|
|
if (y < bodyFlick.contentY) {
|
|
bodyFlick.contentY = Math.max(0, y)
|
|
} else if (y + 56 > bodyFlick.contentY + bodyFlick.height) {
|
|
bodyFlick.contentY = y + 56 - bodyFlick.height
|
|
}
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: QbzShell
|
|
// A page change dismisses (AppShell's tracked-view close hook).
|
|
function onCurrentViewChanged() { QbzSearch.cortinillaDismiss() }
|
|
}
|
|
|
|
// --- Click-outside scrims (any click outside the panel dismisses) ----
|
|
// 1) everything BELOW the header.
|
|
MouseArea {
|
|
visible: root.visible
|
|
x: 0
|
|
y: theme.headerHeight
|
|
width: root.width
|
|
height: root.height - theme.headerHeight
|
|
onClicked: QbzSearch.cortinillaDismiss()
|
|
}
|
|
// 2) header strip LEFT of the search box.
|
|
MouseArea {
|
|
visible: root.visible
|
|
x: 0
|
|
y: 0
|
|
width: (root.width - root.searchBoxWidth) / 2
|
|
height: theme.headerHeight
|
|
onClicked: QbzSearch.cortinillaDismiss()
|
|
}
|
|
// 3) header strip RIGHT of the search box.
|
|
MouseArea {
|
|
visible: root.visible
|
|
x: (root.width + root.searchBoxWidth) / 2
|
|
y: 0
|
|
width: root.width - x
|
|
height: theme.headerHeight
|
|
onClicked: QbzSearch.cortinillaDismiss()
|
|
}
|
|
|
|
// --- The panel ----------------------------------------------------------
|
|
// Drop shadow (Cortinilla.slint:314-316: blur 24, offset-y 6,
|
|
// Theme.card-shadow). Same idiom as the immersive twin
|
|
// (ImmersiveSearchCortinilla.qml): a mirror Rectangle behind the panel,
|
|
// blurred by MultiEffect, skipped when there are no shaders. This port
|
|
// runs on the GPU (OpenGL RHI, measured), but the offscreen smoke forces
|
|
// software, so the guard is real rather than defensive.
|
|
//
|
|
// NOTE: NavFlyout.qml still has no shadow and says so; adding one there
|
|
// is its own parity pass, not this contract's. The two dropdowns will
|
|
// look slightly different until that lands.
|
|
readonly property bool noShaders: GraphicsInfo.api === GraphicsInfo.Software
|
|
|| GraphicsInfo.api === GraphicsInfo.Null
|
|
Rectangle {
|
|
visible: root.visible && !root.noShaders
|
|
x: panel.x
|
|
y: panel.y + 6
|
|
width: panel.width
|
|
height: panel.height
|
|
radius: panel.radius
|
|
color: theme.cardShadow
|
|
layer.enabled: true
|
|
layer.effect: MultiEffect {
|
|
blurEnabled: true
|
|
blurMax: 48
|
|
blur: 0.5 // 24/48
|
|
}
|
|
}
|
|
Rectangle {
|
|
id: panel
|
|
x: (root.width - root.panelWidth) / 2
|
|
y: theme.headerHeight + 6
|
|
width: root.panelWidth
|
|
height: Math.min(bodyColumn.height + 12, root.scrollCap + 12)
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceMain
|
|
border.width: 1
|
|
border.color: theme.borderMuted
|
|
clip: true
|
|
|
|
MouseArea {
|
|
// Hover surface for the idle-close pause; clicks stay inside.
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onContainsMouseChanged: root.panelHovered = containsMouse
|
|
}
|
|
|
|
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 (no cached instant-paint — one clean
|
|
// apply, Cortinilla.slint) -------------------------------
|
|
Column {
|
|
visible: QbzSearch.cortinillaLoading
|
|
width: parent.width
|
|
topPadding: 4
|
|
property bool pulse: false
|
|
Timer {
|
|
interval: 700
|
|
running: parent.visible
|
|
repeat: true
|
|
onTriggered: parent.pulse = !parent.pulse
|
|
}
|
|
Repeater {
|
|
model: 5
|
|
delegate: Item {
|
|
width: bodyColumn.width
|
|
height: 56
|
|
Rectangle {
|
|
x: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 40
|
|
height: 40
|
|
radius: 4
|
|
color: theme.surfaceElevated
|
|
opacity: parent.parent.pulse ? 0.48 : 0.16
|
|
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: theme.surfaceElevated
|
|
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
|
|
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
|
|
}
|
|
Rectangle {
|
|
width: 96
|
|
height: 9
|
|
radius: 3
|
|
color: theme.surfaceElevated
|
|
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
|
|
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Loaded content ----------------------------------------
|
|
Column {
|
|
visible: !QbzSearch.cortinillaLoading
|
|
width: parent.width
|
|
|
|
// No results.
|
|
Item {
|
|
visible: root.empty
|
|
width: parent.width
|
|
height: 28
|
|
Text {
|
|
x: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// The LIVE query, matching the reference
|
|
// (Cortinilla.slint:373 reads
|
|
// SearchState.cortinilla-query, not the payload's
|
|
// echo) — otherwise the empty state quotes the
|
|
// previous query while the user reads it.
|
|
text: QbzSession.tr("No results for", QbzSession.trRev) + " “" + QbzSearch.cortinillaQuery + "”"
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
}
|
|
|
|
// Top result (flat-index 0).
|
|
Column {
|
|
visible: root.hasTop
|
|
width: parent.width
|
|
leftPadding: 6
|
|
rightPadding: 6
|
|
topPadding: 4
|
|
Text {
|
|
x: 10
|
|
height: 22
|
|
text: QbzSession.tr("Top result", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
CortRow { row: root.hasTop ? root.doc.top : ({}) }
|
|
}
|
|
|
|
// Sections.
|
|
Repeater {
|
|
model: root.sections
|
|
delegate: Column {
|
|
required property var modelData
|
|
width: bodyColumn.width
|
|
leftPadding: 6
|
|
rightPadding: 6
|
|
topPadding: 4
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 24
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.title
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
Text {
|
|
visible: modelData.hasMore === true
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("View more", QbzSession.trRev)
|
|
color: vmArea.containsMouse ? theme.textPrimary : theme.accent
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightMedium
|
|
MouseArea {
|
|
id: vmArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (root.headerBar) root.headerBar.clearSearch()
|
|
QbzSearch.cortinillaViewMore(modelData.kind)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Repeater {
|
|
model: modelData.rows
|
|
delegate: CortRow { row: modelData }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzScrollBar {
|
|
target: bodyFlick
|
|
// 10px gutter, not the primitive's default 14 — the reference
|
|
// overrides it the same way (Cortinilla.slint:471).
|
|
width: 10
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
}
|
|
}
|
|
|
|
// --- One result row (CortinillaResultRow) ------------------------------
|
|
component CortRow: Rectangle {
|
|
property var row: ({})
|
|
// A QML Column's padding does NOT size its children, so `parent.width`
|
|
// made every row 12px wider than its block. clip hid the overflow, but
|
|
// the highlight ran flush to the right edge while staying inset 6px on
|
|
// the left — asymmetric. Subtract the padding explicitly.
|
|
width: parent ? parent.width - (parent.leftPadding || 0) - (parent.rightPadding || 0) : 0
|
|
height: 56
|
|
radius: theme.radiusSm
|
|
readonly property bool active: QbzSearch.cortinillaSelectedIndex === (row.flatIndex ?? -2)
|
|
color: (active || rowArea.containsMouse) ? theme.surfaceHover : "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: theme.accent
|
|
}
|
|
Rectangle {
|
|
x: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 40
|
|
height: 40
|
|
// Artists read better as a circle; everything else is a tile.
|
|
radius: row.kind === "artist" ? 20 : 4
|
|
color: theme.surfaceElevated
|
|
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: theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: row.subtitle || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (root.headerBar) root.headerBar.clearSearch()
|
|
QbzSearch.cortinillaRowClicked(row.flatIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|