From 1bde401aa8ad366d83fe790b14499f00bdfa4be4 Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 17:26:01 -0400 Subject: [PATCH] Add quick chat picker and hold-to-quit overlay control --- README.md | 14 ++++--- assets/actions.json | 6 ++- assets/bindings_frame_controller.json | 4 +- assets/config.example.json | 4 +- docs/design.md | 8 ++-- docs/overlay.md | 40 ++++++++++++------- install.sh | 11 +++++- scripts/install_payload.py | 11 +++++- src/config.cpp | 26 +++++++++++-- src/config.hpp | 2 + src/core.cpp | 14 +++++++ src/core.hpp | 2 + src/main.cpp | 7 ++-- src/overlay.cpp | 15 ++++---- src/overlay.hpp | 12 +++++- src/panel_surface.cpp | 55 ++++++++++++++++++++------- src/panel_surface.hpp | 7 +++- src/runtime.cpp | 49 +++++++++++++++++++----- tests/config_test.cpp | 16 ++++++++ tests/core_test.cpp | 20 ++++++++++ tests/panel_test.cpp | 25 +++++++++++- tests/test_action_bindings.py | 5 ++- tests/test_installer.py | 9 +++++ 23 files changed, 291 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 75658db..974d027 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,15 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet transcribe. Repeated presses reached the controls-only diagnostic on Frame; live mic capture through this shortcut still needs guided acceptance. This PTT action is remappable through SteamVR bindings. -- **Right B:** cancel/discard. **Right A:** insert reviewed text with a trailing space. - **Right Y:** insert pending text, then send Enter; with no pending text, Enter only. - **Left grip (when active):** double-tap for the same explicit Enter action. - Nothing submits automatically. Auto Insert is opt-in and off by default. -- **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit. +- **Right B:** cancel/discard (or close quick chat). **Right A:** insert reviewed text with a trailing space. + **Right Y:** open quick chat; press again to cycle its highlighted choice. + **Submit** on the overlay (or double-tap left grip when active) sends that choice + verbatim then Enter; outside quick chat it inserts pending review + Enter, or + sends Enter alone when there is no text. Edit the `quick_inputs` list in + `$XDG_CONFIG_HOME/frameyap/config.json` (restart to apply). Nothing submits + automatically. Auto Insert is opt-in and off by default. +- **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Submit and Hold Quit + (hold the button for 0.9 seconds before releasing). Review and settings share one Inconsolata/neon-framed surface. The header shows local time/date; Settings selects 12/24-hour time and date format/off. Hold the thin bar below the panel to freely move and rotate it with your diff --git a/assets/actions.json b/assets/actions.json index 253f2cb..530435c 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -12,7 +12,8 @@ { "name": "/actions/frameyap/in/ptt", "type": "boolean" }, { "name": "/actions/frameyap/in/cancel", "type": "boolean" }, { "name": "/actions/frameyap/in/insert", "type": "boolean" }, - { "name": "/actions/frameyap/in/enter", "type": "boolean" } + { "name": "/actions/frameyap/in/enter", "type": "boolean" }, + { "name": "/actions/frameyap/in/quick_chat", "type": "boolean" } ], "localization": [{ "language_tag": "en_US", @@ -22,6 +23,7 @@ "/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)", "/actions/frameyap/in/cancel": "Cancel recording or discard", "/actions/frameyap/in/insert": "Insert reviewed text + trailing space", - "/actions/frameyap/in/enter": "Insert pending text + Enter (Enter only if no text)" + "/actions/frameyap/in/enter": "Submit: pending text + Enter, or Enter alone", + "/actions/frameyap/in/quick_chat": "Open quick chat / cycle selection" }] } diff --git a/assets/bindings_frame_controller.json b/assets/bindings_frame_controller.json index 0c59c62..f620578 100644 --- a/assets/bindings_frame_controller.json +++ b/assets/bindings_frame_controller.json @@ -1,6 +1,6 @@ { "controller_type": "frame_controller", - "description": "Right X: hold to dictate. B: cancel. A: insert + space. Y: insert + Enter. Remap in SteamVR.", + "description": "Right X: hold to dictate. B: cancel. A: insert + space. Y: quick chat. Left grip double-tap: submit.", "name": "FrameYap Steam Frame controls", "bindings": { "/actions/frameyap": { @@ -33,7 +33,7 @@ { "path": "/user/hand/right/input/y", "mode": "button", - "inputs": { "click": { "output": "/actions/frameyap/in/enter" } } + "inputs": { "click": { "output": "/actions/frameyap/in/quick_chat" } } } ] } diff --git a/assets/config.example.json b/assets/config.example.json index a98b658..2ec03be 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -6,6 +6,7 @@ "lock_layout": false, "clock_24h": false, "date_format": "mdy", + "quick_inputs": ["/new", "/questions", "/help"], "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": { "background": "#0c101b", @@ -23,6 +24,7 @@ "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", "insert": "/user/hand/right/input/a", - "enter": "/user/hand/right/input/y" + "enter": "", + "quick_chat": "/user/hand/right/input/y" } } diff --git a/docs/design.md b/docs/design.md index 64e44be..e5f7dde 100644 --- a/docs/design.md +++ b/docs/design.md @@ -61,8 +61,10 @@ Recording… 00:04 [ Cancel ] - States: disabled, warming, ready, recording, transcribing, review, inserted, unavailable/error. Recording uses visible icon + text, not color alone. - Default Frame bindings: hold right X to speak, release to finish; B cancels, - A inserts with a trailing space, Y inserts pending text then sends Enter. - The alternate right-grip tap-then-hold gesture and left-grip double-tap Enter + A inserts with a trailing space, Y opens/cycles the quick-chat selection. + Overlay Submit or left-grip double-tap submits the selected literal + Enter, + or pending review + Enter, or Enter alone if neither is present. + The alternate right-grip tap-then-hold gesture and left-grip double-tap Submit remain available. All are remappable through the Bindings button's SteamVR editor. A click-to-start/stop overlay button provides a binding-independent alternative. Bound recording to 20 seconds; discard @@ -71,7 +73,7 @@ Recording… 00:04 [ Cancel ] uninterrupted Xwayland target observation remains valid. **Review mode (default):** wait for Insert. Bring up review on uncertainty rather than silently losing a transcript or typing into a new target. Live Frame acceptance remains open. -- Enter requires its own explicit control activation: insert pending review with +- Submit requires its own explicit control activation: insert pending review with a trailing space, then queue Enter only if the text step succeeds. With no review, it queues only Enter. Never interpret "submit", "delete" or other speech as commands. Transcription completion never auto-submits. diff --git a/docs/overlay.md b/docs/overlay.md index 41ce0c2..00f3243 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -59,18 +59,23 @@ mount choices remain in Settings. The complete transcript preview is paginated by glyph width and four-line height; Previous and Next navigate it without changing the source transcript. -Long status/detail messages show a prefix with a visible truncation marker. +Status fits on the single status line; the old bottom detail label is gone. The footer remains available on all tabs: Record (labelled Stop while recording), -Cancel, Insert, Enter, Quit. Record can retry after an error; it is disabled while -warming/transcribing and until an existing review is inserted or discarded. -Cancel can stop worker startup. Insert and Enter are disabled during recording -and transcription. A pointer action requires +Cancel, Insert, Submit, Hold Quit. Hold Quit needs a 900 ms press and release on +that same button; its thin progress bar shows the hold. Record can retry after an +error; it is disabled while warming/transcribing and until an existing review is +inserted or discarded. Cancel can stop worker startup. Insert and Submit are +disabled during recording and transcription. A pointer action requires a press/release on the same enabled control from the same cursor; focus loss, -tab changes, action-state changes and relocation clear pending presses. Enter is *always* a separate +tab changes, action-state changes and relocation clear pending presses. Submit is *always* a separate deliberate action, not inferred from text. Insert appends a trailing space (without -doubling an existing trailing space). Enter inserts any pending review and then -queues Enter; with no pending text it queues Enter only. A failed text step never -proceeds to Enter. Recording never automatically submits. Auto insert, when +doubling an existing trailing space). Submit inserts any pending review and then +queues Enter; with no pending text it queues Enter only. Y opens the quick-chat +list over the review area; each further Y press cycles its highlighted choice. +Cancel closes the picker without discarding an existing review. Submit sends the +selected text *without* a trailing space, then Enter. The choices are short +single-line literals, not speech commands. A failed text step never proceeds to +Enter. Recording never automatically submits. Auto insert, when explicitly enabled, can queue text + space after transcription only under the stable Xwayland focus guard described below. @@ -108,6 +113,7 @@ installer creates one with defaults on first install. Copy the shipped "lock_layout": false, "clock_24h": false, "date_format": "mdy", + "quick_inputs": ["/new", "/questions", "/help"], "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": { "background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", @@ -118,7 +124,8 @@ installer creates one with defaults on first install. Copy the shipped "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", "insert": "/user/hand/right/input/a", - "enter": "/user/hand/right/input/y", + "enter": "", + "quick_chat": "/user/hand/right/input/y", "left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip" } @@ -162,12 +169,19 @@ speech-driven Auto Insert, target coverage and headset acceptance remain unverified. The setting is preserved on upgrade and a failed preference write applies only to the current session. +`quick_inputs` is an editable list of 1–6 nonempty, printable ASCII strings, +each at most 64 characters. Edit the JSON file and restart; there is no headset +text editor. Inputs are literal (not expanded or interpreted by FrameYap) and +are sent to the current Gamescope focus, so check the destination before Submit. `buttons` maps named OpenVR actions (`left_grip`, `right_grip`, `ptt`, `cancel`, -`insert`, `enter`) to Frame physical `/user/hand/{left|right}/input/NAME` +`insert`, `enter`, `quick_chat`) to Frame physical `/user/hand/{left|right}/input/NAME` button paths. Omitted actions retain their bundled defaults; an empty string disables a mapping, including after an upgrade. The Frame defaults are right -X = hold-to-talk, B = Cancel, A = Insert + space, Y = Insert + Enter. Existing -configs with empty actions retain those disabled mappings; change them explicitly +X = hold-to-talk, B = Cancel, A = Insert + space, Y = quick chat. Enter has no +single-button mapping by default; the left grip double-tap still submits. +An existing config mapping `enter` to right Y is migrated to quick chat in memory +when `quick_chat` is absent; this does not overwrite custom mappings. +Existing configs with empty actions retain those disabled mappings; change them explicitly or use SteamVR's binding editor. Paths must be distinct. Only the Frame binding is customized; SteamVR user overrides may still supersede it. On customized launches a generated action manifest and adjacent bindings are placed in `$XDG_CACHE_HOME/frameyap/bindings` diff --git a/install.sh b/install.sh index 287633c..3f18004 100755 --- a/install.sh +++ b/install.sh @@ -39,6 +39,7 @@ CONFIG_DEFAULTS = { "lock_layout": False, "clock_24h": False, "date_format": "mdy", + "quick_inputs": ["/new", "/questions", "/help"], "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", @@ -46,7 +47,8 @@ CONFIG_DEFAULTS = { "buttons": {"left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip", "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", - "insert": "/user/hand/right/input/a", "enter": "/user/hand/right/input/y"}, + "insert": "/user/hand/right/input/a", "enter": "", + "quick_chat": "/user/hand/right/input/y"}, } COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z") BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z") @@ -107,6 +109,10 @@ def normalized_config(data): fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") fixed["date_format"] = date if date in ("off", "mdy", "dmy", "iso") else "mdy" + quick = data.get("quick_inputs", CONFIG_DEFAULTS["quick_inputs"]) + fixed["quick_inputs"] = quick if (isinstance(quick, list) and 1 <= len(quick) <= 6 + and all(isinstance(item, str) and 1 <= len(item) <= 64 and item.strip(" ") + and all(32 <= ord(ch) <= 126 for ch in item) for item in quick)) else CONFIG_DEFAULTS["quick_inputs"].copy() source = data.get("wrist") source = source if isinstance(source, dict) else {} fixed["wrist"] = {} @@ -123,6 +129,8 @@ def normalized_config(data): valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme" else (not value or BUTTON_RE.fullmatch(value)))) fixed[section][name] = value if valid else default + if "quick_chat" not in (data.get("buttons") if isinstance(data.get("buttons"), dict) else {}) and fixed["buttons"]["enter"] == "/user/hand/right/input/y": + fixed["buttons"]["enter"] = "" # former default Y is now quick chat paths = [value for value in fixed["buttons"].values() if value] if len(paths) != len(set(paths)): fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy() @@ -131,6 +139,7 @@ def normalized_config(data): fixed["font"] = "" if size() > 4096: fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy() + fixed["quick_inputs"] = CONFIG_DEFAULTS["quick_inputs"].copy() return fixed diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 390a60d..18876ae 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -28,6 +28,7 @@ CONFIG_DEFAULTS = { "lock_layout": False, "clock_24h": False, "date_format": "mdy", + "quick_inputs": ["/new", "/questions", "/help"], "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", @@ -35,7 +36,8 @@ CONFIG_DEFAULTS = { "buttons": {"left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip", "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", - "insert": "/user/hand/right/input/a", "enter": "/user/hand/right/input/y"}, + "insert": "/user/hand/right/input/a", "enter": "", + "quick_chat": "/user/hand/right/input/y"}, } COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z") BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z") @@ -96,6 +98,10 @@ def normalized_config(data): fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") fixed["date_format"] = date if date in ("off", "mdy", "dmy", "iso") else "mdy" + quick = data.get("quick_inputs", CONFIG_DEFAULTS["quick_inputs"]) + fixed["quick_inputs"] = quick if (isinstance(quick, list) and 1 <= len(quick) <= 6 + and all(isinstance(item, str) and 1 <= len(item) <= 64 and item.strip(" ") + and all(32 <= ord(ch) <= 126 for ch in item) for item in quick)) else CONFIG_DEFAULTS["quick_inputs"].copy() source = data.get("wrist") source = source if isinstance(source, dict) else {} fixed["wrist"] = {} @@ -112,6 +118,8 @@ def normalized_config(data): valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme" else (not value or BUTTON_RE.fullmatch(value)))) fixed[section][name] = value if valid else default + if "quick_chat" not in (data.get("buttons") if isinstance(data.get("buttons"), dict) else {}) and fixed["buttons"]["enter"] == "/user/hand/right/input/y": + fixed["buttons"]["enter"] = "" # former default Y is now quick chat paths = [value for value in fixed["buttons"].values() if value] if len(paths) != len(set(paths)): fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy() @@ -120,6 +128,7 @@ def normalized_config(data): fixed["font"] = "" if size() > 4096: fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy() + fixed["quick_inputs"] = CONFIG_DEFAULTS["quick_inputs"].copy() return fixed diff --git a/src/config.cpp b/src/config.cpp index 14274b3..e602165 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -13,7 +13,8 @@ namespace { struct Json { std::string value; std::map object; - bool is_string = false, is_object = false, is_number = false, is_bool = false; + std::vector array; + bool is_string = false, is_object = false, is_array = false, is_number = false, is_bool = false; size_t start = 0, end = 0; // original value span for non-destructive config updates }; struct Parser { @@ -88,8 +89,9 @@ struct Parser { fail(); } if (eat('[')) { + result.is_array = true; if (eat(']')) return done(); - do { parse(depth + 1); if (eat(']')) return done(); } while (eat(',')); + do { result.array.push_back(parse(depth + 1)); if (eat(']')) return done(); } while (eat(',')); fail(); } size_t start = pos; @@ -217,6 +219,18 @@ Config load_config(const std::filesystem::path& path) { if (!value.is_string || (value.value != "normal" && value.value != "experimental")) throw std::runtime_error("Config input_priority must be normal or experimental"); config.experimental_input_priority = value.value == "experimental"; + } else if (key == "quick_inputs") { + if (!value.is_array || value.array.empty() || value.array.size() > 6) + throw std::runtime_error("Config quick_inputs must have 1 to 6 entries"); + config.quick_inputs.clear(); + for (const auto& entry : value.array) { + // Short single-line literals: no hidden control or normalization. + if (!entry.is_string || entry.value.empty() || entry.value.size() > 64 || + !std::all_of(entry.value.begin(), entry.value.end(), [](unsigned char c) { return c >= 32 && c <= 126; }) || + entry.value.find_first_not_of(' ') == std::string::npos) + throw std::runtime_error("Config quick_inputs entries must be 1-64 printable ASCII characters"); + config.quick_inputs.push_back(entry.value); + } } else if (key == "wrist") { if (!value.is_object) throw std::runtime_error("Config wrist must be an object"); for (const auto& [name, v] : value.object) { @@ -245,7 +259,7 @@ Config load_config(const std::filesystem::path& path) { } else if (key == "buttons") { if (!value.is_object) throw std::runtime_error("Config buttons must be an object"); for (const auto& [name, v] : value.object) { - if (name != "left_grip" && name != "right_grip" && name != "ptt" && name != "cancel" && name != "insert" && name != "enter") + if (name != "left_grip" && name != "right_grip" && name != "ptt" && name != "cancel" && name != "insert" && name != "enter" && name != "quick_chat") throw std::runtime_error("Unknown OpenVR button action: " + name); if (!v.is_string) throw std::runtime_error("Button path must be a string"); auto path = v.value; @@ -342,8 +356,12 @@ std::filesystem::path action_manifest(const std::string& assets, const Config& c std::map buttons{{"left_grip", "/user/hand/left/input/grip"}, {"right_grip", "/user/hand/right/input/grip"}, {"ptt", "/user/hand/right/input/x"}, {"cancel", "/user/hand/right/input/b"}, {"insert", "/user/hand/right/input/a"}, - {"enter", "/user/hand/right/input/y"}}; + {"enter", ""}, {"quick_chat", "/user/hand/right/input/y"}}; for (const auto& [key, path] : config.buttons) buttons[key] = path; + // Existing configs may explicitly repeat the old default Y -> enter. + // Migrate that default in memory; do not rewrite the user's file. + if (!config.buttons.contains("quick_chat") && buttons["enter"] == "/user/hand/right/input/y") + buttons["enter"].clear(); std::string binding = "{\"controller_type\":\"frame_controller\",\"name\":\"FrameYap configured controls\",\"bindings\":{\"/actions/frameyap\":{\"sources\":["; std::map used; for (const auto& [action, path] : buttons) { diff --git a/src/config.hpp b/src/config.hpp index 202f78e..f9661da 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace frameyap { using Rgba = std::array; @@ -27,6 +28,7 @@ struct Config { WristPlacement wrist; // OpenVR action name -> physical Frame controller input path; empty disables it. std::map buttons; + std::vector quick_inputs{"/new", "/questions", "/help"}; }; std::filesystem::path default_config_path(); Config load_config(const std::filesystem::path& path); diff --git a/src/core.cpp b/src/core.cpp index 32dacc1..75dcc45 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -100,4 +100,18 @@ DeliveryResult deliver_enter(Session& session, const DeliveryFactory& acquire) { catch (...) { return DeliveryResult::EnterUncertain; } return DeliveryResult::EnterQueued; } +DeliveryResult deliver_quick(std::string_view literal, const DeliveryFactory& acquire) { + const auto safe = literal_text(literal); + if (safe.empty() || safe != literal || safe.size() > 64) + throw std::runtime_error("Invalid quick input"); + auto input = lease(acquire); + try { input->text(safe); } + catch (...) { return DeliveryResult::TextUncertain; } + input.reset(); // Gamescope leases are single-use; never retry an uncertain text send. + try { input = lease(acquire); } + catch (...) { return DeliveryResult::TextQueuedEnterUnavailable; } + try { input->enter(); } + catch (...) { return DeliveryResult::EnterUncertain; } + return DeliveryResult::EnterQueued; +} } diff --git a/src/core.hpp b/src/core.hpp index e640e39..8f13753 100644 --- a/src/core.hpp +++ b/src/core.hpp @@ -48,4 +48,6 @@ DeliveryResult deliver_insert(Session& session, const DeliveryFactory& acquire); // Review: insert text plus a trailing space, then explicitly send Enter only // after successful text delivery. Ready/Queued: Enter alone. DeliveryResult deliver_enter(Session& session, const DeliveryFactory& acquire); +// Explicit quick input: exact configured literal, then Enter (no trailing space). +DeliveryResult deliver_quick(std::string_view literal, const DeliveryFactory& acquire); } diff --git a/src/main.cpp b/src/main.cpp index 3d22629..7ded893 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,8 +31,8 @@ void help() { "SteamVR Developer setting Enable global input from overlays.\n" "Right X: hold to speak, release to review (default Frame binding).\n" "Grip gestures are remappable but may be unavailable in the dashboard.\n" - "Right B: cancel; A: insert + space; Y: insert pending text + Enter.\n" - "Left grip: double-tap for the same explicit Enter action when active.\n" + "Right B: cancel; A: insert + space; Y: quick chat picker.\n" + "Left grip: double-tap to Submit (Enter alone when no review).\n" "Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n" "Insert approves current focus; Enter is never automatic.\n" "No device access unless an explicit runtime/check/registration mode is used.\n"; @@ -110,7 +110,8 @@ int main(int argc, char** argv) { case frameyap::UiAction::Record: return "Record"; case frameyap::UiAction::Cancel: return "Cancel"; case frameyap::UiAction::Insert: return "Insert"; - case frameyap::UiAction::Enter: return "Enter"; + case frameyap::UiAction::Enter: return "Submit"; + case frameyap::UiAction::QuickChat: return "QuickChat"; case frameyap::UiAction::Quit: return "Quit"; case frameyap::UiAction::Toggle: return "Toggle"; } diff --git a/src/overlay.cpp b/src/overlay.cpp index a5271e0..0241e1e 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -43,7 +43,7 @@ Matrix34 matrix(const vr::HmdMatrix34_t& value) { for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) result[r][c] = value.m[r][c]; return result; } -constexpr std::array action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}}; +constexpr std::array action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter", "quick_chat"}}; void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) { if (err != vr::VROverlayError_None) throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err)); @@ -75,7 +75,7 @@ struct Overlay::Impl { vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid; std::unique_ptr gpu_texture; vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle; - std::array actions{}; + std::array actions{}; std::filesystem::path settings_path; std::filesystem::path laser_settings_path; Mount mount; @@ -101,7 +101,7 @@ struct Overlay::Impl { vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid; DoubleTap left; GripRecord right; - std::array edges{}; + std::array edges{}; std::array poses{}; bool grip_capture = false, ptt_capture = false; bool focus = true; @@ -577,19 +577,19 @@ struct Overlay::Impl { auto [ra, rd] = digital(1); const auto now = DoubleTap::Clock::now(); if (left.update(la && focus, ld, available(UiAction::Enter), now)) result.push_back(UiAction::Enter); - switch (right.update(ra && focus, rd, now)) { + switch (right.update(ra && focus && !panel.quick_open, rd, now)) { case GripRecord::Change::Begin: grip_capture = true; result.push_back(UiAction::BeginRecord); break; case GripRecord::Change::End: grip_capture = false; result.push_back(UiAction::EndRecord); break; case GripRecord::Change::Cancel: grip_capture = false; result.push_back(UiAction::Cancel); break; case GripRecord::Change::None: break; } - constexpr std::array mapped{{UiAction::Record, UiAction::Cancel, UiAction::Insert, UiAction::Enter}}; + constexpr std::array mapped{{UiAction::Record, UiAction::Cancel, UiAction::Insert, UiAction::Enter, UiAction::QuickChat}}; for (size_t i = 0; i < edges.size(); ++i) { auto [active, down] = digital(i + 2); - if (i == 0 && (!active || !focus || !panel.enabled) && edges[i].reset()) { + if (i == 0 && (!active || !focus || !panel.enabled || panel.quick_open) && edges[i].reset()) { ptt_capture = false; result.push_back(UiAction::Cancel); } - auto change = edges[i].update(active && focus && (panel.enabled || i == 1), down); + auto change = edges[i].update(active && focus && ((panel.enabled && (i != 0 || !panel.quick_open)) || i == 1), down); if (i == 0) { if (change == NeutralEdge::Change::Down) { ptt_capture = true; result.push_back(UiAction::BeginRecord); @@ -611,6 +611,7 @@ std::vector Overlay::poll() { return impl_->poll(); } void Overlay::draw(const Panel& panel) { impl_->draw(panel); } bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; } bool Overlay::auto_insert() const { return impl_->config.auto_insert; } +const std::vector& Overlay::quick_inputs() const { return impl_->config.quick_inputs; } std::string Overlay::controls_status() { // Compare the same actions across modes, before and after our pose/role gate. // IsInputAvailable and a successful UpdateActionState are not delivery proof. diff --git a/src/overlay.hpp b/src/overlay.hpp index db45029..b017e11 100644 --- a/src/overlay.hpp +++ b/src/overlay.hpp @@ -3,17 +3,26 @@ #include #include #include +#include #include namespace frameyap { -enum class UiAction { Toggle, Record, BeginRecord, EndRecord, Cancel, Insert, Enter, Quit }; +enum class UiAction { Toggle, Record, BeginRecord, EndRecord, Cancel, Insert, Enter, QuickChat, Quit }; struct Panel { + Panel() = default; + Panel(std::string status, std::string transcript, std::string detail, + bool enabled, bool recording, bool record_available = true) + : status(std::move(status)), transcript(std::move(transcript)), detail(std::move(detail)), + enabled(enabled), recording(recording), record_available(record_available) {} std::string status; std::string transcript; std::string detail; bool enabled = false; bool recording = false; bool record_available = true; + bool quick_open = false; + size_t quick_selected = 0; + std::vector quick_inputs; }; class Overlay { public: @@ -26,6 +35,7 @@ public: void draw(const Panel& panel); bool advanced_debug() const; bool auto_insert() const; + const std::vector& quick_inputs() const; std::string controls_status(); // diagnostic only, no input delivery std::string pointer_status() const; // diagnostic counters, no input delivery private: diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 2b50bdc..57ba4df 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -43,8 +43,8 @@ constexpr std::array buttons{{ {{32, 574, 176, 68}, Control::Record, "Record"}, {{222, 574, 176, 68}, Control::Cancel, "Cancel"}, {{412, 574, 176, 68}, Control::Insert, "Insert"}, - {{602, 574, 176, 68}, Control::Enter, "Enter"}, - {{792, 574, 176, 68}, Control::Quit, "Quit"}, + {{602, 574, 176, 68}, Control::Enter, "Submit"}, + {{792, 574, 176, 68}, Control::Quit, "Hold Quit"}, {{32, 234, 454, 58}, Control::World, "World space"}, {{514, 234, 454, 58}, Control::Head, "Head"}, {{32, 308, 454, 58}, Control::Left, "Left wrist"}, @@ -111,6 +111,8 @@ struct PanelSurface::Impl { ClockLabel displayed_clock; std::string placement_note, binding_note; std::array pressed{{-1, -1}}; + std::array press_time{}; + int hold_progress = 0; int drag_cursor = -1; std::vector lines; size_t page = 0; @@ -245,14 +247,16 @@ struct PanelSurface::Impl { } size_t page_count() const { return std::max(size_t(1), (lines.size() + lines_per_page - 1) / lines_per_page); } bool available(UiAction a) const { - if (a == UiAction::Record) return panel.recording || panel.record_available; - if (a == UiAction::Insert) return panel.enabled && !panel.recording && !panel.transcript.empty(); + if (a == UiAction::Record) return !panel.quick_open && (panel.recording || panel.record_available); + if (a == UiAction::Insert) return !panel.quick_open && panel.enabled && !panel.recording && !panel.transcript.empty(); if (a == UiAction::Enter) return panel.enabled && !panel.recording; + if (a == UiAction::QuickChat) return panel.enabled && !panel.recording && !panel.quick_inputs.empty(); if (a == UiAction::Toggle) return false; return true; } bool visible(Control c) const { - if (c == Control::Prev || c == Control::Next) return tab == Tab::Review; + if (panel.quick_open && c != Control::Cancel && c != Control::Enter && c != Control::Quit) return false; + if (c == Control::Prev || c == Control::Next) return tab == Tab::Review && !panel.quick_open; if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug || c == Control::AutoInsert || c == Control::Clock24h || c == Control::Date || c == Control::LockLayout) return tab == Tab::Settings; @@ -284,9 +288,18 @@ struct PanelSurface::Impl { lines = wrap(p.transcript.empty() ? "Your words will appear here.\nReview them, then choose Insert." : p.transcript, 32, 904); page = 0; dirty = true; } - if (p.status != panel.status || p.detail != panel.detail || p.enabled != panel.enabled || p.recording != panel.recording) + if (p.status != panel.status || p.enabled != panel.enabled || p.recording != panel.recording || + p.quick_open != panel.quick_open || p.quick_selected != panel.quick_selected || p.quick_inputs != panel.quick_inputs) dirty = true; + if (p.quick_open != panel.quick_open) reset(); panel = p; + if (panel.quick_open && tab != Tab::Review) { tab = Tab::Review; dirty = true; } + int progress = 0; + for (size_t cursor = 0; cursor < pressed.size(); ++cursor) + if (pressed[cursor] == 10) + progress = std::max(progress, std::clamp(int(std::chrono::duration_cast( + PanelSurface::Clock::now() - press_time[cursor]).count() / 100) + 1, 1, 10)); + if (progress != hold_progress) { hold_progress = progress; dirty = true; } auto label = panel_clock(clock_time, clock_24h, date_format); if (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true; if (!dirty) return false; @@ -305,16 +318,22 @@ struct PanelSurface::Impl { auto status = wrap(panel.status, 27, 790); text(status.front(), 70, 109, 27, ink, 844); if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968); - if (tab == Tab::Review) { + if (tab == Tab::Review && panel.quick_open) { + rounded({32, 212, 936, 324}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); + text("QUICK CHAT Y: NEXT SUBMIT: SEND CANCEL: CLOSE", 48, 243, 22, cyan, 952); + for (size_t i = 0; i < panel.quick_inputs.size() && i < 6; ++i) { + const Rect row{48, 254 + int(i) * 45, 904, 40}; + const bool selected = i == panel.quick_selected; + rounded(row, 9, selected ? mix(card, cyan, .22f) : card, + selected ? cyan : mix(card, muted, .4f), selected ? .2f : 0.f, selected ? 2 : 1); + text(panel.quick_inputs[i], 66, row.y + 28, 26, selected ? ink : muted, 930); + } + } else if (tab == Tab::Review) { rounded({32, 212, 936, 178}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); for (size_t i = 0; i < lines_per_page && page * lines_per_page + i < lines.size(); ++i) text(lines[page * lines_per_page + i], 48, 246 + int(i) * 40, 32, panel.transcript.empty() ? muted : ink, 952); text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 436, 23, muted, 790); - auto detail = wrap(panel.detail, 24, 904); - for (size_t i = 0; i < std::min(size_t(2), detail.size()); ++i) - text(detail[i], 32, 486 + int(i) * 30, 24, muted, 936); - if (detail.size() > 2) text("[detail truncated]", 730, 546, 22, pink, 968); if (!binding_note.empty()) text(binding_note, 32, 203, 20, muted, 968); } else { text("Auto insert needs stable X focus; debug logs may contain speech.", 32, 204, 18, pink, 968); @@ -341,6 +360,12 @@ struct PanelSurface::Impl { rounded(b.r, std::min(16, b.r.h / 3), fill, !on ? mix(card, muted, .13f) : highlighted ? accent : mix(card, muted, .38f), highlighted ? .23f : 0.f, highlighted ? 2 : 1); + if (b.id == Control::Quit && (pressed[0] == int(i) || pressed[1] == int(i))) { + const int cursor = pressed[0] == int(i) ? 0 : 1; + const float fraction = std::clamp(float(std::chrono::duration_cast( + PanelSurface::Clock::now() - press_time[cursor]).count()) / PanelSurface::quit_hold.count(), 0.f, 1.f); + rect({b.r.x + 7, b.r.y + b.r.h - 9, int((b.r.w - 14) * fraction), 3}, pink); + } const std::string label = b.id == Control::Record && panel.recording ? "Stop" : b.id == Control::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") : b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" : @@ -389,7 +414,7 @@ bool PanelSurface::available(UiAction a) const { return impl_->available(a); } bool PanelSurface::dragging(unsigned cursor) const { return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor; } -std::optional PanelSurface::pointer_down(unsigned cursor, float x, float y) { +std::optional PanelSurface::pointer_down(unsigned cursor, float x, float y, Clock::time_point now) { if (cursor >= impl_->pressed.size() || impl_->drag_cursor >= 0) return {}; const auto contains = [&](Bounds b) { return Rect{b.x, b.y, b.w, b.h}.contains(x, y); }; if (!impl_->layout_locked && (contains(grab) || contains(scale))) { @@ -398,9 +423,11 @@ std::optional PanelSurface::pointer_down(unsigned cursor, float x return contains(grab) ? PanelDragKind::Grab : PanelDragKind::Scale; } impl_->pressed[cursor] = impl_->hit(x, y); + impl_->press_time[cursor] = now; + if (impl_->pressed[cursor] == 10) impl_->dirty = true; return {}; } -SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) { +SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::time_point now) { SurfaceEvent result; if (cursor >= impl_->pressed.size()) return result; if (impl_->drag_cursor >= 0) { @@ -408,8 +435,10 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) { return result; } int index = std::exchange(impl_->pressed[cursor], -1); + if (index == 10) impl_->dirty = true; if (index < 0 || impl_->hit(x, y) != index) return result; auto c = buttons[index].id; + if (c == Control::Quit && now - impl_->press_time[cursor] < quit_hold) return result; if (c == Control::Record) result.action = impl_->panel.recording ? UiAction::EndRecord : UiAction::BeginRecord; else if (auto a = action(c)) result.action = a; else if (auto m = mounting(c)) { impl_->mount = *m; result.mount = *m; impl_->reset(); impl_->dirty = true; } diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index fca6978..5ce1c6b 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -3,6 +3,7 @@ #include "config.hpp" #include "panel_drag.hpp" #include +#include #include #include #include @@ -37,8 +38,10 @@ public: bool render(const Panel& panel); const std::vector& pixels() const; // Handles capture one cursor and never authorize a UI action on release. - std::optional pointer_down(unsigned cursor, float x, float y); - SurfaceEvent pointer_up(unsigned cursor, float x, float y); + using Clock = std::chrono::steady_clock; + static constexpr auto quit_hold = std::chrono::milliseconds(900); + std::optional pointer_down(unsigned cursor, float x, float y, Clock::time_point now = Clock::now()); + SurfaceEvent pointer_up(unsigned cursor, float x, float y, Clock::time_point now = Clock::now()); bool dragging(unsigned cursor) const; // OpenVR intersection masks use top-left coordinates, unlike mouse events. std::vector input_regions() const; diff --git a/src/runtime.cpp b/src/runtime.cpp index 65fbbeb..f232b61 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -54,8 +54,11 @@ int run(const Options& options) { Worker worker; Audio audio; Session session; - std::string detail = "Review mode. Other apps may also hear your mic. Enter is explicit."; + std::string detail; + std::string status_note; bool quit = false; + bool quick_open = false; + size_t quick_selected = 0; bool advanced_debug = overlay.advanced_debug(); bool auto_insert = overlay.auto_insert(); std::unique_ptr armed_focus; @@ -68,19 +71,25 @@ int run(const Options& options) { switch (result) { case DeliveryResult::Ignored: break; case DeliveryResult::TextQueued: - detail = "Text and trailing space queued to current focus. Enter remains explicit."; break; + detail = "Text and trailing space queued to current focus. Enter remains explicit."; + status_note = "Text queued - Enter not sent"; break; case DeliveryResult::EnterQueued: detail = submit ? "Explicit Enter queued to current focus; not a delivery receipt." - : "Input queued to current focus; not a delivery receipt."; break; + : "Input queued to current focus; not a delivery receipt."; + status_note = "Enter queued - check destination"; break; case DeliveryResult::TextUncertain: - detail = "Text delivery uncertain; Enter not sent. Not retried; check destination."; break; + detail = "Text delivery uncertain; Enter not sent. Not retried; check destination."; + status_note = "Text uncertain - Enter not sent"; break; case DeliveryResult::EnterUncertain: - detail = "Enter delivery uncertain; not retried. Check destination."; break; + detail = "Enter delivery uncertain; not retried. Check destination."; + status_note = "Enter uncertain - check destination"; break; case DeliveryResult::TextQueuedEnterUnavailable: - detail = "Text and trailing space queued; Enter unavailable and not sent. Check destination."; break; + detail = "Text queued; Enter unavailable and not sent. Check destination."; + status_note = "Text queued - Enter unavailable"; break; } }; auto warm = [&] { + status_note.clear(); quick_open = false; audio.close(); worker.stop(); session = Session{}; armed_focus.reset(); worker.start(options.python, options.worker, options.model, options.threads, advanced_debug); detail = "Loading local model; microphone closed. Record again when Ready."; @@ -101,6 +110,7 @@ int run(const Options& options) { if (!audio.open()) audio.prepare(); // recovery only; normal PTT never opens the device if (session.state() == State::Error) session.cancel(); if (!session.record()) return; + status_note.clear(); armed_focus.reset(); if (auto_insert) { auto candidate = std::make_unique(); @@ -173,10 +183,14 @@ int run(const Options& options) { // Drawing precedes input polling: Enter is disabled until Ready is visible. const auto status = session.state() == State::Error && worker.ready() ? "Retry available - local model still loaded" : state_label(session.state()); - Panel panel{status, session.text(), detail, + if (quick_open && (session.state() == State::Recording || session.state() == State::Transcribing || session.state() == State::Warming || session.state() == State::Error)) quick_open = false; + Panel panel{quick_open ? "Quick chat" : status_note.empty() ? status : status_note, session.text(), detail, session.state() != State::Error && session.state() != State::Warming && session.state() != State::Transcribing, session.state() == State::Recording, session.state() != State::Warming && session.state() != State::Transcribing && session.state() != State::Review}; + panel.quick_open = quick_open; + panel.quick_selected = quick_selected; + panel.quick_inputs = overlay.quick_inputs(); if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s"; overlay.draw(panel); auto actions = overlay.poll(); @@ -200,11 +214,14 @@ int run(const Options& options) { case UiAction::Quit: quit = true; break; case UiAction::Toggle: case UiAction::Record: + if (quick_open) break; if (session.state() == State::Recording) stop_record(); else start_record(); break; - case UiAction::BeginRecord: start_record(); break; + case UiAction::BeginRecord: if (!quick_open) start_record(); break; case UiAction::EndRecord: stop_record(); break; case UiAction::Cancel: { + if (quick_open) { quick_open = false; break; } + status_note.clear(); auto state = session.state(); armed_focus.reset(); audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded."; if (state == State::Warming || state == State::Transcribing) { @@ -216,10 +233,19 @@ int run(const Options& options) { break; } case UiAction::Insert: - delivery_detail(deliver_insert(session, acquire), false); + if (!quick_open) delivery_detail(deliver_insert(session, acquire), false); break; case UiAction::Enter: - delivery_detail(deliver_enter(session, acquire), true); + if (quick_open) { + quick_open = false; // one authorization, no repeat on a stale input + delivery_detail(deliver_quick(overlay.quick_inputs().at(quick_selected), acquire), true); + } else delivery_detail(deliver_enter(session, acquire), true); + break; + case UiAction::QuickChat: + if (panel.enabled && !panel.recording && !overlay.quick_inputs().empty()) { + if (quick_open) quick_selected = (quick_selected + 1) % overlay.quick_inputs().size(); + else { quick_selected = 0; quick_open = true; } + } break; } } catch (const std::exception& e) { @@ -228,6 +254,9 @@ int run(const Options& options) { // the IPC stream was partially written. if (session.state() == State::Recording || session.state() == State::Transcribing) { audio.close(); session.fail(); + status_note.clear(); + } else { + status_note = "Input unavailable - check destination"; } detail = e.what(); } diff --git a/tests/config_test.cpp b/tests/config_test.cpp index 8917f76..7273181 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -39,6 +39,7 @@ int main(int argc, char** argv) { assert(default_config_path().empty()); ::setenv("XDG_CONFIG_HOME", dir.c_str(), 1); assert(load_config(path).buttons.empty()); + assert((load_config(path).quick_inputs == std::vector{"/new", "/questions", "/help"})); assert(!load_config(path).experimental_input_priority); assert(!load_config(path).advanced_debug); assert(!load_config(path).auto_insert); @@ -48,6 +49,9 @@ int main(int argc, char** argv) { assert(load_config(path).wrist.width == .30f); auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json"); assert(example.buttons.at("ptt") == "/user/hand/right/input/x"); + assert(example.buttons.at("quick_chat") == "/user/hand/right/input/y"); + assert(example.buttons.at("enter").empty()); + assert(example.quick_inputs.size() == 3); assert(example.font.empty()); assert(!example.experimental_input_priority); assert(!example.advanced_debug); @@ -183,6 +187,7 @@ int main(int argc, char** argv) { assert(generated.find("/actions/frameyap/in/right_grip") == std::string::npos); assert(generated.find("/user/hand/right/input/a") != std::string::npos); assert(generated.find("/user/hand/right/input/y") != std::string::npos); + assert(generated.find("/actions/frameyap/in/quick_chat") != std::string::npos); config.buttons["cancel"] = ""; config.buttons["insert"] = ""; config.buttons["enter"] = ""; auto disabled = action_manifest(argv[1], config); generated = get(disabled.parent_path() / "bindings_frame_controller.json"); @@ -190,6 +195,17 @@ int main(int argc, char** argv) { assert(generated.find("/actions/frameyap/in/insert") == std::string::npos); assert(generated.find("/actions/frameyap/in/enter") == std::string::npos); assert(action_manifest(argv[1], {}) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json")); + put(path, R"({"buttons":{"enter":"/user/hand/right/input/y"}})"); + generated = get(action_manifest(argv[1], load_config(path)).parent_path() / "bindings_frame_controller.json"); + assert(generated.find("/actions/frameyap/in/quick_chat") != std::string::npos); + assert(generated.find("/actions/frameyap/in/enter") == std::string::npos); + put(path, R"({"quick_inputs":["/new","custom phrase"]})"); + assert((load_config(path).quick_inputs == std::vector{"/new", "custom phrase"})); + for (const auto* invalid : {R"({"quick_inputs":[]})", R"({"quick_inputs":[42]})", + R"({"quick_inputs":["line\nfeed"]})", R"({"quick_inputs":["\u001b"]})", + R"({"quick_inputs":["", "/new"]})", R"({"quick_inputs":"/new"})"}) { + put(path, invalid); fails([&] { load_config(path); }); + } put(path, R"({"buttons":{"ptt":"/user/hand/left/input/grip"}})"); fails([&] { action_manifest(argv[1], load_config(path)); }); // overlapping bindings are never silently chosen put(path, R"({"buttons":{"ptt":"/user/hand/left/input/x\"}hack"}})"); diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 48735ea..90d5d6e 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -41,6 +41,26 @@ Session review(std::string_view transcript) { return session; } void delivery_checks() { + { + auto s = review("pending review"); FakeDelivery fake; + CHECK(deliver_quick("/questions", fake.factory()) == DeliveryResult::EnterQueued); + CHECK((fake.events == std::vector{"text:/questions", "enter"})); + CHECK(fake.acquisitions == 2 && fake.active_leases == 0); + CHECK(s.state() == State::Review && s.text() == "pending review"); + for (auto bad : {"", " ", "two\nlines", "\x1b[0m"}) { + try { (void)deliver_quick(bad, fake.factory()); CHECK(false); } + catch (const std::runtime_error&) {} + } + CHECK(fake.acquisitions == 2); + } + { + FakeDelivery fake; fake.fail_text = true; + CHECK(deliver_quick("/new", fake.factory()) == DeliveryResult::TextUncertain); + CHECK((fake.events == std::vector{"text:/new"})); + FakeDelivery unavailable; unavailable.fail_acquisition = 2; + CHECK(deliver_quick("/new", unavailable.factory()) == DeliveryResult::TextQueuedEnterUnavailable); + CHECK((unavailable.events == std::vector{"text:/new"})); + } { auto s = review("Hello 世界"); FakeDelivery fake; CHECK(deliver_insert(s, fake.factory()) == DeliveryResult::TextQueued); diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 71ac247..64b8b42 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -134,6 +134,18 @@ int main(int argc, char** argv) { if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-review.ppm"); assert(click(surface, 480, 610).action == UiAction::Insert); assert(click(surface, 680, 610).action == UiAction::Enter); + p.quick_inputs = {"/new", "/questions", "/help"}; + p.quick_open = true; + assert(surface.render(p)); + const auto first_quick = surface.pixels(); + no_action(click(surface, 900, 430)); // review pagination hidden behind picker + p.quick_selected = 1; + assert(surface.render(p) && surface.pixels() != first_quick); + assert(click(surface, 680, 610).action == UiAction::Enter); + assert(click(surface, 280, 610).action == UiAction::Cancel); + no_action(click(surface, 480, 610)); + p.quick_open = false; p.quick_selected = 0; + assert(surface.render(p)); // Down/up must use the same controller, and release rechecks availability. surface.pointer_down(0, 480, 610); no_action(surface.pointer_up(1, 480, 610)); @@ -282,8 +294,17 @@ int main(int argc, char** argv) { // A press does not re-upload pixels. A completed action still reaches the // caller; meaningful panel changes redraw. assert(!surface.render(p)); - surface.pointer_down(0, 900, 610); assert(!surface.render(p)); - assert(surface.pointer_up(0, 900, 610).action == UiAction::Quit); + const auto at = PanelSurface::Clock::now(); + surface.pointer_down(0, 900, 610, at); + no_action(surface.pointer_up(0, 900, 610, at + PanelSurface::quit_hold - std::chrono::milliseconds(1))); + surface.pointer_down(0, 900, 610, at); + no_action(surface.pointer_up(0, 280, 610, at + PanelSurface::quit_hold)); + surface.pointer_down(0, 900, 610, at); + surface.reset_pointers(); + no_action(surface.pointer_up(0, 900, 610, at + PanelSurface::quit_hold)); + surface.pointer_down(0, 900, 610, at); + assert(surface.pointer_up(0, 900, 610, at + PanelSurface::quit_hold).action == UiAction::Quit); + assert(surface.render(p)); assert(!surface.render(p)); std::cout << "panel checks passed (no OpenVR, microphone or input injection)\n"; } diff --git a/tests/test_action_bindings.py b/tests/test_action_bindings.py index 8d741ff..6c56d7e 100644 --- a/tests/test_action_bindings.py +++ b/tests/test_action_bindings.py @@ -14,12 +14,15 @@ class ActionBindingTests(unittest.TestCase): config = json.loads((ROOT / "assets/config.example.json").read_text()) sources = binding["bindings"]["/actions/frameyap"]["sources"] self.assertEqual(len({s["path"] for s in sources}), len(sources)) - for action, button in {"ptt": "x", "cancel": "b", "insert": "a", "enter": "y"}.items(): + for action, button in {"ptt": "x", "cancel": "b", "insert": "a", "quick_chat": "y"}.items(): name = f"/actions/frameyap/in/{action}" path = f"/user/hand/right/input/{button}" self.assertIn({"name": name, "type": "boolean"}, manifest["actions"]) self.assertEqual(config["buttons"][action], path) self.assertEqual([s["path"] for s in sources if s["inputs"]["click"]["output"] == name], [path]) + self.assertEqual(config["buttons"]["enter"], "") + self.assertFalse(any(s["inputs"]["click"]["output"] == "/actions/frameyap/in/enter" for s in sources)) + self.assertEqual(config["quick_inputs"], ["/new", "/questions", "/help"]) def test_right_x_is_hold_to_talk(self): manifest = json.loads((ROOT / "assets/actions.json").read_text()) diff --git a/tests/test_installer.py b/tests/test_installer.py index e685d99..a74d792 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -133,7 +133,14 @@ class InstallTests(unittest.TestCase): self.assertIs(fixed["lock_layout"], False) self.assertIs(fixed["clock_24h"], False) self.assertEqual(fixed["date_format"], "mdy") + self.assertEqual(fixed["quick_inputs"], ["/new", "/questions", "/help"]) + self.assertEqual(fixed["buttons"]["quick_chat"], "/user/hand/right/input/y") self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"]) + legacy = installer.normalized_config({"buttons": {"enter": "/user/hand/right/input/y"}}) + self.assertEqual(legacy["buttons"]["enter"], "") + self.assertEqual(legacy["buttons"]["quick_chat"], "/user/hand/right/input/y") + self.assertEqual(installer.normalized_config({"quick_inputs": ["oops\n"]})["quick_inputs"], + ["/new", "/questions", "/help"]) backups = list(config.parent.glob("config.json.backup-*")) self.assertEqual(len(backups), 1) self.assertEqual(backups[0].read_bytes(), original) @@ -144,6 +151,7 @@ class InstallTests(unittest.TestCase): fixed["clock_24h"] = True fixed["date_format"] = "iso" fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades + fixed["quick_inputs"] = ["/new", "/questions", "hello there"] fixed["wrist"]["y"] = 0.2 compact = json.dumps(fixed, separators=(",", ":")).encode() config.write_bytes(compact) @@ -168,6 +176,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"]) self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset + self.assertEqual(fixed["quick_inputs"], installer.CONFIG_DEFAULTS["quick_inputs"]) self.assertEqual(fixed["input_priority"], "normal") self.assertIs(fixed["advanced_debug"], False) self.assertIs(fixed["auto_insert"], False)