mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
Add quick chat picker and hold-to-quit overlay control
This commit is contained in:
1 parent
d1485ff71a
commit
1bde401aa8
23 files changed
+291
-71
No files matched your search
@@ -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
|
||||
|
||||
+4
-2
@@ -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"
|
||||
}]
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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.
|
||||
|
||||
+27
-13
@@ -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`
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+22
-4
@@ -13,7 +13,8 @@ namespace {
|
||||
struct Json {
|
||||
std::string value;
|
||||
std::map<std::string, Json> object;
|
||||
bool is_string = false, is_object = false, is_number = false, is_bool = false;
|
||||
std::vector<Json> 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<std::string, std::string> 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<std::string, std::string> used;
|
||||
for (const auto& [action, path] : buttons) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace frameyap {
|
||||
using Rgba = std::array<unsigned char, 4>;
|
||||
@@ -27,6 +28,7 @@ struct Config {
|
||||
WristPlacement wrist;
|
||||
// OpenVR action name -> physical Frame controller input path; empty disables it.
|
||||
std::map<std::string, std::string> buttons;
|
||||
std::vector<std::string> quick_inputs{"/new", "/questions", "/help"};
|
||||
};
|
||||
std::filesystem::path default_config_path();
|
||||
Config load_config(const std::filesystem::path& path);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+4
-3
@@ -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";
|
||||
}
|
||||
|
||||
+8
-7
@@ -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<const char*, 6> action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}};
|
||||
constexpr std::array<const char*, 7> 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<OverlayTexture> gpu_texture;
|
||||
vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle;
|
||||
std::array<vr::VRActionHandle_t, 6> actions{};
|
||||
std::array<vr::VRActionHandle_t, 7> 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<NeutralEdge, 4> edges{};
|
||||
std::array<NeutralEdge, 5> edges{};
|
||||
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> 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<UiAction, 4> mapped{{UiAction::Record, UiAction::Cancel, UiAction::Insert, UiAction::Enter}};
|
||||
constexpr std::array<UiAction, 5> 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<UiAction> 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<std::string>& 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.
|
||||
|
||||
+11
-1
@@ -3,17 +3,26 @@
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> 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<std::string>& quick_inputs() const;
|
||||
std::string controls_status(); // diagnostic only, no input delivery
|
||||
std::string pointer_status() const; // diagnostic counters, no input delivery
|
||||
private:
|
||||
|
||||
+42
-13
@@ -43,8 +43,8 @@ constexpr std::array<Button, 21> 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<int, 2> pressed{{-1, -1}};
|
||||
std::array<PanelSurface::Clock::time_point, 2> press_time{};
|
||||
int hold_progress = 0;
|
||||
int drag_cursor = -1;
|
||||
std::vector<std::string> 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<std::chrono::milliseconds>(
|
||||
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<std::chrono::milliseconds>(
|
||||
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<PanelDragKind> PanelSurface::pointer_down(unsigned cursor, float x, float y) {
|
||||
std::optional<PanelDragKind> 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<PanelDragKind> 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; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "config.hpp"
|
||||
#include "panel_drag.hpp"
|
||||
#include <ctime>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -37,8 +38,10 @@ public:
|
||||
bool render(const Panel& panel);
|
||||
const std::vector<unsigned char>& pixels() const;
|
||||
// Handles capture one cursor and never authorize a UI action on release.
|
||||
std::optional<PanelDragKind> 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<PanelDragKind> 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<Bounds> input_regions() const;
|
||||
|
||||
+39
-10
@@ -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<FocusGuard> 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<FocusGuard>();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<std::string>{"/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<std::string>{"/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"}})");
|
||||
|
||||
@@ -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<std::string>{"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<std::string>{"text:/new"}));
|
||||
FakeDelivery unavailable; unavailable.fail_acquisition = 2;
|
||||
CHECK(deliver_quick("/new", unavailable.factory()) == DeliveryResult::TextQueuedEnterUnavailable);
|
||||
CHECK((unavailable.events == std::vector<std::string>{"text:/new"}));
|
||||
}
|
||||
{
|
||||
auto s = review("Hello 世界"); FakeDelivery fake;
|
||||
CHECK(deliver_insert(s, fake.factory()) == DeliveryResult::TextQueued);
|
||||
|
||||
+23
-2
@@ -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";
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user