mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 03: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;
|
transcribe. Repeated presses reached the controls-only diagnostic on Frame;
|
||||||
live mic capture through this shortcut still needs guided acceptance. This
|
live mic capture through this shortcut still needs guided acceptance. This
|
||||||
PTT action is remappable through SteamVR bindings.
|
PTT action is remappable through SteamVR bindings.
|
||||||
- **Right B:** cancel/discard. **Right A:** insert reviewed text with a trailing space.
|
- **Right B:** cancel/discard (or close quick chat). **Right A:** insert reviewed text with a trailing space.
|
||||||
**Right Y:** insert pending text, then send Enter; with no pending text, Enter only.
|
**Right Y:** open quick chat; press again to cycle its highlighted choice.
|
||||||
**Left grip (when active):** double-tap for the same explicit Enter action.
|
**Submit** on the overlay (or double-tap left grip when active) sends that choice
|
||||||
Nothing submits automatically. Auto Insert is opt-in and off by default.
|
verbatim then Enter; outside quick chat it inserts pending review + Enter, or
|
||||||
- **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit.
|
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
|
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.
|
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
|
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/ptt", "type": "boolean" },
|
||||||
{ "name": "/actions/frameyap/in/cancel", "type": "boolean" },
|
{ "name": "/actions/frameyap/in/cancel", "type": "boolean" },
|
||||||
{ "name": "/actions/frameyap/in/insert", "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": [{
|
"localization": [{
|
||||||
"language_tag": "en_US",
|
"language_tag": "en_US",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
|
"/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
|
||||||
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
||||||
"/actions/frameyap/in/insert": "Insert reviewed text + trailing space",
|
"/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",
|
"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",
|
"name": "FrameYap Steam Frame controls",
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"/actions/frameyap": {
|
"/actions/frameyap": {
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"path": "/user/hand/right/input/y",
|
"path": "/user/hand/right/input/y",
|
||||||
"mode": "button",
|
"mode": "button",
|
||||||
"inputs": { "click": { "output": "/actions/frameyap/in/enter" } }
|
"inputs": { "click": { "output": "/actions/frameyap/in/quick_chat" } }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"lock_layout": false,
|
"lock_layout": false,
|
||||||
"clock_24h": false,
|
"clock_24h": false,
|
||||||
"date_format": "mdy",
|
"date_format": "mdy",
|
||||||
|
"quick_inputs": ["/new", "/questions", "/help"],
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {
|
"theme": {
|
||||||
"background": "#0c101b",
|
"background": "#0c101b",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"ptt": "/user/hand/right/input/x",
|
"ptt": "/user/hand/right/input/x",
|
||||||
"cancel": "/user/hand/right/input/b",
|
"cancel": "/user/hand/right/input/b",
|
||||||
"insert": "/user/hand/right/input/a",
|
"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,
|
- States: disabled, warming, ready, recording, transcribing, review, inserted,
|
||||||
unavailable/error. Recording uses visible icon + text, not color alone.
|
unavailable/error. Recording uses visible icon + text, not color alone.
|
||||||
- Default Frame bindings: hold right X to speak, release to finish; B cancels,
|
- 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.
|
A inserts with a trailing space, Y opens/cycles the quick-chat selection.
|
||||||
The alternate right-grip tap-then-hold gesture and left-grip double-tap Enter
|
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.
|
remain available. All are remappable through the Bindings button's SteamVR editor.
|
||||||
A click-to-start/stop overlay button provides
|
A click-to-start/stop overlay button provides
|
||||||
a binding-independent alternative. Bound recording to 20 seconds; discard
|
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):**
|
uninterrupted Xwayland target observation remains valid. **Review mode (default):**
|
||||||
wait for Insert. Bring up review on uncertainty rather than silently losing a
|
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.
|
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
|
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
|
review, it queues only Enter. Never interpret "submit", "delete" or other speech
|
||||||
as commands. Transcription completion never auto-submits.
|
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
|
The complete transcript preview is paginated by glyph width and four-line
|
||||||
height; Previous and Next navigate it without changing the source transcript.
|
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),
|
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
|
Cancel, Insert, Submit, Hold Quit. Hold Quit needs a 900 ms press and release on
|
||||||
warming/transcribing and until an existing review is inserted or discarded.
|
that same button; its thin progress bar shows the hold. Record can retry after an
|
||||||
Cancel can stop worker startup. Insert and Enter are disabled during recording
|
error; it is disabled while warming/transcribing and until an existing review is
|
||||||
and transcription. A pointer action requires
|
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,
|
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
|
deliberate action, not inferred from text. Insert appends a trailing space (without
|
||||||
doubling an existing trailing space). Enter inserts any pending review and then
|
doubling an existing trailing space). Submit inserts any pending review and then
|
||||||
queues Enter; with no pending text it queues Enter only. A failed text step never
|
queues Enter; with no pending text it queues Enter only. Y opens the quick-chat
|
||||||
proceeds to Enter. Recording never automatically submits. Auto insert, when
|
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
|
explicitly enabled, can queue text + space after transcription only under the
|
||||||
stable Xwayland focus guard described below.
|
stable Xwayland focus guard described below.
|
||||||
|
|
||||||
@@ -108,6 +113,7 @@ installer creates one with defaults on first install. Copy the shipped
|
|||||||
"lock_layout": false,
|
"lock_layout": false,
|
||||||
"clock_24h": false,
|
"clock_24h": false,
|
||||||
"date_format": "mdy",
|
"date_format": "mdy",
|
||||||
|
"quick_inputs": ["/new", "/questions", "/help"],
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {
|
"theme": {
|
||||||
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"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",
|
"ptt": "/user/hand/right/input/x",
|
||||||
"cancel": "/user/hand/right/input/b",
|
"cancel": "/user/hand/right/input/b",
|
||||||
"insert": "/user/hand/right/input/a",
|
"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",
|
"left_grip": "/user/hand/left/input/grip",
|
||||||
"right_grip": "/user/hand/right/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
|
unverified. The setting is preserved on upgrade
|
||||||
and a failed preference write applies only to the current session.
|
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`,
|
`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
|
button paths. Omitted actions retain their bundled defaults; an empty string
|
||||||
disables a mapping, including after an upgrade. The Frame defaults are right
|
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
|
X = hold-to-talk, B = Cancel, A = Insert + space, Y = quick chat. Enter has no
|
||||||
configs with empty actions retain those disabled mappings; change them explicitly
|
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;
|
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
|
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`
|
action manifest and adjacent bindings are placed in `$XDG_CACHE_HOME/frameyap/bindings`
|
||||||
|
|||||||
+10
-1
@@ -39,6 +39,7 @@ CONFIG_DEFAULTS = {
|
|||||||
"lock_layout": False,
|
"lock_layout": False,
|
||||||
"clock_24h": False,
|
"clock_24h": False,
|
||||||
"date_format": "mdy",
|
"date_format": "mdy",
|
||||||
|
"quick_inputs": ["/new", "/questions", "/help"],
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||||
@@ -46,7 +47,8 @@ CONFIG_DEFAULTS = {
|
|||||||
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
||||||
"right_grip": "/user/hand/right/input/grip",
|
"right_grip": "/user/hand/right/input/grip",
|
||||||
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b",
|
"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")
|
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")
|
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
|
fixed["clock_24h"] = clock if type(clock) is bool else False
|
||||||
date = data.get("date_format", "mdy")
|
date = data.get("date_format", "mdy")
|
||||||
fixed["date_format"] = date if date in ("off", "mdy", "dmy", "iso") else "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 = data.get("wrist")
|
||||||
source = source if isinstance(source, dict) else {}
|
source = source if isinstance(source, dict) else {}
|
||||||
fixed["wrist"] = {}
|
fixed["wrist"] = {}
|
||||||
@@ -123,6 +129,8 @@ def normalized_config(data):
|
|||||||
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
|
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
|
||||||
else (not value or BUTTON_RE.fullmatch(value))))
|
else (not value or BUTTON_RE.fullmatch(value))))
|
||||||
fixed[section][name] = value if valid else default
|
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]
|
paths = [value for value in fixed["buttons"].values() if value]
|
||||||
if len(paths) != len(set(paths)):
|
if len(paths) != len(set(paths)):
|
||||||
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
||||||
@@ -131,6 +139,7 @@ def normalized_config(data):
|
|||||||
fixed["font"] = ""
|
fixed["font"] = ""
|
||||||
if size() > 4096:
|
if size() > 4096:
|
||||||
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
||||||
|
fixed["quick_inputs"] = CONFIG_DEFAULTS["quick_inputs"].copy()
|
||||||
return fixed
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ CONFIG_DEFAULTS = {
|
|||||||
"lock_layout": False,
|
"lock_layout": False,
|
||||||
"clock_24h": False,
|
"clock_24h": False,
|
||||||
"date_format": "mdy",
|
"date_format": "mdy",
|
||||||
|
"quick_inputs": ["/new", "/questions", "/help"],
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||||
@@ -35,7 +36,8 @@ CONFIG_DEFAULTS = {
|
|||||||
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
||||||
"right_grip": "/user/hand/right/input/grip",
|
"right_grip": "/user/hand/right/input/grip",
|
||||||
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b",
|
"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")
|
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")
|
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
|
fixed["clock_24h"] = clock if type(clock) is bool else False
|
||||||
date = data.get("date_format", "mdy")
|
date = data.get("date_format", "mdy")
|
||||||
fixed["date_format"] = date if date in ("off", "mdy", "dmy", "iso") else "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 = data.get("wrist")
|
||||||
source = source if isinstance(source, dict) else {}
|
source = source if isinstance(source, dict) else {}
|
||||||
fixed["wrist"] = {}
|
fixed["wrist"] = {}
|
||||||
@@ -112,6 +118,8 @@ def normalized_config(data):
|
|||||||
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
|
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
|
||||||
else (not value or BUTTON_RE.fullmatch(value))))
|
else (not value or BUTTON_RE.fullmatch(value))))
|
||||||
fixed[section][name] = value if valid else default
|
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]
|
paths = [value for value in fixed["buttons"].values() if value]
|
||||||
if len(paths) != len(set(paths)):
|
if len(paths) != len(set(paths)):
|
||||||
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
||||||
@@ -120,6 +128,7 @@ def normalized_config(data):
|
|||||||
fixed["font"] = ""
|
fixed["font"] = ""
|
||||||
if size() > 4096:
|
if size() > 4096:
|
||||||
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
||||||
|
fixed["quick_inputs"] = CONFIG_DEFAULTS["quick_inputs"].copy()
|
||||||
return fixed
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -13,7 +13,8 @@ namespace {
|
|||||||
struct Json {
|
struct Json {
|
||||||
std::string value;
|
std::string value;
|
||||||
std::map<std::string, Json> object;
|
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
|
size_t start = 0, end = 0; // original value span for non-destructive config updates
|
||||||
};
|
};
|
||||||
struct Parser {
|
struct Parser {
|
||||||
@@ -88,8 +89,9 @@ struct Parser {
|
|||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
if (eat('[')) {
|
if (eat('[')) {
|
||||||
|
result.is_array = true;
|
||||||
if (eat(']')) return done();
|
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();
|
fail();
|
||||||
}
|
}
|
||||||
size_t start = pos;
|
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"))
|
if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
|
||||||
throw std::runtime_error("Config input_priority must be normal or experimental");
|
throw std::runtime_error("Config input_priority must be normal or experimental");
|
||||||
config.experimental_input_priority = value.value == "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") {
|
} else if (key == "wrist") {
|
||||||
if (!value.is_object) throw std::runtime_error("Config wrist must be an object");
|
if (!value.is_object) throw std::runtime_error("Config wrist must be an object");
|
||||||
for (const auto& [name, v] : value.object) {
|
for (const auto& [name, v] : value.object) {
|
||||||
@@ -245,7 +259,7 @@ Config load_config(const std::filesystem::path& path) {
|
|||||||
} else if (key == "buttons") {
|
} else if (key == "buttons") {
|
||||||
if (!value.is_object) throw std::runtime_error("Config buttons must be an object");
|
if (!value.is_object) throw std::runtime_error("Config buttons must be an object");
|
||||||
for (const auto& [name, v] : value.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);
|
throw std::runtime_error("Unknown OpenVR button action: " + name);
|
||||||
if (!v.is_string) throw std::runtime_error("Button path must be a string");
|
if (!v.is_string) throw std::runtime_error("Button path must be a string");
|
||||||
auto path = v.value;
|
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"},
|
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"},
|
{"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"},
|
{"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;
|
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::string binding = "{\"controller_type\":\"frame_controller\",\"name\":\"FrameYap configured controls\",\"bindings\":{\"/actions/frameyap\":{\"sources\":[";
|
||||||
std::map<std::string, std::string> used;
|
std::map<std::string, std::string> used;
|
||||||
for (const auto& [action, path] : buttons) {
|
for (const auto& [action, path] : buttons) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace frameyap {
|
namespace frameyap {
|
||||||
using Rgba = std::array<unsigned char, 4>;
|
using Rgba = std::array<unsigned char, 4>;
|
||||||
@@ -27,6 +28,7 @@ struct Config {
|
|||||||
WristPlacement wrist;
|
WristPlacement wrist;
|
||||||
// OpenVR action name -> physical Frame controller input path; empty disables it.
|
// OpenVR action name -> physical Frame controller input path; empty disables it.
|
||||||
std::map<std::string, std::string> buttons;
|
std::map<std::string, std::string> buttons;
|
||||||
|
std::vector<std::string> quick_inputs{"/new", "/questions", "/help"};
|
||||||
};
|
};
|
||||||
std::filesystem::path default_config_path();
|
std::filesystem::path default_config_path();
|
||||||
Config load_config(const std::filesystem::path& 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; }
|
catch (...) { return DeliveryResult::EnterUncertain; }
|
||||||
return DeliveryResult::EnterQueued;
|
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
|
// Review: insert text plus a trailing space, then explicitly send Enter only
|
||||||
// after successful text delivery. Ready/Queued: Enter alone.
|
// after successful text delivery. Ready/Queued: Enter alone.
|
||||||
DeliveryResult deliver_enter(Session& session, const DeliveryFactory& acquire);
|
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"
|
"SteamVR Developer setting Enable global input from overlays.\n"
|
||||||
"Right X: hold to speak, release to review (default Frame binding).\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"
|
"Grip gestures are remappable but may be unavailable in the dashboard.\n"
|
||||||
"Right B: cancel; A: insert + space; Y: insert pending text + Enter.\n"
|
"Right B: cancel; A: insert + space; Y: quick chat picker.\n"
|
||||||
"Left grip: double-tap for the same explicit Enter action when active.\n"
|
"Left grip: double-tap to Submit (Enter alone when no review).\n"
|
||||||
"Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n"
|
"Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n"
|
||||||
"Insert approves current focus; Enter is never automatic.\n"
|
"Insert approves current focus; Enter is never automatic.\n"
|
||||||
"No device access unless an explicit runtime/check/registration mode is used.\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::Record: return "Record";
|
||||||
case frameyap::UiAction::Cancel: return "Cancel";
|
case frameyap::UiAction::Cancel: return "Cancel";
|
||||||
case frameyap::UiAction::Insert: return "Insert";
|
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::Quit: return "Quit";
|
||||||
case frameyap::UiAction::Toggle: return "Toggle";
|
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];
|
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) result[r][c] = value.m[r][c];
|
||||||
return result;
|
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) {
|
void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) {
|
||||||
if (err != vr::VROverlayError_None)
|
if (err != vr::VROverlayError_None)
|
||||||
throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err));
|
throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err));
|
||||||
@@ -75,7 +75,7 @@ struct Overlay::Impl {
|
|||||||
vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid;
|
vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid;
|
||||||
std::unique_ptr<OverlayTexture> gpu_texture;
|
std::unique_ptr<OverlayTexture> gpu_texture;
|
||||||
vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle;
|
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 settings_path;
|
||||||
std::filesystem::path laser_settings_path;
|
std::filesystem::path laser_settings_path;
|
||||||
Mount mount;
|
Mount mount;
|
||||||
@@ -101,7 +101,7 @@ struct Overlay::Impl {
|
|||||||
vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid;
|
vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid;
|
||||||
DoubleTap left;
|
DoubleTap left;
|
||||||
GripRecord right;
|
GripRecord right;
|
||||||
std::array<NeutralEdge, 4> edges{};
|
std::array<NeutralEdge, 5> edges{};
|
||||||
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses{};
|
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses{};
|
||||||
bool grip_capture = false, ptt_capture = false;
|
bool grip_capture = false, ptt_capture = false;
|
||||||
bool focus = true;
|
bool focus = true;
|
||||||
@@ -577,19 +577,19 @@ struct Overlay::Impl {
|
|||||||
auto [ra, rd] = digital(1);
|
auto [ra, rd] = digital(1);
|
||||||
const auto now = DoubleTap::Clock::now();
|
const auto now = DoubleTap::Clock::now();
|
||||||
if (left.update(la && focus, ld, available(UiAction::Enter), now)) result.push_back(UiAction::Enter);
|
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::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::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::Cancel: grip_capture = false; result.push_back(UiAction::Cancel); break;
|
||||||
case GripRecord::Change::None: 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) {
|
for (size_t i = 0; i < edges.size(); ++i) {
|
||||||
auto [active, down] = digital(i + 2);
|
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);
|
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 (i == 0) {
|
||||||
if (change == NeutralEdge::Change::Down) {
|
if (change == NeutralEdge::Change::Down) {
|
||||||
ptt_capture = true; result.push_back(UiAction::BeginRecord);
|
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); }
|
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
|
||||||
bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; }
|
bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; }
|
||||||
bool Overlay::auto_insert() const { return impl_->config.auto_insert; }
|
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() {
|
std::string Overlay::controls_status() {
|
||||||
// Compare the same actions across modes, before and after our pose/role gate.
|
// Compare the same actions across modes, before and after our pose/role gate.
|
||||||
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
|
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
|
||||||
|
|||||||
+11
-1
@@ -3,17 +3,26 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace frameyap {
|
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 {
|
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 status;
|
||||||
std::string transcript;
|
std::string transcript;
|
||||||
std::string detail;
|
std::string detail;
|
||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
bool recording = false;
|
bool recording = false;
|
||||||
bool record_available = true;
|
bool record_available = true;
|
||||||
|
bool quick_open = false;
|
||||||
|
size_t quick_selected = 0;
|
||||||
|
std::vector<std::string> quick_inputs;
|
||||||
};
|
};
|
||||||
class Overlay {
|
class Overlay {
|
||||||
public:
|
public:
|
||||||
@@ -26,6 +35,7 @@ public:
|
|||||||
void draw(const Panel& panel);
|
void draw(const Panel& panel);
|
||||||
bool advanced_debug() const;
|
bool advanced_debug() const;
|
||||||
bool auto_insert() const;
|
bool auto_insert() const;
|
||||||
|
const std::vector<std::string>& quick_inputs() const;
|
||||||
std::string controls_status(); // diagnostic only, no input delivery
|
std::string controls_status(); // diagnostic only, no input delivery
|
||||||
std::string pointer_status() const; // diagnostic counters, no input delivery
|
std::string pointer_status() const; // diagnostic counters, no input delivery
|
||||||
private:
|
private:
|
||||||
|
|||||||
+42
-13
@@ -43,8 +43,8 @@ constexpr std::array<Button, 21> buttons{{
|
|||||||
{{32, 574, 176, 68}, Control::Record, "Record"},
|
{{32, 574, 176, 68}, Control::Record, "Record"},
|
||||||
{{222, 574, 176, 68}, Control::Cancel, "Cancel"},
|
{{222, 574, 176, 68}, Control::Cancel, "Cancel"},
|
||||||
{{412, 574, 176, 68}, Control::Insert, "Insert"},
|
{{412, 574, 176, 68}, Control::Insert, "Insert"},
|
||||||
{{602, 574, 176, 68}, Control::Enter, "Enter"},
|
{{602, 574, 176, 68}, Control::Enter, "Submit"},
|
||||||
{{792, 574, 176, 68}, Control::Quit, "Quit"},
|
{{792, 574, 176, 68}, Control::Quit, "Hold Quit"},
|
||||||
{{32, 234, 454, 58}, Control::World, "World space"},
|
{{32, 234, 454, 58}, Control::World, "World space"},
|
||||||
{{514, 234, 454, 58}, Control::Head, "Head"},
|
{{514, 234, 454, 58}, Control::Head, "Head"},
|
||||||
{{32, 308, 454, 58}, Control::Left, "Left wrist"},
|
{{32, 308, 454, 58}, Control::Left, "Left wrist"},
|
||||||
@@ -111,6 +111,8 @@ struct PanelSurface::Impl {
|
|||||||
ClockLabel displayed_clock;
|
ClockLabel displayed_clock;
|
||||||
std::string placement_note, binding_note;
|
std::string placement_note, binding_note;
|
||||||
std::array<int, 2> pressed{{-1, -1}};
|
std::array<int, 2> pressed{{-1, -1}};
|
||||||
|
std::array<PanelSurface::Clock::time_point, 2> press_time{};
|
||||||
|
int hold_progress = 0;
|
||||||
int drag_cursor = -1;
|
int drag_cursor = -1;
|
||||||
std::vector<std::string> lines;
|
std::vector<std::string> lines;
|
||||||
size_t page = 0;
|
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); }
|
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 {
|
bool available(UiAction a) const {
|
||||||
if (a == UiAction::Record) return panel.recording || panel.record_available;
|
if (a == UiAction::Record) return !panel.quick_open && (panel.recording || panel.record_available);
|
||||||
if (a == UiAction::Insert) return panel.enabled && !panel.recording && !panel.transcript.empty();
|
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::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;
|
if (a == UiAction::Toggle) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool visible(Control c) const {
|
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 ||
|
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
|
||||||
c == Control::AdvancedDebug || c == Control::AutoInsert ||
|
c == Control::AdvancedDebug || c == Control::AutoInsert ||
|
||||||
c == Control::Clock24h || c == Control::Date || c == Control::LockLayout) return tab == Tab::Settings;
|
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);
|
lines = wrap(p.transcript.empty() ? "Your words will appear here.\nReview them, then choose Insert." : p.transcript, 32, 904);
|
||||||
page = 0; dirty = true;
|
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;
|
dirty = true;
|
||||||
|
if (p.quick_open != panel.quick_open) reset();
|
||||||
panel = p;
|
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);
|
auto label = panel_clock(clock_time, clock_24h, date_format);
|
||||||
if (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true;
|
if (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true;
|
||||||
if (!dirty) return false;
|
if (!dirty) return false;
|
||||||
@@ -305,16 +318,22 @@ struct PanelSurface::Impl {
|
|||||||
auto status = wrap(panel.status, 27, 790);
|
auto status = wrap(panel.status, 27, 790);
|
||||||
text(status.front(), 70, 109, 27, ink, 844);
|
text(status.front(), 70, 109, 27, ink, 844);
|
||||||
if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968);
|
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);
|
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)
|
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,
|
text(lines[page * lines_per_page + i], 48, 246 + int(i) * 40, 32,
|
||||||
panel.transcript.empty() ? muted : ink, 952);
|
panel.transcript.empty() ? muted : ink, 952);
|
||||||
text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 436, 23, muted, 790);
|
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);
|
if (!binding_note.empty()) text(binding_note, 32, 203, 20, muted, 968);
|
||||||
} else {
|
} else {
|
||||||
text("Auto insert needs stable X focus; debug logs may contain speech.", 32, 204, 18, pink, 968);
|
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,
|
rounded(b.r, std::min(16, b.r.h / 3), fill,
|
||||||
!on ? mix(card, muted, .13f) : highlighted ? accent : mix(card, muted, .38f),
|
!on ? mix(card, muted, .13f) : highlighted ? accent : mix(card, muted, .38f),
|
||||||
highlighted ? .23f : 0.f, highlighted ? 2 : 1);
|
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" :
|
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::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") :
|
||||||
b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" :
|
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 {
|
bool PanelSurface::dragging(unsigned cursor) const {
|
||||||
return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor;
|
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 {};
|
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); };
|
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))) {
|
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;
|
return contains(grab) ? PanelDragKind::Grab : PanelDragKind::Scale;
|
||||||
}
|
}
|
||||||
impl_->pressed[cursor] = impl_->hit(x, y);
|
impl_->pressed[cursor] = impl_->hit(x, y);
|
||||||
|
impl_->press_time[cursor] = now;
|
||||||
|
if (impl_->pressed[cursor] == 10) impl_->dirty = true;
|
||||||
return {};
|
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;
|
SurfaceEvent result;
|
||||||
if (cursor >= impl_->pressed.size()) return result;
|
if (cursor >= impl_->pressed.size()) return result;
|
||||||
if (impl_->drag_cursor >= 0) {
|
if (impl_->drag_cursor >= 0) {
|
||||||
@@ -408,8 +435,10 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
int index = std::exchange(impl_->pressed[cursor], -1);
|
int index = std::exchange(impl_->pressed[cursor], -1);
|
||||||
|
if (index == 10) impl_->dirty = true;
|
||||||
if (index < 0 || impl_->hit(x, y) != index) return result;
|
if (index < 0 || impl_->hit(x, y) != index) return result;
|
||||||
auto c = buttons[index].id;
|
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;
|
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 a = action(c)) result.action = a;
|
||||||
else if (auto m = mounting(c)) { impl_->mount = *m; result.mount = *m; impl_->reset(); impl_->dirty = true; }
|
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 "config.hpp"
|
||||||
#include "panel_drag.hpp"
|
#include "panel_drag.hpp"
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <chrono>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -37,8 +38,10 @@ public:
|
|||||||
bool render(const Panel& panel);
|
bool render(const Panel& panel);
|
||||||
const std::vector<unsigned char>& pixels() const;
|
const std::vector<unsigned char>& pixels() const;
|
||||||
// Handles capture one cursor and never authorize a UI action on release.
|
// Handles capture one cursor and never authorize a UI action on release.
|
||||||
std::optional<PanelDragKind> pointer_down(unsigned cursor, float x, float y);
|
using Clock = std::chrono::steady_clock;
|
||||||
SurfaceEvent pointer_up(unsigned cursor, float x, float y);
|
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;
|
bool dragging(unsigned cursor) const;
|
||||||
// OpenVR intersection masks use top-left coordinates, unlike mouse events.
|
// OpenVR intersection masks use top-left coordinates, unlike mouse events.
|
||||||
std::vector<Bounds> input_regions() const;
|
std::vector<Bounds> input_regions() const;
|
||||||
|
|||||||
+39
-10
@@ -54,8 +54,11 @@ int run(const Options& options) {
|
|||||||
Worker worker;
|
Worker worker;
|
||||||
Audio audio;
|
Audio audio;
|
||||||
Session session;
|
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 quit = false;
|
||||||
|
bool quick_open = false;
|
||||||
|
size_t quick_selected = 0;
|
||||||
bool advanced_debug = overlay.advanced_debug();
|
bool advanced_debug = overlay.advanced_debug();
|
||||||
bool auto_insert = overlay.auto_insert();
|
bool auto_insert = overlay.auto_insert();
|
||||||
std::unique_ptr<FocusGuard> armed_focus;
|
std::unique_ptr<FocusGuard> armed_focus;
|
||||||
@@ -68,19 +71,25 @@ int run(const Options& options) {
|
|||||||
switch (result) {
|
switch (result) {
|
||||||
case DeliveryResult::Ignored: break;
|
case DeliveryResult::Ignored: break;
|
||||||
case DeliveryResult::TextQueued:
|
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:
|
case DeliveryResult::EnterQueued:
|
||||||
detail = submit ? "Explicit Enter queued to current focus; not a delivery receipt."
|
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:
|
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:
|
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:
|
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 = [&] {
|
auto warm = [&] {
|
||||||
|
status_note.clear(); quick_open = false;
|
||||||
audio.close(); worker.stop(); session = Session{}; armed_focus.reset();
|
audio.close(); worker.stop(); session = Session{}; armed_focus.reset();
|
||||||
worker.start(options.python, options.worker, options.model, options.threads, advanced_debug);
|
worker.start(options.python, options.worker, options.model, options.threads, advanced_debug);
|
||||||
detail = "Loading local model; microphone closed. Record again when Ready.";
|
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 (!audio.open()) audio.prepare(); // recovery only; normal PTT never opens the device
|
||||||
if (session.state() == State::Error) session.cancel();
|
if (session.state() == State::Error) session.cancel();
|
||||||
if (!session.record()) return;
|
if (!session.record()) return;
|
||||||
|
status_note.clear();
|
||||||
armed_focus.reset();
|
armed_focus.reset();
|
||||||
if (auto_insert) {
|
if (auto_insert) {
|
||||||
auto candidate = std::make_unique<FocusGuard>();
|
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.
|
// Drawing precedes input polling: Enter is disabled until Ready is visible.
|
||||||
const auto status = session.state() == State::Error && worker.ready()
|
const auto status = session.state() == State::Error && worker.ready()
|
||||||
? "Retry available - local model still loaded" : state_label(session.state());
|
? "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::Error && session.state() != State::Warming && session.state() != State::Transcribing,
|
||||||
session.state() == State::Recording,
|
session.state() == State::Recording,
|
||||||
session.state() != State::Warming && session.state() != State::Transcribing && session.state() != State::Review};
|
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";
|
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
|
||||||
overlay.draw(panel);
|
overlay.draw(panel);
|
||||||
auto actions = overlay.poll();
|
auto actions = overlay.poll();
|
||||||
@@ -200,11 +214,14 @@ int run(const Options& options) {
|
|||||||
case UiAction::Quit: quit = true; break;
|
case UiAction::Quit: quit = true; break;
|
||||||
case UiAction::Toggle:
|
case UiAction::Toggle:
|
||||||
case UiAction::Record:
|
case UiAction::Record:
|
||||||
|
if (quick_open) break;
|
||||||
if (session.state() == State::Recording) stop_record(); else start_record();
|
if (session.state() == State::Recording) stop_record(); else start_record();
|
||||||
break;
|
break;
|
||||||
case UiAction::BeginRecord: start_record(); break;
|
case UiAction::BeginRecord: if (!quick_open) start_record(); break;
|
||||||
case UiAction::EndRecord: stop_record(); break;
|
case UiAction::EndRecord: stop_record(); break;
|
||||||
case UiAction::Cancel: {
|
case UiAction::Cancel: {
|
||||||
|
if (quick_open) { quick_open = false; break; }
|
||||||
|
status_note.clear();
|
||||||
auto state = session.state();
|
auto state = session.state();
|
||||||
armed_focus.reset(); audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded.";
|
armed_focus.reset(); audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded.";
|
||||||
if (state == State::Warming || state == State::Transcribing) {
|
if (state == State::Warming || state == State::Transcribing) {
|
||||||
@@ -216,10 +233,19 @@ int run(const Options& options) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case UiAction::Insert:
|
case UiAction::Insert:
|
||||||
delivery_detail(deliver_insert(session, acquire), false);
|
if (!quick_open) delivery_detail(deliver_insert(session, acquire), false);
|
||||||
break;
|
break;
|
||||||
case UiAction::Enter:
|
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;
|
break;
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
@@ -228,6 +254,9 @@ int run(const Options& options) {
|
|||||||
// the IPC stream was partially written.
|
// the IPC stream was partially written.
|
||||||
if (session.state() == State::Recording || session.state() == State::Transcribing) {
|
if (session.state() == State::Recording || session.state() == State::Transcribing) {
|
||||||
audio.close(); session.fail();
|
audio.close(); session.fail();
|
||||||
|
status_note.clear();
|
||||||
|
} else {
|
||||||
|
status_note = "Input unavailable - check destination";
|
||||||
}
|
}
|
||||||
detail = e.what();
|
detail = e.what();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ int main(int argc, char** argv) {
|
|||||||
assert(default_config_path().empty());
|
assert(default_config_path().empty());
|
||||||
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
|
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
|
||||||
assert(load_config(path).buttons.empty());
|
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).experimental_input_priority);
|
||||||
assert(!load_config(path).advanced_debug);
|
assert(!load_config(path).advanced_debug);
|
||||||
assert(!load_config(path).auto_insert);
|
assert(!load_config(path).auto_insert);
|
||||||
@@ -48,6 +49,9 @@ int main(int argc, char** argv) {
|
|||||||
assert(load_config(path).wrist.width == .30f);
|
assert(load_config(path).wrist.width == .30f);
|
||||||
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
|
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("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.font.empty());
|
||||||
assert(!example.experimental_input_priority);
|
assert(!example.experimental_input_priority);
|
||||||
assert(!example.advanced_debug);
|
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("/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/a") != std::string::npos);
|
||||||
assert(generated.find("/user/hand/right/input/y") != 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"] = "";
|
config.buttons["cancel"] = ""; config.buttons["insert"] = ""; config.buttons["enter"] = "";
|
||||||
auto disabled = action_manifest(argv[1], config);
|
auto disabled = action_manifest(argv[1], config);
|
||||||
generated = get(disabled.parent_path() / "bindings_frame_controller.json");
|
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/insert") == std::string::npos);
|
||||||
assert(generated.find("/actions/frameyap/in/enter") == 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"));
|
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"}})");
|
put(path, R"({"buttons":{"ptt":"/user/hand/left/input/grip"}})");
|
||||||
fails([&] { action_manifest(argv[1], load_config(path)); }); // overlapping bindings are never silently chosen
|
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"}})");
|
put(path, R"({"buttons":{"ptt":"/user/hand/left/input/x\"}hack"}})");
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ Session review(std::string_view transcript) {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
void delivery_checks() {
|
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;
|
auto s = review("Hello 世界"); FakeDelivery fake;
|
||||||
CHECK(deliver_insert(s, fake.factory()) == DeliveryResult::TextQueued);
|
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");
|
if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-review.ppm");
|
||||||
assert(click(surface, 480, 610).action == UiAction::Insert);
|
assert(click(surface, 480, 610).action == UiAction::Insert);
|
||||||
assert(click(surface, 680, 610).action == UiAction::Enter);
|
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.
|
// Down/up must use the same controller, and release rechecks availability.
|
||||||
surface.pointer_down(0, 480, 610);
|
surface.pointer_down(0, 480, 610);
|
||||||
no_action(surface.pointer_up(1, 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
|
// A press does not re-upload pixels. A completed action still reaches the
|
||||||
// caller; meaningful panel changes redraw.
|
// caller; meaningful panel changes redraw.
|
||||||
assert(!surface.render(p));
|
assert(!surface.render(p));
|
||||||
surface.pointer_down(0, 900, 610); assert(!surface.render(p));
|
const auto at = PanelSurface::Clock::now();
|
||||||
assert(surface.pointer_up(0, 900, 610).action == UiAction::Quit);
|
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));
|
assert(!surface.render(p));
|
||||||
std::cout << "panel checks passed (no OpenVR, microphone or input injection)\n";
|
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())
|
config = json.loads((ROOT / "assets/config.example.json").read_text())
|
||||||
sources = binding["bindings"]["/actions/frameyap"]["sources"]
|
sources = binding["bindings"]["/actions/frameyap"]["sources"]
|
||||||
self.assertEqual(len({s["path"] for s in sources}), len(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}"
|
name = f"/actions/frameyap/in/{action}"
|
||||||
path = f"/user/hand/right/input/{button}"
|
path = f"/user/hand/right/input/{button}"
|
||||||
self.assertIn({"name": name, "type": "boolean"}, manifest["actions"])
|
self.assertIn({"name": name, "type": "boolean"}, manifest["actions"])
|
||||||
self.assertEqual(config["buttons"][action], path)
|
self.assertEqual(config["buttons"][action], path)
|
||||||
self.assertEqual([s["path"] for s in sources if s["inputs"]["click"]["output"] == name], [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):
|
def test_right_x_is_hold_to_talk(self):
|
||||||
manifest = json.loads((ROOT / "assets/actions.json").read_text())
|
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["lock_layout"], False)
|
||||||
self.assertIs(fixed["clock_24h"], False)
|
self.assertIs(fixed["clock_24h"], False)
|
||||||
self.assertEqual(fixed["date_format"], "mdy")
|
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"])
|
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-*"))
|
backups = list(config.parent.glob("config.json.backup-*"))
|
||||||
self.assertEqual(len(backups), 1)
|
self.assertEqual(len(backups), 1)
|
||||||
self.assertEqual(backups[0].read_bytes(), original)
|
self.assertEqual(backups[0].read_bytes(), original)
|
||||||
@@ -144,6 +151,7 @@ class InstallTests(unittest.TestCase):
|
|||||||
fixed["clock_24h"] = True
|
fixed["clock_24h"] = True
|
||||||
fixed["date_format"] = "iso"
|
fixed["date_format"] = "iso"
|
||||||
fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades
|
fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades
|
||||||
|
fixed["quick_inputs"] = ["/new", "/questions", "hello there"]
|
||||||
fixed["wrist"]["y"] = 0.2
|
fixed["wrist"]["y"] = 0.2
|
||||||
compact = json.dumps(fixed, separators=(",", ":")).encode()
|
compact = json.dumps(fixed, separators=(",", ":")).encode()
|
||||||
config.write_bytes(compact)
|
config.write_bytes(compact)
|
||||||
@@ -168,6 +176,7 @@ class InstallTests(unittest.TestCase):
|
|||||||
self.assertEqual(fixed["font"], "/system/face.ttf")
|
self.assertEqual(fixed["font"], "/system/face.ttf")
|
||||||
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
|
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
|
||||||
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
|
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.assertEqual(fixed["input_priority"], "normal")
|
||||||
self.assertIs(fixed["advanced_debug"], False)
|
self.assertIs(fixed["advanced_debug"], False)
|
||||||
self.assertIs(fixed["auto_insert"], False)
|
self.assertIs(fixed["auto_insert"], False)
|
||||||
|
|||||||
Reference in new issue
Block a user