From 1ba0f26e0e341957fce2946a91d9d4f2d6fb040e Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 12:32:43 -0400 Subject: [PATCH] Consolidate VR UI with Inconsolata neon styling and mount settings --- CMakeLists.txt | 21 +- README.md | 2 + assets/fonts/Inconsolata-Regular.ttf | Bin 0 -> 101748 bytes assets/fonts/OFL-Inconsolata.txt | 93 ++++++++ docs/design.md | 24 +- docs/overlay.md | 89 +++++-- docs/third-party.md | 29 ++- scripts/stage-native-poc.py | 10 +- src/main.cpp | 31 ++- src/mount.cpp | 148 ++++++++++++ src/mount.hpp | 33 +++ src/overlay.cpp | 291 ++++++++--------------- src/overlay.hpp | 5 +- src/panel_surface.cpp | 338 +++++++++++++++++++++++++++ src/panel_surface.hpp | 36 +++ src/runtime.cpp | 7 +- src/runtime.hpp | 4 +- tests/mount_test.cpp | 127 ++++++++++ tests/panel_test.cpp | 136 +++++++++++ 19 files changed, 1179 insertions(+), 245 deletions(-) create mode 100644 assets/fonts/Inconsolata-Regular.ttf create mode 100644 assets/fonts/OFL-Inconsolata.txt create mode 100644 src/mount.cpp create mode 100644 src/mount.hpp create mode 100644 src/panel_surface.cpp create mode 100644 src/panel_surface.hpp create mode 100644 tests/mount_test.cpp create mode 100644 tests/panel_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 05e8239..f38eb5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,13 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) option(FRAMEYAP_NATIVE "Build opt-in OpenVR/SDL3/Gamescope runtime (no automatic downloads)" OFF) +option(FRAMEYAP_UI_TESTS "Build hardware-free panel renderer tests (requires FreeType)" OFF) +if(FRAMEYAP_NATIVE OR FRAMEYAP_UI_TESTS) + find_package(Freetype REQUIRED) + add_library(frameyap_panel src/panel_surface.cpp) + target_include_directories(frameyap_panel PUBLIC src) + target_link_libraries(frameyap_panel PRIVATE Freetype::Freetype) +endif() add_library(frameyap_core src/core.cpp) target_include_directories(frameyap_core PUBLIC src) @@ -11,6 +18,8 @@ add_executable(frameyap src/main.cpp) target_link_libraries(frameyap PRIVATE frameyap_core) target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${PROJECT_VERSION}") if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_library(frameyap_mount src/mount.cpp) + target_include_directories(frameyap_mount PUBLIC src) add_library(frameyap_worker src/worker.cpp) target_include_directories(frameyap_worker PUBLIC src) endif() @@ -28,7 +37,6 @@ if(FRAMEYAP_NATIVE) set_property(TARGET PkgConfig::SDL3 PROPERTY INTERFACE_LINK_OPTIONS "${SDL3_LINK_OPTIONS}") endif() pkg_check_modules(WAYLAND REQUIRED IMPORTED_TARGET wayland-client) - pkg_check_modules(FREETYPE REQUIRED IMPORTED_TARGET freetype2) find_program(WAYLAND_SCANNER wayland-scanner REQUIRED) set(OPENVR_ROOT "" CACHE PATH "Explicit standalone OpenVR v2.15.6 SDK root") find_path(OPENVR_INCLUDE_DIR openvr.h HINTS "${OPENVR_ROOT}/headers" REQUIRED) @@ -50,7 +58,7 @@ if(FRAMEYAP_NATIVE) target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/audio.cpp src/text_input.cpp "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h") target_include_directories(frameyap PRIVATE "${OPENVR_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") - target_link_libraries(frameyap PRIVATE frameyap_worker PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::FREETYPE "${OPENVR_LIBRARY}") + target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND "${OPENVR_LIBRARY}") target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1) set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") endif() @@ -67,7 +75,16 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_executable(frameyap_core_test tests/core_test.cpp) target_link_libraries(frameyap_core_test PRIVATE frameyap_core) add_test(NAME frameyap.core COMMAND frameyap_core_test) + if(TARGET frameyap_panel) + add_executable(frameyap_panel_test tests/panel_test.cpp) + target_link_libraries(frameyap_panel_test PRIVATE frameyap_panel) + target_compile_options(frameyap_panel_test PRIVATE -UNDEBUG) + add_test(NAME frameyap.panel COMMAND frameyap_panel_test "${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/Inconsolata-Regular.ttf") + endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(frameyap_mount_test tests/mount_test.cpp) + target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount) + add_test(NAME frameyap.mount COMMAND frameyap_mount_test) add_executable(frameyap_lock_test tests/lock_test.cpp) target_include_directories(frameyap_lock_test PRIVATE src) add_test(NAME frameyap.instance_lock COMMAND frameyap_lock_test) diff --git a/README.md b/README.md index 3c18003..409eaba 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet transcribe. Remappable through SteamVR bindings; separate hold-to-talk action too. - **Left grip:** double-tap to explicitly send Enter. Never inferred from speech. - **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit. + Review and settings share one Inconsolata/neon-framed surface. World-space by + default; settings offer left wrist, right wrist and head mounting, plus recenter. Dashboard lasers provide clickable controls without forcing global laser mode. - **Review-first:** focus your destination, then press Insert. No automatic insertion or submission. Maximum clip 20 seconds; accidental taps under 200 ms are discarded. diff --git a/assets/fonts/Inconsolata-Regular.ttf b/assets/fonts/Inconsolata-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ff1e6bbc60e830a55d63b6288310e93b7a178557 GIT binary patch literal 101748 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 ko67UA. +The TTF is unchanged. No MSDF atlas, icons, engine code or renderer dependencies +were copied. Unicode coverage is finite; missing glyphs use the face's notdef glyph. + +Visual references: kouseki's `apps/vr_workspace/hud.cpp` (rounded mint-to-blue +perimeter and curved accent) and `menu_tablet.hpp` (dark cards, highlighted +selection). FrameYap implements those design ideas independently on a single +CPU RGBA surface. Building, installing and running require no kouseki checkout. +CMake installs the font and OFL with assets; native staging defaults to that +font, places the launcher copy at `fonts/font.ttf`, and includes its license in +`THIRD_PARTY_NOTICES.txt`. Custom staging fonts still require an explicit license. ## Explicit native build inputs (not vendored) @@ -25,8 +47,9 @@ images, protected kernels or another application's assets were extracted. - SDL3: zlib license; device trial used SDL 3.2.16 built in a private user prefix. - Wayland client and scanner: retain upstream MIT-style notices. - FreeType: choose and comply with its applicable FTL/GPL licensing option. -- Font: explicit user-supplied path; observed device check used system Hack Regular. - A release must include the selected font's own license and assess glyph coverage. +- Optional font override: the earlier device check used system Hack Regular. + A custom release font must include its own license and assessed glyph coverage; + new builds default to the bundled Inconsolata described above. - Compiler runtime, libc minimum and transitive shared libraries require a release dependency audit. Passing a developer build is not a portable-runtime guarantee. diff --git a/scripts/stage-native-poc.py b/scripts/stage-native-poc.py index fc7e78f..6a423aa 100644 --- a/scripts/stage-native-poc.py +++ b/scripts/stage-native-poc.py @@ -14,10 +14,16 @@ def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--build", type=Path, required=True) p.add_argument("--destination", type=Path, required=True, help="new staging directory") - for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license", "font", "font-license"): + for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"): p.add_argument("--" + name, type=Path, required=True) + p.add_argument("--font", type=Path, help="override bundled Inconsolata (requires --font-license)") + p.add_argument("--font-license", type=Path) args = p.parse_args() root = Path(__file__).resolve().parents[1] + if bool(args.font) != bool(args.font_license): + p.error("a font override requires both --font and --font-license") + args.font = args.font or root / "assets/fonts/Inconsolata-Regular.ttf" + args.font_license = args.font_license or root / "assets/fonts/OFL-Inconsolata.txt" dest = args.destination.absolute() if dest.exists() or dest.is_symlink(): p.error("destination already exists; use a new staging directory") @@ -36,7 +42,7 @@ def main(): shutil.copyfile(args.font, dest / "fonts/font.ttf") notices = ["FrameYap native-only POC. No ASR runtime or model is included.\n", "Original FrameYap code: MIT. System Wayland/FreeType/libstdc++/glibc are not bundled.\n", - "Bundled libraries: Valve OpenVR and unmodified SDL3; font supplied explicitly below.\n", + "Bundled libraries: Valve OpenVR and unmodified SDL3; font license included below.\n", "This package does not grant any rights to kestrel-kernels or provide a functioning ASR environment.\n"] for label, file in (("FrameYap", root / "LICENSE"), ("OpenVR", args.openvr_license), ("SDL3", args.sdl_license), ("Font", args.font_license)): diff --git a/src/main.cpp b/src/main.cpp index 762076d..41f4c1a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,12 +15,15 @@ namespace { void help() { std::cout << "FrameYap — standalone Steam Frame voice typing POC\n" "Usage: frameyap [--help | --version]\n" - " --run --assets DIR --font FILE --worker FILE --model DIR\n" - " [--python FILE] [--threads 2|4] [--socket gamescope-0] [--head]\n" + " --run --assets DIR [--font FILE] --worker FILE --model DIR\n" + " [--python FILE] [--threads 2|4] [--socket gamescope-0] [--mount MODE]\n" " --register MANIFEST [--autostart] | --unregister MANIFEST\n" " --check-input [--socket gamescope-0] (discovery only, no typing)\n" - " --check-overlay --assets DIR --font FILE [--head] (5s, no mic/input)\n" - " --check-controls --assets DIR --font FILE [--head] (30s, gestures only)\n\n" + " --check-overlay --assets DIR [--font FILE] [--mount MODE] (5s, no mic/input)\n" + " --check-controls --assets DIR [--font FILE] [--mount MODE] (30s, gestures only)\n\n" + "Mount: world (first-launch default), left-wrist, right-wrist, head.\n" + "Settings save the mount; --mount overrides it for this launch. --head is an alias.\n" + "Font defaults to bundled Inconsolata in the assets directory.\n" "Right grip: double-tap, hold second squeeze to speak, release to review.\n" "Left grip: double-tap for explicit Enter. Bindings are remappable.\n" "Review first: Insert approves current focus; never automatic Enter.\n" @@ -49,12 +52,16 @@ int main(int argc, char** argv) { options.socket = socket && *socket ? socket : "gamescope-0"; for (int i = 2; i < argc; ++i) { std::string_view arg = argv[i]; - if (arg == "--head" && mode != "--check-input") { options.hand = false; continue; } + if (arg == "--head" && mode != "--check-input") { options.mount = frameyap::Mount::Head; continue; } if (i + 1 == argc) throw std::runtime_error("Missing option value"); std::string value = argv[++i]; if (arg == "--socket" && mode != "--check-overlay") options.socket = value; else if (arg == "--assets" && mode != "--check-input") options.assets = value; else if (arg == "--font" && mode != "--check-input") options.font = value; + else if (arg == "--mount" && mode != "--check-input") { + options.mount = frameyap::parse_mount(value); + if (!options.mount) throw std::runtime_error("Mount must be world, left-wrist, right-wrist or head"); + } else if (arg == "--worker" && mode == "--run") options.worker = value; else if (arg == "--python" && mode == "--run") options.python = value; else if (arg == "--model" && mode == "--run") options.model = value; @@ -66,10 +73,10 @@ int main(int argc, char** argv) { frameyap::TextInput input(options.socket); std::cout << "Gamescope IME v2 ready; no input delivered.\n"; return 0; } - if (options.assets.empty() || options.font.empty()) throw std::runtime_error("--assets and --font required"); + if (options.assets.empty()) throw std::runtime_error("--assets required"); if (mode == "--check-overlay" || mode == "--check-controls") { frameyap::InstanceLock lock; - frameyap::Overlay overlay(options.assets, options.font, options.hand); + frameyap::Overlay overlay(options.assets, options.font, options.mount); overlay.draw({"FrameYap five-second visual check", "No audio captured. No text or Enter delivered.", "Controls inactive during this check.", false, false}); for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) { if (vr::VRSystem()->GetTrackedDeviceClass(i) != vr::TrackedDeviceClass_Controller) continue; @@ -95,7 +102,15 @@ int main(int argc, char** argv) { if (next != status) { status = next; std::cout << status << std::endl; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } - } else std::this_thread::sleep_for(std::chrono::seconds(5)); + } else { + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (std::chrono::steady_clock::now() < end) { + for (auto action : overlay.poll()) if (action == frameyap::UiAction::Quit) return 0; + overlay.draw({"FrameYap five-second visual check", "No audio captured. No text or Enter delivered.", + "Actions are diagnostic only during this check.", false, false}); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } std::cout << "Overlay APIs accepted panel; physical visibility requires human confirmation.\n"; return 0; } if (options.worker.empty() || options.model.empty()) throw std::runtime_error("--worker and --model required"); diff --git a/src/mount.cpp b/src/mount.cpp new file mode 100644 index 0000000..8de84f0 --- /dev/null +++ b/src/mount.cpp @@ -0,0 +1,148 @@ +#include "mount.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace frameyap { +namespace { +constexpr Matrix34 identity{{{{1.f, 0.f, 0.f, 0.f}}, + {{0.f, 1.f, 0.f, 0.f}}, + {{0.f, 0.f, 1.f, 0.f}}}}; +constexpr std::size_t max_preference_bytes = 32; +std::atomic next_temp{0}; +} // namespace + +std::string_view mount_name(Mount mount) { + switch (mount) { + case Mount::World: return "world"; + case Mount::LeftWrist: return "left-wrist"; + case Mount::RightWrist: return "right-wrist"; + case Mount::Head: return "head"; + } + return {}; +} + +std::optional parse_mount(std::string_view name) { + if (name == "world") return Mount::World; + if (name == "left-wrist") return Mount::LeftWrist; + if (name == "right-wrist") return Mount::RightWrist; + if (name == "head") return Mount::Head; + return std::nullopt; +} + +std::optional world_mount_pose(const Matrix34& hmd) { + for (const auto& row : hmd) + for (float value : row) + if (!std::isfinite(value)) return std::nullopt; + + // The HMD's -Z is its forward direction. Discard pitch and roll, so that + // the panel stays level even when the wearer tilts their head. + const double fx = -static_cast(hmd[0][2]); + const double fz = -static_cast(hmd[2][2]); + const double horizontal = std::hypot(fx, fz); + if (!std::isfinite(horizontal) || horizontal < 1e-4) return std::nullopt; + const double x = fx / horizontal, z = fz / horizontal; + const double px = static_cast(hmd[0][3]) + 1.05 * x; + const double py = static_cast(hmd[1][3]) - 0.16; + const double pz = static_cast(hmd[2][3]) + 1.05 * z; + const Matrix34 pose{{{{static_cast(-z), 0.f, static_cast(-x), static_cast(px)}}, + {{0.f, 1.f, 0.f, static_cast(py)}}, + {{static_cast(x), 0.f, static_cast(-z), static_cast(pz)}}}}; + for (const auto& row : pose) + for (float value : row) + if (!std::isfinite(value)) return std::nullopt; + return pose; +} + +Matrix34 relative_mount_pose(Mount mount) { + auto pose = identity; + switch (mount) { + case Mount::Head: pose[1][3] = -0.16f; pose[2][3] = -1.05f; break; + case Mount::LeftWrist: pose[0][3] = 0.13f; pose[1][3] = 0.12f; pose[2][3] = -0.18f; break; + case Mount::RightWrist: pose[0][3] = -0.13f; pose[1][3] = 0.12f; pose[2][3] = -0.18f; break; + case Mount::World: break; + } + return pose; +} + +float mount_width(Mount mount) { + return mount == Mount::LeftWrist || mount == Mount::RightWrist ? 0.42f : 0.85f; +} + +std::filesystem::path default_mount_settings_path() { + try { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg && *xdg && std::filesystem::path(xdg).is_absolute()) + return std::filesystem::path(xdg) / "frameyap/mount"; + const char* home = std::getenv("HOME"); + if (home && *home && std::filesystem::path(home).is_absolute()) + return std::filesystem::path(home) / ".config/frameyap/mount"; + } catch (...) { return {}; } + return {}; +} + +Mount load_mount(const std::filesystem::path& path) { + try { + if (path.empty() || !std::filesystem::is_regular_file(path)) return Mount::World; + std::ifstream input(path, std::ios::binary); + if (!input) return Mount::World; + char bytes[max_preference_bytes + 1]; + input.read(bytes, sizeof(bytes)); + const auto size = input.gcount(); + if (size <= 0 || size > static_cast(max_preference_bytes)) return Mount::World; + std::string_view token(bytes, static_cast(size)); + if (token.back() == '\n') token.remove_suffix(1); + return parse_mount(token).value_or(Mount::World); + } catch (...) { return Mount::World; } +} + +bool save_mount(const std::filesystem::path& path, Mount mount) { + std::filesystem::path temporary; + bool created = false; + int fd = -1; + try { + const auto name = mount_name(mount); + if (path.empty() || path.filename().empty() || name.empty()) return false; + const auto dir = path.has_parent_path() ? path.parent_path() : std::filesystem::path("."); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) return false; + for (int attempt = 0; attempt < 16; ++attempt) { + temporary = dir / (path.filename().string() + ".tmp." + std::to_string(::getpid()) + + "." + std::to_string(next_temp.fetch_add(1))); + fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (fd >= 0) { created = true; break; } + if (errno != EEXIST) return false; + } + if (!created) return false; + const std::string content = std::string(name) + '\n'; + std::size_t written = 0; + while (written < content.size()) { + const auto n = ::write(fd, content.data() + written, content.size() - written); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { ::close(fd); fd = -1; std::filesystem::remove(temporary, ec); return false; } + written += static_cast(n); + } + const bool synced = ::fsync(fd) == 0; + const bool closed = ::close(fd) == 0; + fd = -1; + if (!synced || !closed) { std::filesystem::remove(temporary, ec); return false; } + std::filesystem::rename(temporary, path, ec); + if (ec) { std::filesystem::remove(temporary, ec); return false; } + return true; + } catch (...) { + if (fd >= 0) ::close(fd); + if (created) { std::error_code ec; std::filesystem::remove(temporary, ec); } + return false; + } +} + +} // namespace frameyap diff --git a/src/mount.hpp b/src/mount.hpp new file mode 100644 index 0000000..6cffb3d --- /dev/null +++ b/src/mount.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace frameyap { + +enum class Mount { World, LeftWrist, RightWrist, Head }; + +std::string_view mount_name(Mount mount); +std::optional parse_mount(std::string_view name); + +// Row-major 3x4 affine transform (OpenVR axes: +Y up, -Z forward). +using Matrix34 = std::array, 3>; + +// World-space, yaw-only pose in front of the HMD, upright and facing the viewer. +// Invalid/nonfinite HMD transforms or a vanishing horizontal forward return nullopt. +std::optional world_mount_pose(const Matrix34& hmd); +// Device-relative transform; World has no relative anchor and returns identity. +Matrix34 relative_mount_pose(Mount mount); +float mount_width(Mount mount); + +// Empty when neither XDG_CONFIG_HOME nor HOME specifies an absolute base. +std::filesystem::path default_mount_settings_path(); +// A missing, unreadable, overlong or malformed preference defaults to World. +Mount load_mount(const std::filesystem::path& path); +// Create local parent directories and atomically replace a single mount preference. +// Never stores any audio or transcript; failures return false without throwing. +bool save_mount(const std::filesystem::path& path, Mount mount); + +} // namespace frameyap diff --git a/src/overlay.cpp b/src/overlay.cpp index fc75ad0..8f0cc4c 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -1,9 +1,8 @@ #include "overlay.hpp" #include "gestures.hpp" +#include "panel_surface.hpp" #include -#include -#include FT_FREETYPE_H #include #include @@ -31,19 +30,7 @@ void configure_registry() { if (root.is_absolute() && std::filesystem::is_regular_file(registry)) ::setenv("VR_PATHREG_OVERRIDE", registry.c_str(), 0); } -constexpr int W = 900, H = 500; -struct Rect { int x, y, w, h; bool contains(int px, int py) const { - return px >= x && px < x + w && py >= y && py < y + h; -} }; -struct Button { Rect bounds; const char* label; UiAction action; }; -constexpr std::array buttons{{ - {{24, 422, 130, 58}, "Prev", UiAction::Toggle}, // handled as page navigation, not a toggle - {{168, 422, 130, 58}, "Record", UiAction::Record}, - {{312, 422, 130, 58}, "Cancel", UiAction::Cancel}, - {{456, 422, 130, 58}, "Insert", UiAction::Insert}, - {{600, 422, 130, 58}, "Enter", UiAction::Enter}, - {{744, 422, 130, 58}, "Quit", UiAction::Quit}, -}}; +constexpr int W = PanelSurface::width, H = PanelSurface::height; 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)); @@ -53,24 +40,6 @@ std::filesystem::path absolute_file(const std::filesystem::path& p) { if (!std::filesystem::is_regular_file(result)) throw std::runtime_error("Missing file: " + result.string()); return result; } -// A strict, bounded UTF-8 decoder: malformed input is shown as replacement glyphs. -uint32_t next_codepoint(std::string_view s, size_t& i) { - const unsigned char a = static_cast(s[i++]); - if (a < 0x80) return a; - int count = a >= 0xc2 && a <= 0xdf ? 1 : a >= 0xe0 && a <= 0xef ? 2 : a >= 0xf0 && a <= 0xf4 ? 3 : 0; - if (!count || i + count > s.size()) return 0xfffd; - uint32_t value = a & (count == 1 ? 0x1f : count == 2 ? 0x0f : 0x07); - for (int k = 0; k < count; ++k) { - unsigned char b = static_cast(s[i + k]); - if ((b & 0xc0) != 0x80) return 0xfffd; - value = (value << 6) | (b & 0x3f); - } - if ((count == 1 && value < 0x80) || (count == 2 && value < 0x800) || - (count == 3 && value < 0x10000) || (value >= 0xd800 && value <= 0xdfff) || value > 0x10ffff) - return 0xfffd; - i += count; - return value; -} } // namespace struct Overlay::Impl { @@ -80,32 +49,29 @@ struct Overlay::Impl { vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid; vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle; std::array actions{}; - FT_Library library = nullptr; - FT_Face face = nullptr; - std::vector pixels = std::vector(W * H * 4); + std::filesystem::path settings_path; + Mount mount; + PanelSurface surface; Panel panel; - bool hand = false; + bool save_failed = false; + bool world_ready = false, placed = false, has_texture = false, shown = false; + vr::HmdMatrix34_t world_transform{}; + std::optional applied_mount; vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid; DoubleTap left; GripRecord right; std::array edges{}; - std::array pressed_button{{-1, -1}}; std::array poses{}; - std::vector pages; - size_t page = 0; - bool drawn = false; bool grip_capture = false, ptt_capture = false; bool focus = true; - static constexpr Rect next_page{754, 292, 112, 30}; - Impl(const std::string& assets, const std::string& font, bool attach) : hand(attach) { + Impl(const std::string& assets, const std::string& font, std::optional requested) + : settings_path(default_mount_settings_path()), + mount(requested ? *requested : load_mount(settings_path)), + surface(font.empty() ? (std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf").string() : font, mount) { const auto action_path = absolute_file(std::filesystem::path(assets) / "actions.json"); absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json"); - const auto font_path = absolute_file(font); - if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed"); try { - if (FT_New_Face(library, font_path.c_str(), 0, &face) || FT_Set_Pixel_Sizes(face, 0, 31)) - throw std::runtime_error("Could not load specified font"); configure_registry(); vr::EVRInitError err = vr::VRInitError_None; system = vr::VR_Init(&err, vr::VRApplication_Overlay); @@ -136,6 +102,7 @@ struct Overlay::Impl { overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_VisibleInDashboard, true), overlay, "VisibleInDashboard"); vr::HmdVector2_t mouse_scale{{float(W), float(H)}}; overlay_check(overlay->SetOverlayMouseScale(handle, &mouse_scale), overlay, "SetOverlayMouseScale"); + system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size())); place(); draw(Panel{"Disabled", "", "Record to start local worker", false, false}); } catch (...) { cleanup(); throw; } @@ -148,140 +115,71 @@ struct Overlay::Impl { handle = vr::k_ulOverlayHandleInvalid; } if (system) { vr::VR_Shutdown(); system = nullptr; overlay = nullptr; input = nullptr; } - if (face) { FT_Done_Face(face); face = nullptr; } - if (library) { FT_Done_FreeType(library); library = nullptr; } + } + void visibility() { + const bool wanted = placed && has_texture; + if (wanted == shown) return; + overlay_check(wanted ? overlay->ShowOverlay(handle) : overlay->HideOverlay(handle), overlay, "Overlay visibility"); + shown = wanted; } void place() { + Mount effective = mount; vr::TrackedDeviceIndex_t target = vr::k_unTrackedDeviceIndex_Hmd; - if (hand) { - const auto left_index = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand); - if (left_index != vr::k_unTrackedDeviceIndexInvalid && left_index < poses.size() && - poses[left_index].bPoseIsValid && system->IsTrackedDeviceConnected(left_index)) - target = left_index; + if (mount == Mount::LeftWrist || mount == Mount::RightWrist) { + target = system->GetTrackedDeviceIndexForControllerRole(mount == Mount::LeftWrist + ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand); + if (target >= poses.size() || !poses[target].bPoseIsValid || !system->IsTrackedDeviceConnected(target)) + effective = Mount::World; } - if (anchor == target) return; - vr::HmdMatrix34_t transform{{{1.f, 0.f, 0.f, target == vr::k_unTrackedDeviceIndex_Hmd ? 0.f : 0.13f}, - {0.f, 1.f, 0.f, target == vr::k_unTrackedDeviceIndex_Hmd ? -0.16f : 0.12f}, - {0.f, 0.f, 1.f, target == vr::k_unTrackedDeviceIndex_Hmd ? -1.05f : -0.18f}}}; - overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay, "SetOverlayTransformTrackedDeviceRelative"); - anchor = target; - } - void rect(Rect r, std::array c) { - for (int y = std::max(r.y, 0); y < std::min(H, r.y + r.h); ++y) - for (int x = std::max(r.x, 0); x < std::min(W, r.x + r.w); ++x) { - const size_t p = (size_t(y) * W + x) * 4; - std::copy(c.begin(), c.end(), pixels.begin() + p); + if (effective == Mount::World && applied_mount && *applied_mount != Mount::World) + world_ready = false; // a fresh world fallback near the wearer, not an old room location + std::string note = save_failed ? "Preference could not be saved; using it for this session." : ""; + if (effective != mount) note = save_failed ? "Wrist untracked; world fallback. Preference not saved." : + "Wrist not tracked - using world space until it returns."; + if (effective == Mount::World && !world_ready) { + const auto& hmd = poses[vr::k_unTrackedDeviceIndex_Hmd]; + Matrix34 pose{}; + for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) pose[r][c] = hmd.mDeviceToAbsoluteTracking.m[r][c]; + const auto world = hmd.bPoseIsValid && hmd.bDeviceIsConnected ? world_mount_pose(pose) : std::nullopt; + if (!world) { + placed = false; + surface.set_placement_note("Waiting for headset tracking to place the menu."); + visibility(); + return; } - } - void text(std::string_view s, int x, int baseline, int max_x, int max_y, int max_bytes) { - size_t i = 0; - int used = 0; - while (i < s.size() && used < max_bytes && baseline < max_y) { - const size_t before = i; - const auto cp = next_codepoint(s, i); - used += int(i - before); - if (cp == '\n') { x = 28; baseline += 39; continue; } - if (cp == '\r' || cp == '\t') continue; - if (FT_Load_Char(face, cp, FT_LOAD_RENDER)) continue; - const auto glyph = face->glyph; - if (x + (glyph->advance.x >> 6) >= max_x) { x = 28; baseline += 39; } - if (baseline >= max_y) break; - const auto& b = glyph->bitmap; - for (unsigned row = 0; row < b.rows; ++row) - for (unsigned col = 0; col < b.width; ++col) { - int px = x + glyph->bitmap_left + int(col); - int py = baseline - glyph->bitmap_top + int(row); - if (px < 0 || px >= W || py < 0 || py >= max_y) continue; - const unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)]; - auto* dst = pixels.data() + (size_t(py) * W + px) * 4; - for (int c = 0; c < 3; ++c) - dst[c] = static_cast((unsigned(dst[c]) * (255 - alpha) + 240u * alpha) / 255); - } - x += glyph->advance.x >> 6; + for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) world_transform.m[r][c] = (*world)[r][c]; + world_ready = true; + applied_mount.reset(); } - } - bool available(UiAction a) const { - switch (a) { - case UiAction::Toggle: return page > 0; - case UiAction::Quit: case UiAction::Record: case UiAction::Cancel: return true; - case UiAction::BeginRecord: case UiAction::EndRecord: return true; - case UiAction::Enter: return panel.enabled; - case UiAction::Insert: return panel.enabled && !panel.transcript.empty(); - } - return false; - } - // Split by rendered glyph advances, never by byte count or arbitrary characters. - // UTF-8 slices remain intact; all bytes (including invalid UTF-8) stay visible. - std::vector paginate(std::string_view s, int lines, int right) { - std::vector out; - size_t start = 0, i = 0; - int x = 28, line = 0; - while (i < s.size()) { - size_t before = i; - auto cp = next_codepoint(s, i); - if (cp == '\n') { - if (++line == lines) { - out.emplace_back(s.substr(start, i - start)); - start = i; line = 0; - } - x = 28; - continue; + surface.set_placement_note(note); + if (applied_mount != effective || (effective != Mount::World && anchor != target)) { + surface.reset_pointers(); // a release on a relocated surface cannot activate an old press + if (effective == Mount::World) { + overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &world_transform), overlay, + "SetOverlayTransformAbsolute"); + } else { + const auto pose = relative_mount_pose(effective); + vr::HmdMatrix34_t transform{}; + for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) transform.m[r][c] = pose[r][c]; + overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay, + "SetOverlayTransformTrackedDeviceRelative"); } - int advance = 0; - if (cp != '\r' && cp != '\t' && !FT_Load_Char(face, cp, FT_LOAD_DEFAULT)) - advance = face->glyph->advance.x >> 6; - if (x + advance >= right && x != 28) { - if (++line == lines) { - out.emplace_back(s.substr(start, before - start)); - start = before; line = 0; - } else { - // Add a line break in the displayed page while keeping source intact. - // The renderer's own width wrapping uses the same advance rule. - } - x = 28; - } - x += advance; + overlay_check(overlay->SetOverlayWidthInMeters(handle, mount_width(effective)), overlay, "SetOverlayWidthInMeters"); + applied_mount = effective; + anchor = target; } - if (start < s.size() || out.empty()) out.emplace_back(s.substr(start)); - return out; + placed = true; + visibility(); } + bool available(UiAction action) const { return surface.available(action); } void draw(const Panel& p) { - if (p.transcript != panel.transcript || pages.empty()) { - pages = paginate(p.transcript.empty() ? "Transcript preview" : p.transcript, 3, W - 28); - page = 0; - drawn = false; - } - if (drawn && p.status == panel.status && p.transcript == panel.transcript && - p.detail == panel.detail && p.enabled == panel.enabled && p.recording == panel.recording) return; panel = p; - rect({0, 0, W, H}, {20, 26, 39, 255}); - rect({0, 0, W, 12}, {static_cast(p.recording ? 220 : 55), - static_cast(p.recording ? 82 : 135), 72, 255}); - text(p.enabled ? "FrameYap | Enabled" : "FrameYap | Disabled", 28, 52, W - 20, 72, 128); - const auto status_lines = paginate(p.status, 2, 620); - text(status_lines[0], 28, 96, 620, 150, int(status_lines[0].size())); - if (status_lines.size() > 1) text("[status truncated]", 635, 140, W - 28, 150, 32); - rect({20, 153, W - 40, 171}, {37, 46, 63, 255}); - text(pages[page], 28, 190, W - 28, 290, int(pages[page].size())); - const std::string page_label = "Page " + std::to_string(page + 1) + " / " + std::to_string(pages.size()); - text(page_label, 383, 315, 720, 324, 64); - rect(next_page, page + 1 < pages.size() ? std::array{56, 94, 129, 255} - : std::array{49, 52, 60, 255}); - text("Next", 767, 316, 860, 324, 8); - const auto detail_lines = paginate(p.detail, 2, 620); - text(detail_lines[0], 28, 355, 620, 412, int(detail_lines[0].size())); - if (detail_lines.size() > 1) - text("[detail truncated]", 635, 405, W - 28, 412, 32); - for (const auto& button : buttons) { - const bool enabled = available(button.action); - rect(button.bounds, enabled ? std::array{56, 94, 129, 255} - : std::array{49, 52, 60, 255}); - text(button.label, button.bounds.x + 10, button.bounds.y + 38, - button.bounds.x + button.bounds.w - 5, H, 64); + if (surface.render(p)) { + // OpenVR's API takes void*, but does not modify the submitted RGBA bytes. + overlay_check(overlay->SetOverlayRaw(handle, const_cast(surface.pixels().data()), W, H, 4), overlay, "SetOverlayRaw"); + has_texture = true; } - overlay_check(overlay->SetOverlayRaw(handle, pixels.data(), W, H, 4), overlay, "SetOverlayRaw"); - overlay_check(overlay->ShowOverlay(handle), overlay, "ShowOverlay"); - drawn = true; + visibility(); } void reset_input(std::vector& result) { left.reset(); @@ -291,7 +189,7 @@ struct Overlay::Impl { ptt_capture = false; if (lost_grip || lost_ptt) result.push_back(UiAction::Cancel); for (size_t i = 1; i < edges.size(); ++i) edges[i].reset(); - pressed_button.fill(-1); + surface.reset_pointers(); } // Bound actions are accepted only with a connected tracked source. A held input // following loss of activity must return to neutral before generating an edge. @@ -318,6 +216,9 @@ struct Overlay::Impl { vr::VREvent_t event{}; while (system->PollNextEvent(&event, sizeof(event))) { if (event.eventType == vr::VREvent_Quit) result.push_back(UiAction::Quit); + if (event.eventType == vr::VREvent_SeatedZeroPoseReset || event.eventType == vr::VREvent_ChaperoneUniverseHasChanged) { + world_ready = false; applied_mount.reset(); surface.reset_pointers(); + } if (event.eventType == vr::VREvent_TrackedDeviceDeactivated || event.eventType == vr::VREvent_InputFocusCaptured) reset_input(result); } @@ -337,36 +238,30 @@ struct Overlay::Impl { // activity. Cancel the old gesture, then require neutral rearm; // do not permanently latch global grip actions off. reset_input(result); break; - case vr::VREvent_MouseButtonDown: { - const auto& m = event.data.mouse; - if (m.button != vr::VRMouseButton_Left || m.cursorIndex >= pressed_button.size()) break; - const int x = int(m.x), y = H - int(m.y); - pressed_button[m.cursorIndex] = -1; - for (size_t i = 0; i < buttons.size(); ++i) - if (buttons[i].bounds.contains(x, y) && available(buttons[i].action)) { - pressed_button[m.cursorIndex] = int(i); break; + case vr::VREvent_MouseMove: + surface.pointer_move(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); + break; + case vr::VREvent_MouseButtonDown: + if (event.data.mouse.button == vr::VRMouseButton_Left) + surface.pointer_down(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); + break; + case vr::VREvent_MouseButtonUp: + if (event.data.mouse.button == vr::VRMouseButton_Left) { + auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); + if (event_result.action) result.push_back(*event_result.action); + if (event_result.mount) { + mount = *event_result.mount; + save_failed = !save_mount(settings_path, mount); + world_ready = false; + applied_mount.reset(); } - if (next_page.contains(x, y) && page + 1 < pages.size()) - pressed_button[m.cursorIndex] = int(buttons.size()); + if (event_result.recenter) { world_ready = false; applied_mount.reset(); } + } break; - } - case vr::VREvent_MouseButtonUp: { - const auto& m = event.data.mouse; - if (m.button != vr::VRMouseButton_Left || m.cursorIndex >= pressed_button.size()) break; - const int index = pressed_button[m.cursorIndex]; - pressed_button[m.cursorIndex] = -1; - if (index == int(buttons.size()) && next_page.contains(int(m.x), H - int(m.y)) && - page + 1 < pages.size()) { ++page; drawn = false; } - else if (index == 0 && buttons[0].bounds.contains(int(m.x), H - int(m.y)) && page > 0) { - --page; drawn = false; - } else if (index > 0 && index < int(buttons.size()) && - buttons[index].bounds.contains(int(m.x), H - int(m.y)) && - available(buttons[index].action)) result.push_back(buttons[index].action); - break; - } default: break; } } + place(); vr::VRActiveActionSet_t set{}; set.ulActionSet = action_set; set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle; @@ -377,7 +272,7 @@ struct Overlay::Impl { auto [la, ld] = digital(0); auto [ra, rd] = digital(1); const auto now = DoubleTap::Clock::now(); - if (left.update(la && focus, ld, panel.enabled, 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)) { 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; @@ -405,8 +300,8 @@ struct Overlay::Impl { } }; -Overlay::Overlay(const std::string& assets, const std::string& font, bool hand) - : impl_(std::make_unique(assets, font, hand)) {} +Overlay::Overlay(const std::string& assets, const std::string& font, std::optional mount) + : impl_(std::make_unique(assets, font, mount)) {} Overlay::~Overlay() = default; std::vector Overlay::poll() { return impl_->poll(); } void Overlay::draw(const Panel& panel) { impl_->draw(panel); } diff --git a/src/overlay.hpp b/src/overlay.hpp index 8ab7e96..a8bed0f 100644 --- a/src/overlay.hpp +++ b/src/overlay.hpp @@ -1,4 +1,6 @@ #pragma once +#include "mount.hpp" +#include #include #include #include @@ -11,10 +13,11 @@ struct Panel { std::string detail; bool enabled = false; bool recording = false; + bool record_available = true; }; class Overlay { public: - Overlay(const std::string& assets, const std::string& font, bool hand); + Overlay(const std::string& assets, const std::string& font = {}, std::optional mount = {}); ~Overlay(); Overlay(const Overlay&) = delete; Overlay& operator=(const Overlay&) = delete; diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp new file mode 100644 index 0000000..a2a8efa --- /dev/null +++ b/src/panel_surface.cpp @@ -0,0 +1,338 @@ +#include "panel_surface.hpp" +#include +#include FT_FREETYPE_H +#include +#include +#include +#include +#include +#include + +namespace frameyap { +namespace { +constexpr int W = PanelSurface::width, H = PanelSurface::height; +using Color = std::array; +constexpr Color background{12, 16, 27, 255}, card{20, 28, 43, 255}; +constexpr Color ink{230, 240, 249, 255}, muted{151, 173, 193, 255}; +constexpr Color cyan{31, 240, 164, 255}, pink{255, 110, 135, 255}; +struct Rect { + int x, y, w, h; + bool contains(float px, float py) const { + return std::isfinite(px) && std::isfinite(py) && px >= x && px < x + w && py >= y && py < y + h; + } +}; +enum class Control { Review, Settings, Prev, Next, Record, Cancel, Insert, Enter, Quit, + World, Left, Right, Head, Recenter }; +struct Button { Rect r; Control id; const char* label; }; +constexpr std::array buttons{{ + {{32, 138, 180, 46}, Control::Review, "Review"}, + {{226, 138, 180, 46}, Control::Settings, "Settings"}, + {{32, 406, 154, 44}, Control::Prev, "Previous"}, + {{838, 406, 130, 44}, Control::Next, "Next"}, + {{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"}, + {{32, 234, 454, 58}, Control::World, "World space"}, + {{514, 234, 454, 58}, Control::Head, "Head"}, + {{32, 308, 454, 58}, Control::Left, "Left wrist"}, + {{514, 308, 454, 58}, Control::Right, "Right wrist"}, + {{32, 394, 300, 50}, Control::Recenter, "Recenter in front"}, +}}; +std::optional action(Control c) { + switch (c) { + case Control::Record: return UiAction::Record; + case Control::Cancel: return UiAction::Cancel; + case Control::Insert: return UiAction::Insert; + case Control::Enter: return UiAction::Enter; + case Control::Quit: return UiAction::Quit; + default: return {}; + } +} +std::optional mounting(Control c) { + switch (c) { + case Control::World: return Mount::World; + case Control::Left: return Mount::LeftWrist; + case Control::Right: return Mount::RightWrist; + case Control::Head: return Mount::Head; + default: return {}; + } +} +std::string_view mount_label(Mount m) { + switch (m) { + case Mount::World: return "World space"; + case Mount::LeftWrist: return "Left wrist"; + case Mount::RightWrist: return "Right wrist"; + case Mount::Head: return "Head"; + } + return "World space"; +} +// Invalid bytes become visible replacement glyphs, never control commands. +uint32_t next_codepoint(std::string_view s, size_t& i) { + const auto a = static_cast(s[i++]); + if (a < 0x80) return a; + int count = a >= 0xc2 && a <= 0xdf ? 1 : a >= 0xe0 && a <= 0xef ? 2 : a >= 0xf0 && a <= 0xf4 ? 3 : 0; + if (!count || i + count > s.size()) return 0xfffd; + uint32_t value = a & (count == 1 ? 0x1f : count == 2 ? 0x0f : 0x07); + for (int k = 0; k < count; ++k) { + auto b = static_cast(s[i + k]); + if ((b & 0xc0) != 0x80) return 0xfffd; + value = (value << 6) | (b & 0x3f); + } + if ((count == 1 && value < 0x80) || (count == 2 && value < 0x800) || + (count == 3 && value < 0x10000) || (value >= 0xd800 && value <= 0xdfff) || value > 0x10ffff) + return 0xfffd; + i += count; + return value; +} +} // namespace + +struct PanelSurface::Impl { + FT_Library library = nullptr; + FT_Face face = nullptr; + std::vector pixels = std::vector(W * H * 4); + Panel panel; + Mount mount; + bool settings = false, dirty = true; + std::string placement_note; + std::array pressed{{-1, -1}}, hovered{{-1, -1}}; + std::vector lines; + size_t page = 0; + static constexpr size_t lines_per_page = 4; + + Impl(const std::string& font, Mount m) : mount(m) { + if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed"); + if (FT_New_Face(library, font.c_str(), 0, &face)) { + FT_Done_FreeType(library); + throw std::runtime_error("Could not load specified font: " + font); + } + } + ~Impl() { FT_Done_Face(face); FT_Done_FreeType(library); } + void size(unsigned px) { + if (FT_Set_Pixel_Sizes(face, 0, px)) throw std::runtime_error("Could not size panel font"); + } + void rect(Rect r, Color c) { + for (int y = std::max(r.y, 0); y < std::min(H, r.y + r.h); ++y) + for (int x = std::max(r.x, 0); x < std::min(W, r.x + r.w); ++x) + std::copy(c.begin(), c.end(), pixels.begin() + (size_t(y) * W + x) * 4); + } + void border(Rect r, Color c, int thickness = 1) { + rect({r.x, r.y, r.w, thickness}, c); rect({r.x, r.y + r.h - thickness, r.w, thickness}, c); + rect({r.x, r.y, thickness, r.h}, c); rect({r.x + r.w - thickness, r.y, thickness, r.h}, c); + } + void neon(Rect r, Color c) { + // Baked halo + bright core: no extra overlay, mesh, bloom pass or animation. + for (int spread = 5; spread >= 1; --spread) { + Color halo = background; + for (int k = 0; k < 3; ++k) halo[k] += (c[k] - halo[k]) / (spread * 3 + 2); + border({r.x - spread, r.y - spread, r.w + spread * 2, r.h + spread * 2}, halo); + } + border(r, c, 2); + } + void frame() { + // Independently rasterized version of kouseki's HUD visual language: + // rounded mint-to-blue perimeter and a second shallow curved accent. + for (int y = 0; y < H; ++y) for (int x = 0; x < W; ++x) { + if (x > 34 && x < W - 34 && y > 34 && y < H - 34) continue; + const float qx = std::abs(x - W / 2.f) - (W / 2.f - 28.f); + const float qy = std::abs(y - H / 2.f) - (H / 2.f - 28.f); + const float distance = std::hypot(std::max(qx, 0.f), std::max(qy, 0.f)) + + std::min(std::max(qx, qy), 0.f) - 18.f; + const float edge = std::abs(distance); + const float strength = edge <= 1.f ? 1.f : .30f * std::max(0.f, 1.f - (edge - 1.f) / 6.f); + const float t = float(x) / W; + const Color gradient{31, static_cast(255 - 143 * t), + static_cast(145 + 110 * t), 255}; + auto* dst = pixels.data() + (size_t(y) * W + x) * 4; + for (int k = 0; k < 3; ++k) dst[k] = static_cast(background[k] + (gradient[k] - background[k]) * strength); + dst[3] = distance <= 1.f ? 255 : static_cast(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f)); + } + for (int x = 32; x < W - 32; ++x) { + const float t = float(x - 32) / (W - 64); + const int y = H - 21 - int(5 * std::sin(t * 3.14159265f)); + rect({x, y, 1, 1}, {31, static_cast(175 - 75 * t), + static_cast(115 + 80 * t), 255}); + } + } + int advance(uint32_t cp) { + if (cp == '\r' || cp == '\t') return 0; + if (FT_Load_Char(face, cp, FT_LOAD_DEFAULT)) return 0; + return int(face->glyph->advance.x >> 6); + } + std::vector wrap(std::string_view s, unsigned px, int width) { + size(px); + std::vector result; + size_t start = 0, i = 0; + int x = 0; + while (i < s.size()) { + size_t before = i; + auto cp = next_codepoint(s, i); + if (cp == '\n') { + result.emplace_back(s.substr(start, before - start)); start = i; x = 0; continue; + } + int a = advance(cp); + if (x + a > width && x > 0) { + result.emplace_back(s.substr(start, before - start)); start = before; x = 0; + } + x += a; + } + result.emplace_back(s.substr(start)); + return result; + } + void text(std::string_view s, int x, int baseline, unsigned px, Color color, int right) { + size(px); + size_t i = 0; + while (i < s.size()) { + auto cp = next_codepoint(s, i); + if (cp == '\n' || cp == '\r' || cp == '\t') continue; + if (FT_Load_Char(face, cp, FT_LOAD_RENDER)) continue; + auto glyph = face->glyph; + int a = int(glyph->advance.x >> 6); + if (x + a > right) break; + const auto& b = glyph->bitmap; + for (unsigned row = 0; row < b.rows; ++row) + for (unsigned col = 0; col < b.width; ++col) { + int xx = x + glyph->bitmap_left + int(col), yy = baseline - glyph->bitmap_top + int(row); + if (xx < 0 || xx >= right || xx >= W || yy < 0 || yy >= H) continue; + unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)]; + auto* dst = pixels.data() + (size_t(yy) * W + xx) * 4; + for (int k = 0; k < 3; ++k) + dst[k] = static_cast((unsigned(dst[k]) * (255 - alpha) + unsigned(color[k]) * alpha) / 255); + } + x += a; + } + } + 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::Enter) return panel.enabled && !panel.recording; + if (a == UiAction::Toggle) return false; + return true; + } + bool visible(Control c) const { + if (c == Control::Prev || c == Control::Next) return !settings; + if (mounting(c) || c == Control::Recenter) return settings; + return true; + } + bool enabled(Control c) const { + if (auto a = action(c)) return available(*a); + if (c == Control::Prev) return page > 0; + if (c == Control::Next) return page + 1 < page_count(); + if (c == Control::Recenter) return mount == Mount::World; + return true; + } + int hit(float x, float y) const { + for (size_t i = 0; i < buttons.size(); ++i) + if (visible(buttons[i].id) && enabled(buttons[i].id) && buttons[i].r.contains(x, y)) return int(i); + return -1; + } + void reset() { + if (std::any_of(pressed.begin(), pressed.end(), [](int i) { return i >= 0; }) || + std::any_of(hovered.begin(), hovered.end(), [](int i) { return i >= 0; })) dirty = true; + pressed.fill(-1); hovered.fill(-1); + } + bool render(const Panel& p) { + if (p.recording != panel.recording || p.enabled != panel.enabled || + p.record_available != panel.record_available || p.transcript != panel.transcript) { + reset(); // an old press cannot authorize a changed action or replacement transcript + dirty = true; + } + if (p.transcript != panel.transcript || lines.empty()) { + 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) + dirty = true; + panel = p; + if (!dirty) return false; + rect({0, 0, W, H}, background); + frame(); + text("FrameYap", 32, 61, 40, ink, 300); + text("ON-DEVICE / REVIEW FIRST", 280, 58, 22, muted, 720); + text(mount_label(mount), 756, 58, 24, cyan, 968); + rect({32, 84, 7, 34}, panel.recording ? pink : cyan); + auto status = wrap(panel.status, 27, 790); + text(status.front(), 54, 109, 27, ink, 844); + if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968); + rect({32, 198, 936, 1}, {44, 61, 79, 255}); + if (!settings) { + rect({32, 214, 936, 176}, card); + rect({32, 214, 3, 176}, {50, 102, 119, 255}); + 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); + } else { + text("MOUNT THE MENU", 32, 224, 22, muted, 968); + text("World stays put. Recenter places it in front of you.", 354, 426, 22, muted, 968); + text(placement_note.empty() ? "Choose a mount. Preference is saved on this device." : placement_note, + 32, 486, 23, muted, 968); + text("Tracking lost? Wrist placement falls back to world space.", 32, 518, 23, muted, 968); + } + rect({32, 556, 936, 1}, {44, 61, 79, 255}); + for (size_t i = 0; i < buttons.size(); ++i) { + const auto& b = buttons[i]; + if (!visible(b.id)) continue; + bool on = enabled(b.id); + bool selected = (b.id == Control::Review && !settings) || (b.id == Control::Settings && settings) || + (mounting(b.id) && *mounting(b.id) == mount); + bool hover = std::find(hovered.begin(), hovered.end(), int(i)) != hovered.end(); + bool down = std::find(pressed.begin(), pressed.end(), int(i)) != pressed.end(); + rect(b.r, !on ? Color{18, 23, 34, 255} : down ? Color{37, 74, 89, 255} : + hover || selected ? Color{27, 53, 68, 255} : card); + Color accent = b.id == Control::Record && panel.recording ? pink : cyan; + if (on && (hover || selected || b.id == Control::Record)) neon(b.r, accent); + else border(b.r, on ? Color{58, 79, 101, 255} : Color{30, 40, 53, 255}); + if (selected) rect({b.r.x + 1, b.r.y + 1, 4, b.r.h - 2}, cyan); + const auto label = b.id == Control::Record && panel.recording ? "Stop" : b.label; + text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 27, on ? ink : Color{81, 96, 113, 255}, b.r.x + b.r.w - 8); + if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12); + } + dirty = false; + return true; + } +}; +PanelSurface::PanelSurface(const std::string& font, Mount mount) : impl_(std::make_unique(font, mount)) {} +PanelSurface::~PanelSurface() = default; +bool PanelSurface::render(const Panel& p) { return impl_->render(p); } +const std::vector& PanelSurface::pixels() const { return impl_->pixels; } +bool PanelSurface::available(UiAction a) const { return impl_->available(a); } +void PanelSurface::pointer_move(unsigned cursor, float x, float y) { + if (cursor >= impl_->hovered.size()) return; + const int next = impl_->hit(x, y); + if (next != impl_->hovered[cursor]) { impl_->hovered[cursor] = next; impl_->dirty = true; } +} +void PanelSurface::pointer_down(unsigned cursor, float x, float y) { + if (cursor >= impl_->pressed.size()) return; + impl_->pressed[cursor] = impl_->hit(x, y); + pointer_move(cursor, x, y); + impl_->dirty = true; +} +SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) { + SurfaceEvent result; + if (cursor >= impl_->pressed.size()) return result; + int index = std::exchange(impl_->pressed[cursor], -1); + pointer_move(cursor, x, y); + impl_->dirty = true; + if (index < 0 || impl_->hit(x, y) != index) return result; + auto c = buttons[index].id; + 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(); } + else if (c == Control::Recenter) { result.recenter = true; impl_->reset(); } + else if (c == Control::Review || c == Control::Settings) { impl_->settings = c == Control::Settings; impl_->reset(); } + else if (c == Control::Prev) --impl_->page; + else if (c == Control::Next) ++impl_->page; + return result; +} +void PanelSurface::reset_pointers() { impl_->reset(); } +void PanelSurface::set_placement_note(std::string note) { + if (impl_->placement_note != note) { impl_->placement_note = std::move(note); impl_->dirty = true; } +} +} // namespace frameyap diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp new file mode 100644 index 0000000..b99b410 --- /dev/null +++ b/src/panel_surface.hpp @@ -0,0 +1,36 @@ +#pragma once +#include "overlay.hpp" +#include +#include +#include +#include +#include + +namespace frameyap { +struct SurfaceEvent { + std::optional action; + std::optional mount; + bool recenter = false; +}; +// One CPU RGBA canvas, independent of OpenVR. Settings replace the review area; +// status and safety controls remain on the same surface. +class PanelSurface { +public: + static constexpr int width = 1000, height = 680; + PanelSurface(const std::string& font, Mount mount); + ~PanelSurface(); + PanelSurface(const PanelSurface&) = delete; + PanelSurface& operator=(const PanelSurface&) = delete; + bool render(const Panel& panel); + const std::vector& pixels() const; + void pointer_down(unsigned cursor, float x, float y); + SurfaceEvent pointer_up(unsigned cursor, float x, float y); + void pointer_move(unsigned cursor, float x, float y); + void reset_pointers(); + void set_placement_note(std::string note); + bool available(UiAction action) const; +private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace frameyap diff --git a/src/runtime.cpp b/src/runtime.cpp index abd83e4..b93e781 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -40,7 +40,7 @@ int run(const Options& options) { auto old_int = std::signal(SIGINT, signal_stop); auto old_term = std::signal(SIGTERM, signal_stop); struct Restore { decltype(old_int) a, b; ~Restore() { std::signal(SIGINT, a); std::signal(SIGTERM, b); } } restore{old_int, old_term}; - Overlay overlay(options.assets, options.font, options.hand); + Overlay overlay(options.assets, options.font, options.mount); Worker worker; Audio audio; Session session; @@ -88,8 +88,9 @@ int run(const Options& options) { } // Drawing precedes input polling: Enter is disabled until Ready is visible. Panel panel{state_label(session.state()), session.text(), detail, - session.state() != State::Error && session.state() != State::Warming, - session.state() == State::Recording}; + 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}; if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s"; overlay.draw(panel); for (auto action : overlay.poll()) { diff --git a/src/runtime.hpp b/src/runtime.hpp index 9fa1746..510a912 100644 --- a/src/runtime.hpp +++ b/src/runtime.hpp @@ -1,10 +1,12 @@ #pragma once +#include "mount.hpp" +#include #include namespace frameyap { struct Options { std::string assets, font, python = "python3", worker, model, socket; int threads = 2; - bool hand = true; + std::optional mount; // saved preference, or world on first launch }; int run(const Options& options); } diff --git a/tests/mount_test.cpp b/tests/mount_test.cpp new file mode 100644 index 0000000..aba354d --- /dev/null +++ b/tests/mount_test.cpp @@ -0,0 +1,127 @@ +#include "mount.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace frameyap; +namespace fs = std::filesystem; +#define CHECK(x) do { if (!(x)) { std::cerr << "line " << __LINE__ << ": " #x "\n"; std::exit(1); } } while (0) + +namespace { +constexpr Matrix34 identity{{{{1.f, 0.f, 0.f, 0.f}}, + {{0.f, 1.f, 0.f, 0.f}}, + {{0.f, 0.f, 1.f, 0.f}}}}; +bool near(float a, float b) { return std::abs(a - b) < 1e-4f; } +void check_basis(const Matrix34& pose, float right_x, float right_z, float back_x, float back_z) { + CHECK(near(pose[0][0], right_x)); CHECK(near(pose[1][0], 0)); CHECK(near(pose[2][0], right_z)); + CHECK(near(pose[0][1], 0)); CHECK(near(pose[1][1], 1)); CHECK(near(pose[2][1], 0)); + CHECK(near(pose[0][2], back_x)); CHECK(near(pose[1][2], 0)); CHECK(near(pose[2][2], back_z)); +} +struct TempDir { + fs::path path; + TempDir() { + std::string pattern = (fs::temp_directory_path() / "frameyap-mount-test-XXXXXX").string(); + auto* dir = ::mkdtemp(pattern.data()); + CHECK(dir != nullptr); + path = dir; + } + ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } +}; +void put(const fs::path& path, const std::string& content) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + CHECK(bool(out)); out << content; CHECK(bool(out)); +} +struct EnvRestore { + std::string key; + bool had; + std::string original; + explicit EnvRestore(const char* k) : key(k), had(std::getenv(k) != nullptr), original(had ? std::getenv(k) : "") {} + ~EnvRestore() { if (had) ::setenv(key.c_str(), original.c_str(), 1); else ::unsetenv(key.c_str()); } +}; +} // namespace + +int main() { + const std::array mounts{Mount::World, Mount::LeftWrist, Mount::RightWrist, Mount::Head}; + const std::array names{"world", "left-wrist", "right-wrist", "head"}; + for (std::size_t i = 0; i < mounts.size(); ++i) { + CHECK(mount_name(mounts[i]) == names[i]); + CHECK(parse_mount(names[i]) == mounts[i]); + } + CHECK(!parse_mount("World")); CHECK(!parse_mount("head\n")); CHECK(!parse_mount("left_wrist")); + CHECK(!parse_mount("")); CHECK(!parse_mount("world extra")); + + CHECK(near(mount_width(Mount::World), .85f)); CHECK(near(mount_width(Mount::Head), .85f)); + CHECK(near(mount_width(Mount::LeftWrist), .42f)); CHECK(near(mount_width(Mount::RightWrist), .42f)); + CHECK(relative_mount_pose(Mount::World) == identity); + const auto head = relative_mount_pose(Mount::Head); + check_basis(head, 1, 0, 0, 1); + CHECK(near(head[0][3], 0)); CHECK(near(head[1][3], -.16f)); CHECK(near(head[2][3], -1.05f)); + const auto left = relative_mount_pose(Mount::LeftWrist), right = relative_mount_pose(Mount::RightWrist); + check_basis(left, 1, 0, 0, 1); check_basis(right, 1, 0, 0, 1); + CHECK(near(left[0][3], .13f)); CHECK(near(right[0][3], -.13f)); + CHECK(near(left[1][3], .12f)); CHECK(near(right[1][3], .12f)); + CHECK(near(left[2][3], -.18f)); CHECK(near(right[2][3], -.18f)); + + auto hmd = identity; + hmd[0][3] = 2; hmd[1][3] = 1.7f; hmd[2][3] = 3; + auto pose = world_mount_pose(hmd); + CHECK(pose); check_basis(*pose, 1, 0, 0, 1); + CHECK(near((*pose)[0][3], 2)); CHECK(near((*pose)[1][3], 1.54f)); CHECK(near((*pose)[2][3], 1.95f)); + // 90 degree yaw: HMD looks +X. The panel's +Z points back toward -X. + hmd[0][0] = 0; hmd[0][2] = -1; hmd[2][0] = 1; hmd[2][2] = 0; + pose = world_mount_pose(hmd); + CHECK(pose); check_basis(*pose, 0, 1, -1, 0); + CHECK(near((*pose)[0][3], 3.05f)); CHECK(near((*pose)[2][3], 3)); + // Pitch must not tilt the panel or change its upright orientation. + hmd = identity; + hmd[1][1] = .8f; hmd[1][2] = -.6f; + hmd[2][1] = .6f; hmd[2][2] = .8f; + pose = world_mount_pose(hmd); + CHECK(pose); check_basis(*pose, 1, 0, 0, 1); + CHECK(near((*pose)[1][3], -.16f)); CHECK(near((*pose)[2][3], -1.05f)); + hmd[0][2] = 0; hmd[2][2] = 0; // forward is vertical, yaw undefined + CHECK(!world_mount_pose(hmd)); + hmd[2][2] = 1e-6f; CHECK(!world_mount_pose(hmd)); + hmd = identity; hmd[0][3] = std::numeric_limits::infinity(); CHECK(!world_mount_pose(hmd)); + hmd = identity; hmd[0][0] = std::numeric_limits::quiet_NaN(); CHECK(!world_mount_pose(hmd)); + + TempDir tmp; + const auto file = tmp.path / "nested" / "mount"; + CHECK(load_mount(file) == Mount::World); CHECK(!save_mount({}, Mount::Head)); + for (auto mount : mounts) { + CHECK(save_mount(file, mount)); + CHECK(load_mount(file) == mount); + std::ifstream in(file, std::ios::binary); + CHECK(bool(in)); + const std::string data{std::istreambuf_iterator(in), std::istreambuf_iterator()}; + CHECK(data == std::string(mount_name(mount)) + "\n"); + } + put(file, "other\n"); CHECK(load_mount(file) == Mount::World); + put(file, "head\nextra\n"); CHECK(load_mount(file) == Mount::World); + put(file, std::string(128, 'a')); CHECK(load_mount(file) == Mount::World); + put(file, std::string("head\0\n", 6)); CHECK(load_mount(file) == Mount::World); + CHECK(!save_mount(tmp.path, Mount::Head)); // cannot rename over an existing directory + CHECK(!save_mount(file, static_cast(-1))); + CHECK(load_mount(tmp.path) == Mount::World); + + EnvRestore restore_xdg("XDG_CONFIG_HOME"), restore_home("HOME"); + ::setenv("XDG_CONFIG_HOME", tmp.path.c_str(), 1); + ::setenv("HOME", "/other-absolute-home", 1); + CHECK(default_mount_settings_path() == tmp.path / "frameyap/mount"); + ::setenv("XDG_CONFIG_HOME", "relative", 1); + CHECK(default_mount_settings_path() == fs::path("/other-absolute-home/.config/frameyap/mount")); + ::setenv("HOME", "relative", 1); + CHECK(default_mount_settings_path().empty()); + ::unsetenv("HOME"); ::unsetenv("XDG_CONFIG_HOME"); + CHECK(default_mount_settings_path().empty()); + std::cout << "mount checks passed\n"; +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp new file mode 100644 index 0000000..b994f1d --- /dev/null +++ b/tests/panel_test.cpp @@ -0,0 +1,136 @@ +#include "panel_surface.hpp" +#include +#include +#include +#include +#include + +using namespace frameyap; +namespace { +SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0) { + surface.pointer_down(cursor, x, y); + return surface.pointer_up(cursor, x, y); +} +void no_action(const SurfaceEvent& event) { assert(!event.action && !event.mount && !event.recenter); } +void snapshot(PanelSurface& surface, const std::string& path) { + std::ofstream out(path, std::ios::binary); + assert(out); + out << "P6\n" << PanelSurface::width << ' ' << PanelSurface::height << "\n255\n"; + const auto& pixels = surface.pixels(); + for (size_t i = 0; i < pixels.size(); i += 4) out.write(reinterpret_cast(pixels.data() + i), 3); + assert(out); +} +} +int main(int argc, char** argv) { + assert(argc >= 2); + PanelSurface surface(argv[1], Mount::World); + Panel p{"Ready to record", "", "Focus your destination before Insert. Enter is always separate.", false, false}; + assert(surface.render(p)); + assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4)); + assert(!surface.render(p)); + surface.reset_pointers(); + assert(!surface.render(p)); // repeated inactive-input resets do not force uploads + assert(surface.pixels()[3] == 0); // rounded outer corner is transparent + assert(surface.pixels()[(40 * PanelSurface::width + 40) * 4 + 3] == 255); + // Disabled controls never emit insertion/submission. Retry/Cancel remain available. + no_action(click(surface, 480, 610)); no_action(click(surface, 680, 610)); + assert(click(surface, 100, 610).action == UiAction::BeginRecord); + assert(click(surface, 280, 610).action == UiAction::Cancel); + no_action(surface.pointer_up(0, 100, 610)); + surface.pointer_down(0, 100, 610); + no_action(surface.pointer_up(0, 280, 610)); + surface.pointer_down(0, 100, 610); + surface.reset_pointers(); + no_action(surface.pointer_up(0, 100, 610)); + no_action(click(surface, 100, 610, 2)); + no_action(click(surface, std::numeric_limits::quiet_NaN(), 610)); + no_action(click(surface, -1, 610)); + no_action(click(surface, 1000, 680)); + + p.enabled = true; + p.record_available = false; + p.status = "Review your words"; + p.transcript = "A quieter way to type in VR.\nKeep the menu where it feels comfortable.\nNothing is sent until you choose Insert."; + assert(surface.render(p)); + surface.reset_pointers(); surface.render(p); + 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); + // 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)); + p.recording = true; p.status = "Recording - 00:04"; + surface.render(p); + no_action(surface.pointer_up(0, 480, 610)); + no_action(click(surface, 680, 610)); + assert(click(surface, 100, 610).action == UiAction::EndRecord); // explicit Stop, never a stale toggle + surface.reset_pointers(); surface.render(p); + if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-recording.ppm"); + surface.pointer_down(0, 100, 610); // an old Stop release must not begin recording again + p.recording = false; p.status = "Review your words"; + surface.render(p); + no_action(surface.pointer_up(0, 100, 610)); + p.record_available = false; p.enabled = false; // warming/transcribing + surface.render(p); + no_action(click(surface, 100, 610)); no_action(click(surface, 680, 610)); + assert(click(surface, 280, 610).action == UiAction::Cancel); + p.enabled = true; // review: Insert/Enter available, Record waits for discard/insertion + surface.render(p); + no_action(click(surface, 100, 610)); + assert(click(surface, 480, 610).action == UiAction::Insert); + surface.render(p); + + // Settings replace preview in the same RGBA surface; footer remains available. + no_action(click(surface, 290, 160)); + surface.render(p); + surface.reset_pointers(); surface.render(p); + if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-settings.ppm"); + assert(click(surface, 200, 420).recenter); + auto event = click(surface, 180, 332); + assert(event.mount == Mount::LeftWrist && !event.action); + no_action(click(surface, 200, 420)); // recenter is world-only + assert(click(surface, 680, 332).mount == Mount::RightWrist); + assert(click(surface, 680, 260).mount == Mount::Head); + assert(click(surface, 180, 260).mount == Mount::World); + assert(click(surface, 280, 610).action == UiAction::Cancel); + surface.set_placement_note("Wrist not tracked - using world space until it returns."); + assert(surface.render(p)); assert(!surface.render(p)); + surface.set_placement_note("Wrist not tracked - using world space until it returns."); + assert(!surface.render(p)); + // Switching tabs invalidates the other pointer's old press. + surface.pointer_down(1, 680, 260); + no_action(click(surface, 100, 160)); + no_action(surface.pointer_up(1, 680, 260)); + no_action(click(surface, 680, 260)); // mount controls not active on Review + + // Long UTF-8, newlines and malformed bytes are bounded, paginated and navigable. + p.transcript.clear(); + for (int i = 0; i < 60; ++i) p.transcript += "Line " + std::to_string(i) + ": café / 日本語 / naïve / Ω\n"; + p.transcript += std::string("invalid: \xff\xc0\xe0\x80", 13); + surface.render(p); + surface.reset_pointers(); surface.render(p); + const auto first = surface.pixels(); + no_action(click(surface, 900, 430)); + surface.reset_pointers(); assert(surface.render(p)); + assert(surface.pixels() != first); + no_action(click(surface, 90, 430)); + surface.reset_pointers(); surface.render(p); + assert(surface.pixels() == first); + for (int i = 0; i < 200; ++i) { no_action(click(surface, 900, 430)); surface.render(p); } + for (int i = 0; i < 200; ++i) { no_action(click(surface, 90, 430)); surface.render(p); } + surface.reset_pointers(); surface.render(p); + assert(surface.pixels() == first); + p.transcript = "New result"; + assert(surface.render(p)); + surface.reset_pointers(); surface.render(p); + auto replaced = surface.pixels(); + no_action(click(surface, 900, 430)); + surface.reset_pointers(); surface.render(p); + assert(surface.pixels() == replaced); + p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd'); + assert(surface.render(p)); assert(!surface.render(p)); + surface.pointer_move(0, 900, 610); assert(surface.render(p)); + surface.pointer_move(0, 900, 610); assert(!surface.render(p)); + assert(click(surface, 900, 610).action == UiAction::Quit); + std::cout << "panel checks passed (no OpenVR, microphone or input injection)\n"; +}