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

+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)