Files
qbz/crates/qbz-qt/qml/controls/QbzSelect.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

512 lines
24 KiB
QML

// QbzSelect (primitives/QbzSelect.slint, standard size) — extracted from
// SettingsView.qml in phase 19 so every settings panel shares the one
// replica (the ui-control-alignment-standard searchable dropdown: the
// filter box shows past ~8 options via `searchable: true`).
// 34px bordered control (elevated, hover -> surface-hover) + a Popup
// list (surface-main r8 border-muted, 32px rows, capped at 360px with
// scroll, optional 42px filter box, 24px group-header rows, "BP" badge
// / speaker glyph on device options). `options` entries are either
// plain strings or {label, bp, group} objects (the device list).
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
Rectangle {
// Explicit density opt-in; desktop geometry remains the default.
property bool kioskHost: false
property var options: []
property int currentIndex: 0
property int menuWidth: 240
property int popupWidth: 0
// The default keeps the standard dropdown below the field. Flyouts near a
// window edge may opt into a lateral list without changing every other
// QbzSelect instance. Supported values: "below" and "left".
property string popupPlacement: "below"
property bool searchable: false
// Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the
// standard Settings size; TOOLBARS opt in with `sm: true` — 30px tall, r6,
// NO outline (it matches the favorites toolbar buttons), 12px label and a
// 14px chevron, and 40px narrower because toolbar selects were needlessly
// wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299).
// Optional sort selector: reselecting an option still emits selected.
property string sortDirection: ""
property bool sm: false
// Optional leading glyph before the label (e.g. a sort field's
// arrow-down-up). Empty = none, and the positioner ignores the hidden
// slot so every existing select is byte-identical.
property string leadingIcon: ""
// When set, the COLLAPSED control always shows this fixed label instead of
// the current option — a placeholder select whose active choice lives in
// the outline + tooltip, not the field (the Sort control). The open list
// still highlights the current option.
property string placeholderText: ""
// "This select holds a non-default choice": draw an accent outline at rest.
// Only the border changes — the fill stays the ambient-aware token, so the
// control still goes translucent under the dynamic background.
property bool outlineActive: false
// Optional shared-tooltip host (a QbzTooltip) + text, shown on hover. Used
// to name the active sort when it differs from the tab default.
property var tooltipHost: null
property string tooltipText: ""
property string tooltipKey: ""
signal selected(int index)
QbzTheme { id: theme }
id: selectRoot
width: sm ? Math.max(0, menuWidth - 40) : menuWidth
height: kioskHost ? 44 : (sm ? 30 : 34)
radius: sm ? 6 : theme.radiusSm
// sm (toolbar) selects show NO focus ring: it lingered after a pick and
// read as "still modified", so the outline could never fully clear on a
// return to default. There, only outlineActive draws a border, and it is
// gone the instant the choice is the default. Settings-size selects keep
// the keyboard focus ring.
border.width: (selectRoot.activeFocus && !sm) ? 2
: (selectRoot.outlineActive ? 1 : (sm ? 0 : 1))
border.color: (selectRoot.activeFocus && !sm) || selectRoot.outlineActive
? theme.accent : theme.borderSubtle
// Resting fill goes translucent under the dynamic background so the field
// shows through the control (QbzSelect.slint:110-116). Hover keeps its own
// token, which is already translucent.
color: selArea.containsMouse && enabled
? theme.elevatedHoverFill
: theme.elevatedFill
opacity: enabled ? 1.0 : 0.4
activeFocusOnTab: enabled
Accessible.role: Accessible.ComboBox
Accessible.name: currentIndex >= 0 && currentIndex < options.length
? optLabel(currentIndex) : ""
Accessible.onPressAction: selectRoot.openPopup()
Keys.onPressed: function (event) {
if (!selectRoot.enabled || event.isAutoRepeat)
return
if (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter) {
if (popup.opened)
popup.close()
else
selectRoot.openPopup()
} else if (event.key === Qt.Key_Up) {
var previous = selectRoot.adjacentEnabled(selectRoot.currentIndex, -1)
if (previous >= 0) selectRoot.selected(previous)
} else if (event.key === Qt.Key_Down) {
var next = selectRoot.adjacentEnabled(selectRoot.currentIndex, 1)
if (next >= 0) selectRoot.selected(next)
} else {
return
}
// Do not let Space reach the global play/pause binding.
event.accepted = true
}
// `width`, NOT `menuWidth`: QbzSelect.slint:89-91 resolves the open list as
// `max(popup-width, EFF-WIDTH)`, and eff-width is `menu-width - 40px` at
// `sm` (:88) — the same value it uses for the collapsed control. Reading the
// RAW menuWidth here made every toolbar dropdown's PANEL 40px wider than the
// reference's (MyQBZ sort 180 vs 140, kind filter 200 vs 160, detail sort
// 170 vs 130, and the Local Library toolbar / LabelReleases selects with it).
// The three `popupWidth` callers (AudioSettings 480, CastPicker 260,
// LyricsControlsFlyout 178) are all non-`sm`, where width === menuWidth and
// popupWidth wins anyway, so none of them moves.
readonly property int listWidth: Math.max(popupWidth, selectRoot.width)
readonly property int rowHeight: kioskHost ? 44 : 32
readonly property int headerHeight: 24
readonly property int searchHeight: searchable ? (kioskHost ? 44 : 42) : 0
readonly property int maxListHeight: kioskHost ? Math.max(44, Math.min(360, (selectRoot.Window.window ? selectRoot.Window.window.height : 480) - searchHeight - 100)) : 360
property string filter: ""
function optLabel(i) {
const o = options[i]
return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "")
}
function optHasBadges() {
return options.length > 0 && typeof options[0] !== "string"
&& options[0] && options[0].bp !== undefined
}
function optBp(i) {
const o = options[i]
return o && o.bp === true
}
function optGroup(i) {
const o = options[i]
return (o && o.group !== undefined) ? o.group : ""
}
function optDetail(i) {
const o = options[i]
return (o && o.detail !== undefined) ? o.detail : ""
}
function optEnabled(i) {
const o = options[i]
return !(o && o.enabled !== undefined) || o.enabled === true
}
function adjacentEnabled(start, delta) {
for (var i = start + delta; i >= 0 && i < options.length; i += delta) {
if (optEnabled(i)) return i
}
return -1
}
function openPopup() {
if (!selectRoot.enabled || selectRoot.options.length === 0)
return
selectRoot.filter = ""
listContent.currentIndex = selectRoot.currentIndex
// A field tooltip must not sit over its own open list.
if (selectRoot.tooltipHost)
selectRoot.tooltipHost.hide(selectRoot.tooltipKey !== "" ? selectRoot.tooltipKey : "qbz-select")
popup.open()
if (selectRoot.searchable)
searchInput.forceActiveFocus()
}
function optionMatches(i) {
return i >= 0 && i < options.length
&& optEnabled(i)
&& (filter === ""
|| optLabel(i).toLowerCase().indexOf(filter.toLowerCase()) >= 0)
}
function moveSearchHighlight(delta) {
let i = listContent.currentIndex
for (let n = 0; n < options.length; n++) {
i += delta
if (i < 0 || i >= options.length)
break
if (optionMatches(i)) {
listContent.currentIndex = i
listContent.positionViewAtIndex(i, ListView.Contain)
return
}
}
}
function acceptSearchHighlight() {
const i = listContent.currentIndex
if (!optionMatches(i))
return
popup.close()
selectRoot.forceActiveFocus()
selectRoot.selected(i)
}
Row {
anchors.fill: parent
anchors.leftMargin: selectRoot.sm ? 10 : 12
anchors.rightMargin: selectRoot.sm ? 8 : 10
spacing: 8
// Leading glyph (opt-in). A Row ignores a hidden child entirely — no
// slot, no spacing — so selects without a leadingIcon are unchanged.
QbzIcon {
id: leadingGlyph
visible: selectRoot.leadingIcon !== ""
name: selectRoot.leadingIcon
width: visible ? (selectRoot.sm ? 14 : 16) : 0
height: selectRoot.sm ? 14 : 16
anchors.verticalCenter: parent.verticalCenter
tintName: selectRoot.outlineActive ? "accent" : "secondary"
}
Text {
width: parent.width - (leadingGlyph.visible ? leadingGlyph.width + 8 : 0)
- 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
height: parent.height
// A placeholder select shows its fixed label; otherwise the current
// option. The active choice of a placeholder select is surfaced by
// the outline + tooltip, not here.
text: selectRoot.placeholderText !== ""
? selectRoot.placeholderText
: (selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : "")
color: theme.textPrimary
font.pixelSize: selectRoot.kioskHost ? (selectRoot.sm ? 12 : theme.fontBody) * 1.2 : (selectRoot.sm ? 12 : theme.fontBody)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Trailing BP badge / speaker glyph for the CURRENT device option.
Item {
id: badgeSlot
visible: selectRoot.optHasBadges()
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& selectRoot.optBp(selectRoot.currentIndex)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: selectRoot.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& !selectRoot.optBp(selectRoot.currentIndex)
name: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
QbzIcon {
// A placeholder select keeps a NEUTRAL chevron collapsed — its
// direction is not surfaced in the field (it lives in the tooltip
// and the open list), so the field says nothing about the active
// sort. Non-placeholder selects keep the direction chevron.
name: (selectRoot.sortDirection !== "" && selectRoot.placeholderText === "")
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
: (selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down")
width: selectRoot.sm ? 14 : 16
height: selectRoot.sm ? 14 : 16
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
}
MouseArea {
id: selArea
anchors.fill: parent
hoverEnabled: true
cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (selectRoot.enabled) {
selectRoot.forceActiveFocus()
selectRoot.openPopup()
}
}
onContainsMouseChanged: {
if (selectRoot.tooltipHost && selectRoot.tooltipText !== "")
selectRoot.tooltipHost.hover(containsMouse && !popup.opened, selectRoot,
selectRoot.tooltipKey !== "" ? selectRoot.tooltipKey : "qbz-select",
selectRoot.tooltipText)
}
}
Popup {
id: popup
parent: selectRoot
// Standard selects grow down and left-align their right edge. A
// right-edge flyout may place the list beside the field instead; its
// bottom edge stays aligned so it cannot fall into the player bar.
x: selectRoot.popupPlacement === "left"
? -selectRoot.listWidth - 4
: selectRoot.width - selectRoot.listWidth
y: selectRoot.popupPlacement === "left"
? selectRoot.height - popup.height
: selectRoot.height + 4
width: selectRoot.listWidth
height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10
padding: 0
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Item {
implicitWidth: selectRoot.listWidth
implicitHeight: popup.height
// Filter box (searchable lists only).
Rectangle {
id: searchBox
visible: selectRoot.searchable
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: selectRoot.searchHeight
color: "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.bottomMargin: 6
spacing: 8
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - 14 - 8
height: parent.height
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: selectRoot.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
verticalAlignment: Text.AlignVCenter
clip: true
text: selectRoot.filter
onTextChanged: {
selectRoot.filter = text
if (!listContent)
return
for (let i = 0; i < selectRoot.options.length; i++) {
if (selectRoot.optionMatches(i)) {
listContent.currentIndex = i
return
}
}
listContent.currentIndex = -1
}
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Down)
selectRoot.moveSearchHighlight(1)
else if (event.key === Qt.Key_Up)
selectRoot.moveSearchHighlight(-1)
else if (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)
selectRoot.acceptSearchHighlight()
else
return
event.accepted = true
}
}
Text {
visible: searchInput.text === ""
anchors.fill: parent
text: QbzSession.tr("Search…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: selectRoot.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
verticalAlignment: Text.AlignVCenter
}
}
}
}
ListView {
id: listContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchBox.bottom
anchors.bottom: parent.bottom
anchors.topMargin: 5
anchors.bottomMargin: 5
clip: true
boundsBehavior: Flickable.StopAtBounds
model: selectRoot.options
delegate: Column {
id: optRow
required property int index
required property var modelData
width: listContent.width
readonly property string label: selectRoot.optLabel(index)
readonly property bool shown: selectRoot.filter === ""
|| label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0
// Group-header row (ALSA device sections).
Rectangle {
visible: optRow.shown && selectRoot.optGroup(optRow.index) !== ""
width: parent.width
height: visible ? selectRoot.headerHeight : 0
color: "transparent"
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 4
height: parent.height - 4
text: selectRoot.optGroup(optRow.index)
color: theme.textMuted
font.pixelSize: selectRoot.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Rectangle {
visible: optRow.shown
width: parent.width
height: visible
? (selectRoot.optDetail(optRow.index) !== "" ? 50 : selectRoot.rowHeight)
: 0
color: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
Column {
width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0)
height: parent.height
anchors.verticalCenter: parent.verticalCenter
Text {
width: parent.width
height: selectRoot.optDetail(optRow.index) !== "" ? 26 : parent.height
text: optRow.label
color: !selectRoot.optEnabled(optRow.index)
? theme.textMuted
: (optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary)
font.pixelSize: selectRoot.kioskHost ? (selectRoot.sm ? 12 : theme.fontBody) * 1.2 : (selectRoot.sm ? 12 : theme.fontBody)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
visible: selectRoot.optDetail(optRow.index) !== ""
width: parent.width
height: visible ? 18 : 0
text: selectRoot.optDetail(optRow.index)
color: theme.textMuted
font.pixelSize: selectRoot.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Item {
id: rowBadge
visible: selectRoot.optHasBadges()
|| (selectRoot.sortDirection !== "" && optRow.index === selectRoot.currentIndex)
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optBp(optRow.index)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: selectRoot.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: !selectRoot.optBp(optRow.index)
name: selectRoot.sortDirection !== ""
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
}
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
enabled: selectRoot.optEnabled(optRow.index)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
popup.close()
selectRoot.selected(optRow.index)
}
}
}
}
}
}
}
}