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:
15 files changed
+299
-4
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user