fix(qt): cortinilla geometry - NPB-aware cap, scrim inset, row width, scroll base

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
This commit is contained in:
vicrodh committed 2026-08-03 16:38:00 -06:00
1 parent 5a194d2499
commit 033b838b98
4 files changed
+144 -17

No files matched your search

+6 -2
View File
@@ -494,8 +494,12 @@ Rectangle {
ambientOn: root.ambientOn
}
// Search cortinilla (phase 15): the live-search dropdown overlay, LAST
// child so it renders above every surface (Cortinilla.slint's mount).
// 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
+58 -9
View File
@@ -13,6 +13,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import com.blitzfc.qbz
import "../theme"
@@ -45,10 +46,24 @@ Item {
// The centered header search box width (HeaderBar's responsive rule) —
// the click-through gaps below leave it free.
readonly property int searchBoxWidth: root.width < 960 ? 179 : 256
// 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).
readonly property int scrollCap: root.height - 42 - 6 - 42 - 16 - 12
// 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
@@ -90,9 +105,9 @@ Item {
MouseArea {
visible: root.visible
x: 0
y: 42
y: theme.headerHeight
width: root.width
height: root.height - 42
height: root.height - theme.headerHeight
onClicked: QbzSearch.cortinillaDismiss()
}
// 2) header strip LEFT of the search box.
@@ -101,7 +116,7 @@ Item {
x: 0
y: 0
width: (root.width - root.searchBoxWidth) / 2
height: 42
height: theme.headerHeight
onClicked: QbzSearch.cortinillaDismiss()
}
// 3) header strip RIGHT of the search box.
@@ -110,15 +125,42 @@ Item {
x: (root.width + root.searchBoxWidth) / 2
y: 0
width: root.width - x
height: 42
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: 42 + 6
y: theme.headerHeight + 6
width: root.panelWidth
height: Math.min(bodyColumn.height + 12, root.scrollCap + 12)
radius: theme.radiusSm
@@ -299,6 +341,9 @@ Item {
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
@@ -308,7 +353,11 @@ Item {
// --- One result row (CortinillaResultRow) ------------------------------
component CortRow: Rectangle {
property var row: ({})
width: parent ? parent.width : 0
// 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)
+1 -1
View File
@@ -745,7 +745,7 @@ Rectangle {
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
text: "⏎ " + QbzSession.tr("Enter", QbzSession.trRev)
text: "↵ " + QbzSession.tr("Enter", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
+79 -5
View File
@@ -878,11 +878,14 @@ pub fn move_selection(delta: i32) {
}
fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f64 {
// NOTE: the `6.0` base is WRONG — it mirrors a 6 px padding that is a
// Flickable VIEWPORT margin in the QML, not content space, so every row
// is reported 6 px low (contract row E4 / D-BUG-3). C0 is a pure move:
// the base is corrected to 0.0 in C4, together with its unit test.
let mut y = 6.0f64;
// Base 0.0, NOT 6.0. The panel's 6px top padding is a Flickable VIEWPORT
// margin (`anchors.topMargin`), which shrinks the visible window rather
// than shifting the content, so content-space y starts at zero. With a
// 6.0 base every row was reported 6px low: the scroll-into-view handler
// over-scrolled by 6px going down and clipped the row's top going up.
// The immersive twin, written later against the same layout, already
// starts at 0.0 — this brings the desktop arm in line.
let mut y = 0.0f64;
if let Some(top) = &data.top {
if top.flat_index == flat_index {
return y + 4.0 + 22.0;
@@ -2403,6 +2406,77 @@ mod tests {
assert_eq!(imm_next_selection(&order, 3, -1), 2);
}
#[test]
fn desktop_scroll_arithmetic_starts_at_zero_and_matches_the_layout() {
// The DESKTOP layout (Cortinilla.qml): base 0 — the panel's 6px top
// padding is a Flickable viewport margin, not content space. Then
// top-result block = padTop 4 + label 22 + row 56 = 82, and per
// section padTop 4 + header 24 = 28, 56 per row.
//
// This test exists because the base was 6.0 and nothing caught it:
// the immersive twin had this test, the desktop arm did not.
let mut data = CortinillaData {
query: "q".into(),
top: Some(imm_row("album", "top", "qobuz")),
sections: vec![
CortSection {
title: "Albums".into(),
kind: "album".into(),
has_more: false,
rows: vec![imm_row("album", "1", "qobuz"), imm_row("album", "2", "qobuz")],
},
CortSection {
title: "Artists".into(),
kind: "artist".into(),
has_more: false,
rows: vec![imm_row("artist", "3", "qobuz")],
},
],
};
assign_flat_indices(&mut data);
assert_eq!(
flat_index_content_y(&data, 0),
26.0,
"the top result sits at 4 + 22 — NOT 32, which is what the 6px \
viewport margin used to add"
);
assert_eq!(
flat_index_content_y(&data, 1),
110.0,
"first section row: 82 (top block) + 28 (section head)"
);
assert_eq!(flat_index_content_y(&data, 2), 166.0, "110 + one 56px row");
assert_eq!(
flat_index_content_y(&data, 3),
250.0,
"82 + 28 + 2*56 + the second section's 28"
);
assert_eq!(
flat_index_content_y(&data, 99),
0.0,
"an unknown index falls to the top"
);
}
#[test]
fn desktop_scroll_arithmetic_without_a_top_result() {
// No top-result block: the first section row starts at 28, so the
// 6px base would have been visible here as 34.
let mut data = CortinillaData {
query: "q".into(),
top: None,
sections: vec![CortSection {
title: "Albums".into(),
kind: "album".into(),
has_more: false,
rows: vec![imm_row("album", "1", "qobuz")],
}],
};
assign_flat_indices(&mut data);
// Flat indices still START AT 1 with no top result (C7 convention).
assert_eq!(flat_index_content_y(&data, 1), 28.0);
}
#[test]
fn immersive_scroll_arithmetic_matches_the_no_top_layout() {
// main.rs:10530-10547: per-section padTop 4 + header 24 (= 28), 56