Files
qbz/crates/qbz-qt/qml/controls/QbzSearchField.qml
T
vicrodh 3d4d3bc6ed feat(qt): the sidebar's playlist and My QBZ filters follow the search-box contract
Both inline fields were bare TextInputs: no clear cross, no Escape.
They are QbzSearchField now — Escape empties the filter, hands the
keyboard back and folds the field into its title; the cross empties it
and keeps the caret. The control grew what a 22px header row needs: an
optional leading glyph (the toggle already shows one), a cross sized to
the field, and an `escaped` signal for hosts that fold the field away.

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

166 lines
5.6 KiB
QML

// QbzSearchField — the inline search box the views share (2026-09-13): the
// rounded elevated field with a leading search glyph, a placeholder, a clear
// cross floating at the right while there is text, and one key contract —
// Escape clears the text AND hands the keyboard back to the shell root (the
// walk the header search does), so no search box keeps a stale query or a
// blinking cursor after Escape.
//
// FOCUS RETENTION. A search box that filters a list on every keystroke sits
// above a surface that rebuilds on every keystroke, and the field must never
// lose the keyboard mid-word to that rebuild. After each edit, once the event
// loop has settled, the field checks it still has focus and reclaims it
// unless another text input took it: a click elsewhere is a decision, a
// rebuilt list is not.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property alias text: input.text
property string placeholder: ""
/// The leading magnifier; off for hosts whose own toggle already shows one
/// (the sidebar's inline filters).
property bool showGlyph: true
/// Escape emptied the field and handed the keyboard back; a host that
/// folds the field away listens here.
signal escaped()
/// The clear cross scales with a short field (the sidebar's 22px rows).
readonly property int clearSize: Math.min(22, Math.max(16, height - 2))
property int glyphSize: 14
property int fontPx: 13
property int sidePadding: 10
/// Fired on every keystroke and on clear, with the live text.
signal edited(string text)
readonly property bool fieldActive: input.activeFocus
implicitWidth: 168
implicitHeight: 34
radius: 6
// The header search's ambient-aware fill, so every search box reads alike.
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
border.width: 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
QbzTheme { id: theme }
function clear() {
if (input.text === "")
return
input.text = ""
root.edited("")
}
/// Escape's contract: empty AND hand the keyboard to the shell root.
function clearAndBlur() {
root.clear()
root.blur()
}
function blur() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
input.focus = false
}
function focusField() { input.forceActiveFocus() }
property bool _reclaim: false
function _reclaimFocus() {
if (!root._reclaim)
return
root._reclaim = false
if (input.activeFocus)
return
var win = root.Window.window
var current = win ? win.activeFocusItem : null
if (current instanceof TextInput || current instanceof TextEdit)
return
console.log("[QbzSearchField] focus left the field after an edit; reclaimed (holder was "
+ (current ? current.toString() : "none") + ")")
input.forceActiveFocus()
}
QbzIcon {
visible: root.showGlyph
name: "search"
width: root.glyphSize
height: root.glyphSize
x: root.sidePadding
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
id: input
QbzTextEditMenu { }
anchors.left: parent.left
anchors.right: clearSlot.left
anchors.leftMargin: root.sidePadding + (root.showGlyph ? root.glyphSize + 7 : 0)
anchors.rightMargin: 4
height: parent.height
color: theme.textPrimary
font.pixelSize: root.fontPx
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: {
root.edited(text)
root._reclaim = true
Qt.callLater(root._reclaimFocus)
}
Keys.onEscapePressed: function (event) {
root.clearAndBlur()
root.escaped()
event.accepted = true
}
Text {
visible: parent.text === ""
anchors.fill: parent
text: root.placeholder
color: theme.textMuted
font.pixelSize: root.fontPx
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// The clear cross: floats at the right while there is text; a click
// empties the field and keeps the keyboard in it.
Item {
id: clearSlot
anchors.right: parent.right
anchors.rightMargin: 5
width: input.text !== "" ? root.clearSize : 0
height: parent.height
visible: input.text !== ""
Rectangle {
anchors.centerIn: parent
width: root.clearSize
height: root.clearSize
radius: root.clearSize / 2
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: Math.round(root.clearSize * 0.55)
height: Math.round(root.clearSize * 0.55)
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.clear()
input.forceActiveFocus()
}
}
}
}
}