Under the ambient field some controls still painted the opaque theme token while their neighbours used the half-alpha one: the album track filter and its select toggle, the Library tab strip's host, the Local Library icon selects, the header's active nav items and open playlists button, the queue sidebar's now-playing card and artwork placeholders, the Listen list's floating actions, and the shared SettingsButton, IconTextButton, multi-select bar, text area, load-more pill, tree row selection, sidebar count chips, Purchases tab strip, play-history filter box and Orbit banner. Each now takes the ambient-aware fill the header search and QbzTabBar already use (theme.ambientOn ? …A50 : …). Tooltips, popups and modals stay opaque on purpose: they are read over content, not chrome. cc
156 lines
4.9 KiB
QML
156 lines
4.9 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: ""
|
|
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 {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|