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:
4 files changed
+144
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user