fix(qt): preserve navigation shortcuts and release text input focus

Keep search, settings and link shortcuts usable while editing, dismiss focus on outside clicks and navigation, and guard deferred focus against hidden inputs. Add Qt Quick regression coverage.

Refs #753

cx
This commit is contained in:
vicrodh committed 2026-09-08 21:40:28 -06:00
1 parent bc6a795980
commit 0e76c1f486
15 files changed
+299 -4

No files matched your search

+1
View File
@@ -575,6 +575,7 @@ fn main() {
"qml/controls/QbzEmptyState.qml",
"qml/controls/QbzIconButton.qml",
"qml/controls/QbzLineEdit.qml",
"qml/controls/InputFocusDismiss.qml",
"qml/controls/QbzLoadingDots.qml",
"qml/controls/QbzLoadMore.qml",
"qml/controls/QbzMultiSelectBar.qml",
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Window
// Observe presses without taking the target control's mouse/touch grab.
Item {
id: observer
anchors.fill: parent
// Above modal MouseAreas: a passive handler observes without blocking them.
z: 100000
property Item focusRoot: parent
function dismiss() {
var window = observer.Window.window
var item = window ? window.activeFocusItem : null
if (item instanceof TextInput || item instanceof TextEdit)
observer.focusRoot.forceActiveFocus()
}
PointHandler {
target: null
acceptedButtons: Qt.LeftButton
onActiveChanged: {
if (!active)
return
var window = observer.Window.window
var item = window ? window.activeFocusItem : null
if (!(item instanceof TextInput || item instanceof TextEdit))
return
var localPoint = item.mapFromItem(observer, point.position.x, point.position.y)
if (!item.contains(localPoint))
observer.focusRoot.forceActiveFocus()
}
}
}
@@ -3,6 +3,7 @@
// opened it.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
@@ -17,9 +18,29 @@ Item {
event.accepted = true
}
Connections {
target: QbzLink
function onFocusRequested() { urlInput.focusField() }
}
onVisibleChanged: {
if (visible)
if (visible) {
urlInput.focusField()
} else {
// Return focus only if it still belongs to this modal. A search
// shortcut may already have focused the header before close arrives.
var window = root.Window.window
var item = window ? window.activeFocusItem : null
while (item && item !== root)
item = item.parent
if (item === root) {
var shell = root.parent
while (shell && shell.isQbzShellRoot !== true)
shell = shell.parent
if (shell)
shell.forceActiveFocus()
}
}
}
Rectangle {
+5 -1
View File
@@ -139,7 +139,11 @@ Rectangle {
id: focusDefer
interval: 30
repeat: false
onTriggered: input.forceActiveFocus()
onTriggered: {
// A close/navigation can win the race with this deferred request.
if (root.visible && root.enabled && (!root.expandable || root.open))
input.forceActiveFocus()
}
}
// --- Closed: the magnifier toggle (fades, so both directions are smooth)
+6
View File
@@ -152,6 +152,12 @@ Rectangle {
// only what it owns. Shared controls accept their Space/Enter events
// before they bubble here; in particular a focused Settings toggle owns
// Space, so toggling it cannot also trigger global play/pause (§4.1).
InputFocusDismiss { id: inputFocusDismiss }
Connections {
target: QbzShell
function onCurrentViewChanged() { inputFocusDismiss.dismiss() }
}
Keys.onPressed: function (event) {
var w = root.Window.window
var afi = w !== null ? w.activeFocusItem : null
+6
View File
@@ -583,6 +583,12 @@ Rectangle {
// central text-input gate (hotkeys_bridge.rs:295-299) is the counterpart
// of the first guard. The textInputFocused predicate is computed exactly
// as the desktop shell computes it (AppShell.qml:91).
InputFocusDismiss { id: inputFocusDismiss }
Connections {
target: QbzShell
function onCurrentViewChanged() { inputFocusDismiss.dismiss() }
}
Keys.onPressed: function (event) {
var w = root.Window.window
var afi = w !== null ? w.activeFocusItem : null
+26 -2
View File
@@ -360,8 +360,22 @@ impl qbz_hotkeys::QbzHotkeys {
// QML per §1.4.4 and passed in. Null activeFocusItem passes (the
// semantically right case).
if text_input_focused {
// Keep ordinary typing off the preferences-on-disk lookup path.
if !crate::hotkeys_qt::mods_from_qt(modifiers).0 {
return false;
}
let overrides = crate::hotkeys_qt::load_overrides();
return crate::hotkeys_qt::action_for_text_input(
crate::hotkeys_qt::active_keymap(),
&overrides,
key,
modifiers,
&text,
crate::immersive_bridge::is_open(),
)
.map(|action| self.as_mut().run_action(action.id))
.unwrap_or(false);
}
// (C2) Ctrl+A select-all — separate NON-REBINDABLE branch (§4.6,
// trap 8): the predicate is `ctrl && a` with other modifiers NOT
@@ -400,8 +414,18 @@ impl qbz_hotkeys::QbzHotkeys {
"playback.prev" => crate::transport_previous(),
"nav.back" => crate::nav_qt::back(),
"nav.forward" => crate::nav_qt::forward(),
"nav.search" => self.as_mut().focus_search_requested(), // K6 QML seam
"nav.settings" => crate::navigate_to("settings"),
"nav.search" => {
if crate::link_resolver_bridge::is_open() {
crate::link_resolver_bridge::close_modal();
}
self.as_mut().focus_search_requested();
}
"nav.settings" => {
if crate::link_resolver_bridge::is_open() {
crate::link_resolver_bridge::close_modal();
}
crate::navigate_to("settings");
}
"ui.sidebar" => crate::shell_bridge::ui(|s| s.cycle_sidebar()),
"ui.focusMode" => crate::immersive_bridge::toggle(),
"ui.queue" => crate::shell_bridge::ui(|s| s.toggle_queue()),
+79
View File
@@ -507,6 +507,16 @@ pub fn token_from_qt_key(key: i32, modifiers: i32, text: &str) -> Option<String>
let base = (b'0' + (key - QT_KEY_0) as u8) as char;
return Some(base.to_string());
}
// Some platforms provide no text for Ctrl/Cmd+punctuation (notably
// Settings: Ctrl+,). Keep shifted symbols on the text/layout path.
if mods_from_qt(modifiers).0
&& modifiers & QT_SHIFT == 0
&& (text.is_empty() || text.chars().all(char::is_control))
{
if let Some(c) = char::from_u32(key as u32).filter(char::is_ascii_punctuation) {
return Some(c.to_string());
}
}
// The text fallback: exactly one char, printable (non-control). This is
// where the shifted symbols come from (`Shift+/` delivers "?").
let mut chars = text.chars();
@@ -919,6 +929,23 @@ pub fn action_for_key(
}
}
/// Focus-taking commands remain available while editing. Plain Vim keys
/// and standard clipboard/edit shortcuts continue to belong to the field.
pub fn action_for_text_input(
keymap: Keymap,
overrides: &BTreeMap<String, String>,
key: i32,
modifiers: i32,
text: &str,
immersive_open: bool,
) -> Option<&'static ActionDef> {
if !mods_from_qt(modifiers).0 || matches!(key, 0x41 | 0x43 | 0x56 | 0x58 | 0x59 | 0x5a) {
return None;
}
action_for_key(keymap, overrides, key, modifiers, text, immersive_open)
.filter(|action| matches!(action.id, "nav.search" | "nav.settings" | "ui.openLink"))
}
/// Resolve a key event over the MINI window's context — a SIBLING of
/// `action_for_key`, never a widening of it (2026-08-03 miniplayer/tray
/// contract A-16, §4.9).
@@ -1047,6 +1074,58 @@ mod tests {
BTreeMap::new()
}
#[test]
fn focus_commands_work_while_editing_without_stealing_text_keys() {
for modifiers in [QT_CONTROL, QT_META] {
for (key, id) in [
(0x46, "nav.search"),
(0x4c, "ui.openLink"),
(0x2c, "nav.settings"),
] {
for _ in 0..3 {
assert_eq!(
action_for_text_input(
Keymap::Default,
&no_overrides(),
key,
modifiers,
"",
false
)
.map(|a| a.id),
Some(id)
);
}
}
}
for keymap in [Keymap::Default, Keymap::Vim] {
for key in [0x41, 0x43, 0x56, 0x58, 0x59, 0x5a, QT_KEY_SPACE, 0x2f] {
for modifiers in [0, QT_SHIFT, QT_CONTROL] {
assert!(action_for_text_input(
keymap,
&no_overrides(),
key,
modifiers,
"",
false
)
.is_none());
}
}
}
let mut overrides = no_overrides();
overrides.insert("nav.search".into(), "Ctrl+g".into());
assert_eq!(
action_for_text_input(Keymap::Default, &overrides, 0x47, QT_CONTROL, "", false)
.map(|a| a.id),
Some("nav.search")
);
assert!(
action_for_text_input(Keymap::Default, &overrides, 0x46, QT_CONTROL, "", false)
.is_none()
);
}
// --- Grammar round-trips (§3.5) ---------------------------------------
#[test]
@@ -29,6 +29,9 @@ pub mod qbz_link {
#[qproperty(QString, playlist_provider)]
type QbzLink = super::QbzLinkRust;
#[qsignal]
fn focus_requested(self: Pin<&mut QbzLink>);
#[qinvokable]
fn boot(self: Pin<&mut QbzLink>);
#[qinvokable]
@@ -98,6 +101,10 @@ pub(crate) fn close_modal() {
}
fn reset_open(mut link: Pin<&mut QbzLink>) {
if *link.modal_open() {
link.as_mut().focus_requested();
return;
}
link.as_mut().set_url(QString::default());
link.as_mut().set_platform(QString::default());
link.as_mut().set_error(QString::default());
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { property bool npHasTrack: false }
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { property string themeJson: ""; property int ambientMode: 0 }
@@ -0,0 +1,3 @@
module com.blitzfc.qbz
singleton QbzShell 1.0 QbzShell.qml
singleton QbzPlayer 1.0 QbzPlayer.qml
+33
View File
@@ -0,0 +1,33 @@
import QtQuick
import QtTest
import "../../crates/qbz-qt/qml/controls"
Item {
id: root
width: 300; height: 200
QbzLineEdit { id: field }
TestCase {
name: "DeferredFieldFocus"; when: windowShown
function init() {
field.visible = true; field.enabled = true
field.expandable = false; field.open = false
root.forceActiveFocus()
}
function test_visible_field_focuses_repeatedly() {
for (var i = 0; i < 3; ++i) {
root.forceActiveFocus(); field.focusField()
tryCompare(field, "fieldActive", true)
}
}
function test_closed_dialog_cannot_steal_search_focus() {
field.focusField(); field.visible = false
root.forceActiveFocus(); wait(80)
verify(root.activeFocus)
}
function test_closed_search_cannot_regain_focus() {
field.expandable = true; field.open = true; field.closeSearch()
root.forceActiveFocus(); wait(80)
verify(root.activeFocus)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import QtQuick
import QtTest
import QtQuick.Controls
import "../../crates/qbz-qt/qml/controls"
Item {
id: root
width: 300; height: 200
property int clicks: 0
property string selectedOnClick: ""
InputFocusDismiss { id: dismissal }
TextInput { id: first; width: 100; height: 30; text: "first" }
TextInput { id: second; y: 40; width: 100; height: 30; text: "second" }
MouseArea { x: 150; width: 100; height: 100; onClicked: { root.clicks++; root.selectedOnClick = first.selectedText } }
TextEdit { id: multi; x: 150; y: 110; width: 140; height: 75; text: "multi-line" }
Popup {
id: editMenu
parent: Overlay.overlay
x: 150; y: 110; width: 100; height: 50
contentItem: MouseArea {
onClicked: { root.selectedOnClick = first.selectedText; editMenu.close() }
}
}
TestCase {
name: "InputFocusDismiss"; when: windowShown
function test_inside() { first.forceActiveFocus(); mouseClick(root, 30, 15); verify(first.activeFocus) }
function test_other_input() { first.forceActiveFocus(); mouseClick(root, 30, 55); verify(second.activeFocus) }
function test_outside() { first.forceActiveFocus(); mouseClick(root, 20, 150); verify(root.activeFocus) }
function test_button_keeps_click() { first.forceActiveFocus(); clicks=0; mouseClick(root, 170, 20); compare(clicks,1); verify(root.activeFocus) }
function test_text_edit_outside() { multi.forceActiveFocus(); mouseClick(root, 20, 150); verify(root.activeFocus) }
function test_text_survives_blur() { first.text = "edited"; first.forceActiveFocus(); mouseClick(root, 20, 150); compare(first.text,"edited") }
function test_selection_available_to_clicked_action() {
first.text = "copy me"; first.forceActiveFocus(); first.selectAll()
editMenu.open(); tryCompare(editMenu, "visible", true)
mouseClick(editMenu.contentItem, 10, 10)
compare(selectedOnClick, "copy me")
}
function test_navigation() { first.forceActiveFocus(); dismissal.dismiss(); verify(root.activeFocus) }
}
}
+32
View File
@@ -0,0 +1,32 @@
import QtQuick
import QtTest
Item {
width: 300; height: 200
property int lastKey: 0
Keys.onPressed: function(event) { lastKey = event.key; event.accepted = true }
TextInput { id: field; width: 150; height: 30 }
TestCase {
name: "TextInputPropagation"; when: windowShown
function test_modified_data() {
return [{tag: "search", key: Qt.Key_F}, {tag:"link",key:Qt.Key_L}, {tag:"settings",key:Qt.Key_Comma}]
}
function test_text_editing_stays_in_field() {
field.text = "example"; field.forceActiveFocus()
keyClick(Qt.Key_A, Qt.ControlModifier)
compare(field.selectedText, "example")
keyClick(Qt.Key_Backspace)
compare(field.text, "")
lastKey = 0
keyClick(Qt.Key_S, Qt.ShiftModifier)
compare(field.text.toLowerCase(), "s")
verify(lastKey !== Qt.Key_S)
}
function test_modified(data) {
for (var i = 0; i < 3; ++i) {
field.forceActiveFocus(); lastKey = 0
keyClick(data.key, Qt.ControlModifier)
compare(lastKey, data.key)
}
}
}
}