Files
qbz/crates/qbz-qt/qml/controls/QbzLineEdit.qml
T
vicrodh 81c43a114e fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex.

"primary" is the legacy alias of literal #ffffff, not Theme.text-primary,
so every control passing it drew a white glyph. On the eleven light
themes that is white-on-light -- the header back/forward chevrons, the
sidebar toggle and the carousel arrows the owner marked. They now pass
the theme-following names, which icon_tint_qt bakes at runtime.

The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that
predate the runtime tinter collapse into those names, and the qrc floor
underneath them becomes polarity-aware: dir_for() returns "" whenever
the cache dir is unavailable, a bake fails or the theme document has not
been published, and that path used to resolve textPrimary straight back
to the white bake -- reproducing the bug it was fixing, app-wide, in the
degraded state.

On accent fills the fix is a deviation from Slint, approved by the owner
after measurement. A fixed white glyph on Theme.accent falls under 2.6:1
on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and
wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast.
Slint hardcodes #ffffff there (CircleAction.slint:72 and three others),
so the bug is upstream. accentText alone is not the answer either: it is
picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn,
worse than the black it replaces. So QbzTheme picks the best-contrast
tint by real sRGB luminance against the live accent, and the labels
beside those glyphs use its colour twin -- BrowseGenreButton and
LibraryView were drawing a white glyph next to an accentText label
inside the same pill.

Rounds out the two parity items the same files owe: QbzNavButton gains
the ambient arm from Carousel.slint (wired at the six carousel sites,
not left dead), and QbzCircleAction rounds its glyph to whole pixels the
way CircleAction.slint does.

cc
2026-07-29 10:30:43 -06:00

216 lines
8.3 KiB
QML

// QbzLineEdit — THE shared text input. Two arms over ONE control, so there
// is never a second search-box implementation in the tree:
//
// * PLAIN (default, unchanged): 240x34 elevated r8 bordered input that
// commits on Enter AND on focus loss (the Tauri onchange semantics —
// PlaybackSettings.slint). Settings panels use this arm.
//
// * SEARCH (`searchMode`): leading magnifier, LIVE `edited(text)` and a
// trailing X. With `expandable` it is primitives/ExpandableSearch.slint
// 1:1 — a 34px (30px when `sm`) magnifier slot that, on click, opens a
// RIGHT-ANCHORED field growing LEFT over whatever sits beside it (200ms
// ease-in-out), the magnifier cross-fading out (120ms); focus is grabbed
// one tick later because focusing synchronously inside the open races the
// open animation (the Slint comment at ExpandableSearch.slint:26); the X
// clears AND closes; Esc closes. The closed footprint is fixed, so
// nothing to the control's right ever moves.
//
// Every per-view search box (Local Library toolbars, tree rail, folder
// detail, queue) is this control.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// --- plain arm (API unchanged) ---------------------------------------
property string text: ""
property string placeholder: ""
property bool isPassword: false
signal committed(string value)
// --- search arm -------------------------------------------------------
/// Leading magnifier + live `edited` + trailing X.
property bool searchMode: false
/// Collapse to the magnifier slot (implies searchMode semantics).
property bool expandable: false
/// Bootstrap-style small variant (ExpandableSearch `sm`): 30px, not 34px.
property bool sm: false
/// Open width of the expandable field (ExpandableSearch max-open-width).
property int openWidth: 196
/// surface-elevated fill (toolbar variant) vs surface-card (rail/overlay).
property bool elevated: true
property bool open: false
signal edited(string value)
QbzTheme { id: theme }
readonly property int collapsedSize: sm ? 30 : 34
readonly property int glyph: sm ? 13 : 14
// The ROOT keeps the closed footprint; only the inner field animates, so
// a positioner never reflows while the search opens.
width: expandable ? collapsedSize : 240
height: (searchMode || expandable) ? collapsedSize : 34
color: "transparent"
function clearSearch() {
input.text = ""
root.text = ""
root.edited("")
}
function closeSearch() {
clearSearch()
root.open = false
input.focus = false
}
// Deferred focus — see the header note.
onOpenChanged: if (open) focusDefer.restart()
Timer {
id: focusDefer
interval: 30
repeat: false
onTriggered: input.forceActiveFocus()
}
// --- Closed: the magnifier toggle (fades, so both directions are smooth)
Rectangle {
visible: root.expandable
x: root.width - width
width: root.collapsedSize
height: root.collapsedSize
radius: 6
opacity: root.open ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 120 } }
color: openArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: root.sm ? 14 : 16
height: root.sm ? 14 : 16
anchors.centerIn: parent
tintName: openArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: openArea
anchors.fill: parent
enabled: !root.open
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.open = true
}
}
// --- The field. Right-anchored, so an expandable one grows LEFT. ------
Rectangle {
id: box
x: root.width - width
width: root.expandable ? (root.open ? root.openWidth : 0) : root.width
height: root.height
radius: (root.searchMode || root.expandable) ? 6 : theme.radiusSm
color: root.elevated ? theme.surfaceElevated : theme.surfaceCard
border.width: (root.expandable && !root.open) ? 0 : 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
clip: true
Behavior on width {
enabled: root.expandable
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Row {
anchors.fill: parent
anchors.leftMargin: root.searchMode ? (root.expandable ? 11 : 9) : 12
anchors.rightMargin: root.searchMode ? 5 : 12
spacing: root.searchMode ? 7 : 0
QbzIcon {
visible: root.searchMode
name: "search"
width: root.glyph
height: root.glyph
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
// Clamped: the open animation passes through width 0 and a
// negative width is a silent layout trap.
width: Math.max(0, parent.width
- (root.searchMode ? root.glyph + 7 : 0)
- (clearSlot.visible ? clearSlot.width + 7 : 0))
height: parent.height
clip: true
TextInput {
id: input
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: root.searchMode ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
echoMode: root.isPassword ? TextInput.Password : TextInput.Normal
text: root.text
onAccepted: root.committed(text)
onActiveFocusChanged: if (!activeFocus) root.committed(text)
onTextEdited: root.edited(text)
Keys.onEscapePressed: function (event) {
if (root.expandable) {
root.closeSearch()
event.accepted = true
} else {
event.accepted = false
}
}
// External republish (e.g. Reset) re-seeds the field while
// it is not being edited.
Binding {
target: input
property: "text"
value: root.text
when: !input.activeFocus
}
}
Text {
visible: input.text === ""
anchors.fill: parent
text: root.placeholder
color: theme.textMuted
font.pixelSize: root.searchMode ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Trailing X — clears (plain search) or clears AND closes
// (expandable, back to the magnifier).
Item {
id: clearSlot
visible: root.searchMode && (root.expandable || input.text !== "")
width: visible ? (root.sm ? 22 : 24) : 0
height: parent.height
Rectangle {
anchors.centerIn: parent
width: root.sm ? 22 : 24
height: root.sm ? 22 : 24
radius: 4
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: 12
height: 12
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.expandable ? root.closeSearch() : root.clearSearch()
}
}
}
}
}
}