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 {