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