Add quick chat picker and hold-to-quit overlay control

This commit is contained in:
baketnk committed 2026-09-24 17:26:01 -04:00
1 parent d1485ff71a
commit 1bde401aa8
23 files changed
+291 -71

No files matched your search

+9 -5
View File
@@ -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
View File
@@ -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"
}] }]
} }
+2 -2
View File
@@ -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" } }
} }
] ]
} }
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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
View File
@@ -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) {
+2
View File
@@ -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);
+14
View File
@@ -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;
}
} }
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+5 -2
View File
@@ -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
View File
@@ -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();
} }
+16
View File
@@ -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"}})");
+20
View File
@@ -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
View File
@@ -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";
} }
+4 -1
View File
@@ -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())
+9
View File
@@ -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)