feat(qt): search boxes clear on Escape, drop focus, and offer a clear cross

Every search field follows one contract: Escape empties the text AND hands
the keyboard back to the shell root, and a clickable cross clears the text
while keeping the caret in the field. The header search shows the cross
left of the Enter glyph while the cortinilla is open; QbzLineEdit's search
mode and the new QbzSearchField (the album track filter and the playlist
filter) float it at the right edge.

Each field also keeps its focus after an edit unless another text input
took it, and logs when it had to reclaim it.

cc
This commit is contained in:
vicrodh committed 2026-09-13 23:32:00 -06:00
1 parent e546e6614e
commit b868319233
6 files changed
+245 -73

No files matched your search

+1
View File
@@ -597,6 +597,7 @@ fn main() {
"qml/controls/ScrollMemory.qml",
"qml/controls/ScopePanel.qml",
"qml/controls/QbzSegToggle.qml",
"qml/controls/QbzSearchField.qml",
"qml/controls/QbzSelect.qml",
"qml/controls/QbzSelectableText.qml",
"qml/controls/QbzSplitButton.qml",
+47 -1
View File
@@ -19,6 +19,7 @@
// detail, queue) is this control.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
@@ -110,6 +111,39 @@ Rectangle {
root.open = false
input.focus = false
}
/// Hand the keyboard back to the shell root (the walk the header search
/// does); plain unfocus when no shell root is above (previews, tests).
function blurToShell() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
input.focus = false
}
// FOCUS RETENTION (2026-09-13, every search box): a field that filters a
// list on each keystroke sits above a surface that rebuilds on each
// keystroke, and must not lose the keyboard to that rebuild. After an
// edit, once the event loop settles, reclaim focus unless another text
// input took it (a click elsewhere is a decision, a rebuilt list is not).
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("[QbzLineEdit] focus left the field after an edit; reclaimed (holder was "
+ (current ? current.toString() : "none") + ")")
input.forceActiveFocus()
}
// Immersive-port contract D16 (2026-08-02, additive one-liner): the ONE
// read-only seam into the inner TextInput's focus. The immersive root
@@ -237,11 +271,23 @@ Rectangle {
text: root.text
onAccepted: { root.accepted(text); root.committed(text) }
onActiveFocusChanged: if (!activeFocus) root.committed(text)
onTextEdited: root.edited(text)
onTextEdited: {
root.edited(text)
if (root.searchMode) {
root._reclaim = true
Qt.callLater(root._reclaimFocus)
}
}
// Escape on a search box: empty it AND hand the keyboard
// back (2026-09-13). Form fields keep propagating it.
Keys.onEscapePressed: function (event) {
if (root.expandable) {
root.closeSearch()
event.accepted = true
} else if (root.searchMode) {
root.clearSearch()
root.blurToShell()
event.accepted = true
} else {
event.accepted = false
}
@@ -0,0 +1,154 @@
// 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: ""
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
color: 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 {
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.glyphSize + 7
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()
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 !== "" ? 22 : 0
height: parent.height
visible: input.text !== ""
Rectangle {
anchors.centerIn: parent
width: 22
height: 22
radius: 11
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.clear()
input.forceActiveFocus()
}
}
}
}
}
+33 -6
View File
@@ -942,6 +942,24 @@ Rectangle {
function focusSearch() {
searchInput.forceActiveFocus()
}
// FOCUS RETENTION (2026-09-13, every search box): after an edit, once the
// event loop settles, reclaim the keyboard unless another text input took
// it — the dropdown opening or closing must never steal it mid-word.
property bool _searchReclaim: false
function _reclaimSearchFocus() {
if (!root._searchReclaim)
return
root._searchReclaim = false
if (searchInput.activeFocus)
return
var win = root.Window.window
var current = win ? win.activeFocusItem : null
if (current instanceof TextInput || current instanceof TextEdit)
return
console.log("[HeaderBar] search lost focus after an edit; reclaimed (holder was "
+ (current ? current.toString() : "none") + ")")
searchInput.forceActiveFocus()
}
Rectangle {
id: searchBox
@@ -1001,7 +1019,11 @@ Rectangle {
QbzSearch.searchLive(text)
}
}
onTextEdited: applyLiveQuery()
onTextEdited: {
applyLiveQuery()
root._searchReclaim = true
Qt.callLater(root._reclaimSearchFocus)
}
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
// a keyboard selection -> activate the row; open + none -> full
@@ -1045,8 +1067,9 @@ Rectangle {
// the modals use, not merely cleared: a null
// activeFocusItem passes the gate but leaves AppShell's
// Keys handler receiving nothing at all.
if (QbzSearch.cortinillaOpen)
QbzSearch.cortinillaDismiss()
// …and it empties the box too (2026-09-13, every search
// box): clearSearch() drops the text and the dropdown.
root.clearSearch()
event.accepted = true
var p = searchInput
while (p.parent) {
@@ -1078,6 +1101,7 @@ Rectangle {
// open (Slint: it lives in the box, opposite the magnifier), else
// the × clear.
Text {
id: enterHint
visible: QbzSearch.cortinillaOpen
anchors.right: parent.right
anchors.rightMargin: 10
@@ -1087,10 +1111,13 @@ Rectangle {
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
// The clear cross: always there while there is text (2026-09-13); it
// sits to the LEFT of the ↵ hint while the dropdown is open, at the
// right edge otherwise.
Rectangle {
visible: !QbzSearch.cortinillaOpen && searchInput.text !== ""
anchors.right: parent.right
anchors.rightMargin: 5
visible: searchInput.text !== ""
anchors.right: QbzSearch.cortinillaOpen ? enterHint.left : parent.right
anchors.rightMargin: QbzSearch.cortinillaOpen ? 4 : 5
width: 22
height: 22
anchors.verticalCenter: parent.verticalCenter
+5 -36
View File
@@ -1560,45 +1560,14 @@ Rectangle {
- 168 - 30 - 2 * 16)
height: 1
}
Rectangle {
// Track filter (QbzSearchField: Escape clears + blurs,
// clear cross, focus kept across the list rebuild).
QbzSearchField {
width: 168
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 7
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
width: parent.width - 21
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: root.trackQuery = text
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev)
onEdited: function (text) { root.trackQuery = text }
}
// Multi-select toggle (AlbumPageView.slint:752-787):
// accent border + tint while active; leaving the mode
+5 -30
View File
@@ -722,39 +722,14 @@ Rectangle {
}
// In-playlist search.
Rectangle {
QbzSearchField {
width: 220
height: 30
radius: 6
sidePadding: 9
glyphSize: 13
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 6
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
TextInput {
QbzTextEditMenu { }
width: parent.width - 19
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzBridge.playlistSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
placeholder: QbzSession.tr("Search tracks", QbzSession.trRev)
onEdited: function (text) { QbzBridge.playlistSetSearch(text) }
}
// Sort dropdown.
Rectangle {