diff --git a/rust/zaparoo-core/src/input_actions.rs b/rust/zaparoo-core/src/input_actions.rs index 54f96f98..0ba8107f 100644 --- a/rust/zaparoo-core/src/input_actions.rs +++ b/rust/zaparoo-core/src/input_actions.rs @@ -16,6 +16,7 @@ pub mod actions { pub const RIGHT: &str = "right"; pub const ACCEPT: &str = "accept"; pub const CANCEL: &str = "cancel"; + pub const CANCEL_HOLD: &str = "cancel_hold"; pub const WRITE_CARD: &str = "write_card"; pub const DETAILS: &str = "details"; pub const PAGE_PREV: &str = "page_prev"; diff --git a/src/ui/app/Main.qml b/src/ui/app/Main.qml index 67e3a768..c65a2eb8 100644 --- a/src/ui/app/Main.qml +++ b/src/ui/app/Main.qml @@ -295,6 +295,45 @@ MainLayout { Browse.AppState.active_screen = screen; } + function _closeAllModals(): void { + root.hideCardWriteModal(); + root.qrCodeModalVisible = false; + root.commercialNoticeModalVisible = false; + root.firstRunIndexModalVisible = false; + root.logUploadModalVisible = false; + root.quitConfirmModalVisible = false; + root.listPickerModalVisible = false; + root.settingNeedsRestartModalVisible = false; + root.contextMenuVisible = false; + root.contextMenuOwner = ""; + root.contextMenuIndex = -1; + root.contextMenuMode = "main"; + root.contextMenuEntries = []; + root._discoverParentEntries = []; + root._discoverMenuPending = false; + root.listPickerTitle = ""; + root.listPickerEntries = []; + root.listPickerInitialId = ""; + root.listPickerFieldId = ""; + root._pendingLanguageSelection = ""; + root._pendingResolutionSelection = ""; + ScreenManager.modalStack = []; + } + + function _forceHub(): void { + root._stopRepeat(); + root._stopCancelHold(); + root.gamesScreen.flushSelectedPersist(); + root.pendingTransition = ""; + if (root.activeScreen === root.screenHub) { + root._closeAllModals(); + root.openQuitConfirmModal(); + return; + } + root._closeAllModals(); + root._goto(root.screenHub); + } + // Single-shot callback slots fired by the loadingChanged // listeners below. Only one transition is in flight at a time // (input gate guarantees this), so two scalars are enough. @@ -1276,6 +1315,10 @@ MainLayout { if (root._maybeDismissScreensaver()) return; root._resetIdle(); + if (action === "cancel_hold") { + root._forceHub(); + return; + } // Input gate. While a forward transition is in flight, swallow // every press so a user mashing buttons during the loading // wait can't queue a second transition or kick a half-cancel @@ -1347,13 +1390,18 @@ MainLayout { // active, just like fresh presses. readonly property int _repeatInitialMs: 350 readonly property int _repeatTickMs: 90 + readonly property int _cancelHoldMs: 1000 property string _heldAction: "" property int _heldKey: 0 property bool _dispatchingRepeat: false + property bool _cancelPending: false + property bool _cancelHoldReady: false + property int _cancelHeldKey: 0 // Aliased so tst_navigation.qml can observe the repeat state machine // — child Timer ids are file-scoped and aren't reachable otherwise. property alias _repeatPending: repeatInitial.running property alias _repeatTicking: repeatTick.running + property alias _cancelHoldPending: cancelHoldTimer.running function _stopRepeat(): void { repeatInitial.stop(); @@ -1370,10 +1418,24 @@ MainLayout { root.gamesScreen.flushSelectedPersist(); } + function _stopCancelHold(): void { + cancelHoldTimer.stop(); + root._cancelPending = false; + root._cancelHoldReady = false; + root._cancelHeldKey = 0; + } + function _isRepeatableAction(action: string): bool { return action === "up" || action === "down" || action === "left" || action === "right"; } + function _armCancelHold(key: int): void { + root._cancelPending = true; + root._cancelHoldReady = false; + root._cancelHeldKey = key; + cancelHoldTimer.restart(); + } + // State-machine half of handleKey: records the held key/action and // arms the initial-delay timer. Pulled out of handleKey so unit // tests can drive the repeat state machine without also routing @@ -1399,6 +1461,10 @@ MainLayout { const action = Browse.Input.action_for_key(key); if (action === "") return; + if (action === "cancel") { + root._armCancelHold(key); + return; + } root.handleAction(action); root._armRepeat(action, key); } @@ -1514,6 +1580,12 @@ MainLayout { // a release of any other key in flight (a chord, an unrelated press // mid-hold) is ignored. function handleKeyRelease(key: int): void { + if (root._cancelPending && key === root._cancelHeldKey) { + const action = root._cancelHoldReady ? "cancel_hold" : "cancel"; + root._stopCancelHold(); + root.handleAction(action); + return; + } if (root._heldAction !== "" && key === root._heldKey) root._stopRepeat(); } @@ -1524,6 +1596,17 @@ MainLayout { root._dispatchingRepeat = false; } + Timer { + id: cancelHoldTimer + interval: root._cancelHoldMs + repeat: false + onTriggered: { + if (!root._cancelPending) + return; + root._cancelHoldReady = true; + } + } + Timer { id: cardWriteFailureTimer interval: 1500 @@ -1561,8 +1644,10 @@ MainLayout { // quirk) would leave the timer ticking forever. `root.active` is // ApplicationWindow's own active property. onActiveChanged: { - if (!root.active) + if (!root.active) { root._stopRepeat(); + root._stopCancelHold(); + } } Item { diff --git a/tests/ui/tst_navigation.qml b/tests/ui/tst_navigation.qml index e78a5a9a..1c4c611a 100644 --- a/tests/ui/tst_navigation.qml +++ b/tests/ui/tst_navigation.qml @@ -40,6 +40,7 @@ TestCase { // run in microseconds, so the pending fire would land on the // next test if we didn't reset it here. main._stopRepeat(); + main._stopCancelHold(); } function test_initial_state_is_hub(): void { @@ -95,6 +96,7 @@ TestCase { function test_escape_on_games_returns_to_systems(): void { main.activeScreen = main.screenGames; main.handleKey(Qt.Key_Escape); + main.handleKeyRelease(Qt.Key_Escape); compare(main.activeScreen, main.screenSystems); } @@ -102,6 +104,7 @@ TestCase { function test_escape_on_systems_returns_to_hub(): void { main.activeScreen = main.screenSystems; main.handleKey(Qt.Key_Escape); + main.handleKeyRelease(Qt.Key_Escape); compare(main.activeScreen, main.screenHub); } @@ -118,9 +121,36 @@ TestCase { function test_backspace_behaves_like_escape_on_games(): void { main.activeScreen = main.screenGames; main.handleKey(Qt.Key_Backspace); + main.handleKeyRelease(Qt.Key_Backspace); compare(main.activeScreen, main.screenSystems); } + function test_cancel_press_arms_hold_without_immediate_navigation(): void { + main.activeScreen = main.screenGames; + main.handleKey(Qt.Key_Escape); + compare(main.activeScreen, main.screenGames, "Back press must wait for release-or-hold timeout"); + compare(main._cancelHoldPending, true); + } + + function test_cancel_hold_from_games_routes_directly_to_hub(): void { + main.activeScreen = main.screenGames; + main.handleKey(Qt.Key_Escape); + tryCompare(main, "activeScreen", main.screenHub); + compare(main._cancelHoldPending, false, "Hold timer must clear after firing"); + main.handleKeyRelease(Qt.Key_Escape); + compare(main.activeScreen, main.screenHub, "Release after long-hold must not fire a second cancel"); + } + + function test_cancel_hold_on_hub_opens_quit_confirm(): void { + main.activeScreen = main.screenHub; + main.handleKey(Qt.Key_Escape); + tryCompare(main, "quitConfirmModalVisible", true); + compare(main.activeScreen, main.screenHub, "Long-hold Back on Hub must stay on Hub and ask for quit"); + compare(main._cancelHoldPending, false, "Hold timer must clear after opening quit confirm"); + main.handleKeyRelease(Qt.Key_Escape); + compare(main.quitConfirmModalVisible, true, "Release after long-hold must not close the quit confirm"); + } + // Cross-row mapping. The test harness has no live CategoriesModel // so we can't drive the full handleAction("down") flow with real // categories — instead we unit-test the pure arithmetic helper