From b8683192331e52628ce46abf589f06f0a8bde506 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:32:00 -0600 Subject: [PATCH] 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 --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/QbzLineEdit.qml | 48 +++++- crates/qbz-qt/qml/controls/QbzSearchField.qml | 154 ++++++++++++++++++ crates/qbz-qt/qml/shell/HeaderBar.qml | 39 ++++- crates/qbz-qt/qml/views/AlbumView.qml | 41 +---- crates/qbz-qt/qml/views/PlaylistView.qml | 35 +--- 6 files changed, 245 insertions(+), 73 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/QbzSearchField.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 366377303..ecb4ae03a 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index f43ccf9c5..5eda4ce41 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -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 } diff --git a/crates/qbz-qt/qml/controls/QbzSearchField.qml b/crates/qbz-qt/qml/controls/QbzSearchField.qml new file mode 100644 index 000000000..263474576 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzSearchField.qml @@ -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() + } + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index a3ff4331d..c5a994f5b 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index df98929ec..95205bb32 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index eb384fa40..455d80800 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -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 {