diff --git a/CMakeLists.txt b/CMakeLists.txt index fefb898..c2101cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,8 @@ target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}" FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}") if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_library(frameyap_companions src/companion_apps.cpp) + target_include_directories(frameyap_companions PUBLIC src) add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp src/placement.cpp) target_include_directories(frameyap_mount PUBLIC src) add_library(frameyap_worker src/worker.cpp) @@ -102,7 +104,7 @@ if(FRAMEYAP_NATIVE) target_sources(frameyap PRIVATE src/runtime.cpp src/controller.cpp src/backend_manager.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp src/focus_guard.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_paced frameyap_worker frameyap_mount frameyap_battery frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}") + target_link_libraries(frameyap PRIVATE frameyap_paced frameyap_worker frameyap_mount frameyap_battery frameyap_panel frameyap_companions PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}") target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1) set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") endif() @@ -149,6 +151,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) 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_companions_test tests/companion_apps_test.cpp) + target_link_libraries(frameyap_companions_test PRIVATE frameyap_companions) + target_compile_options(frameyap_companions_test PRIVATE -UNDEBUG) + add_test(NAME frameyap.companions COMMAND frameyap_companions_test) add_executable(frameyap_config_test tests/config_test.cpp) target_link_libraries(frameyap_config_test PRIVATE frameyap_config) target_compile_options(frameyap_config_test PRIVATE -UNDEBUG) diff --git a/docs/overlay.md b/docs/overlay.md index f8e7f46..bb7a8ef 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -33,7 +33,7 @@ preview, status and control surfaces use independently rasterized antialiased ed and restrained baked neon halos rather than GPU bloom. The recording indicator and selected controls remain distinguishable by their labels, not color alone. Rounded control hit areas exclude their clipped corners. -Rendering/uploads occur for changed content, page or settings, and, when the +Rendering/uploads occur for changed content, scroll position or settings, and, when the animated gradient is enabled and visible, at most every 100 ms (10 fps) on a monotonic clock. There is no animation redraw while hidden. Laser hover and button down/up are hit-tested without an upload; static frames are reused when @@ -87,8 +87,13 @@ source URL, MIT license, the selected model's name and license from its manifest the bundled font's license, and a pointer to `docs/third-party.md`. It performs no checks and no network access; Settings returns. Recording controls stay live. -The complete transcript preview is paginated by glyph width and six-line -height; Previous and Next navigate it without changing the source transcript. +The transcript wraps by glyph width and scrolls in its review viewport with +the right-stick laser wheel; a new transcript resets the scroll position. +The former paging row is retained for **Open Plan** and **Open Keyboard** when +the installed `tnkplan` or `tnkboard` executable is found at startup in +`~/.local/bin` or an absolute PATH directory. Missing apps have no button. +Clicking launches the installed wrapper without a shell; those apps' normal +second-launch behavior toggles their existing panels. 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, Type (labelled Enter when nothing is pending review), Type + Enter, Hold Quit. Hold Quit needs a 900 ms press and release on diff --git a/src/companion_apps.cpp b/src/companion_apps.cpp new file mode 100644 index 0000000..8b736fa --- /dev/null +++ b/src/companion_apps.cpp @@ -0,0 +1,70 @@ +#include "companion_apps.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace frameyap { +namespace { +bool executable(const std::filesystem::path& path) { + struct stat info{}; + return path.is_absolute() && ::stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode) && + ::access(path.c_str(), X_OK) == 0; +} +} +std::optional find_companion(std::string_view name) { + if (name != "tnkplan" && name != "tnkboard") return {}; + if (const char* home = std::getenv("HOME")) { + const auto launcher = std::filesystem::path(home) / ".local/bin" / name; + if (executable(launcher)) return launcher; + } + if (const char* path = std::getenv("PATH")) { + const std::string dirs(path); + for (size_t start = 0; start <= dirs.size();) { + const size_t end = dirs.find(':', start); + const auto launcher = std::filesystem::path(dirs.substr(start, end - start)) / name; + // Ignore empty/relative PATH entries: never execute from an untrusted cwd. + if (executable(launcher)) return launcher; + if (end == std::string::npos) break; + start = end + 1; + } + } + return {}; +} +bool launch_companion(const std::filesystem::path& launcher) { + if (!executable(launcher)) return false; + const auto path = launcher.string(); + char* const args[] = {const_cast(path.c_str()), nullptr}; + // Double fork so the long-lived companion is adopted rather than becoming + // a zombie owned by FrameYap. An O_CLOEXEC pipe reports exec failure only. + int pipefd[2]; + if (::pipe2(pipefd, O_CLOEXEC) != 0) return false; + const pid_t intermediate = ::fork(); + if (intermediate < 0) { ::close(pipefd[0]); ::close(pipefd[1]); return false; } + if (intermediate == 0) { + ::close(pipefd[0]); + const pid_t child = ::fork(); + if (child < 0) { + const int error = errno; + (void)::write(pipefd[1], &error, sizeof(error)); + } else if (child == 0) { + ::execv(path.c_str(), args); + const int error = errno; + (void)::write(pipefd[1], &error, sizeof(error)); + } + ::_exit(child < 0 ? 1 : 0); + } + ::close(pipefd[1]); + int error = 0; + ssize_t count; + do { count = ::read(pipefd[0], &error, sizeof(error)); } while (count < 0 && errno == EINTR); + ::close(pipefd[0]); + int status = 0; + pid_t reaped; + do { reaped = ::waitpid(intermediate, &status, 0); } while (reaped < 0 && errno == EINTR); + return count == 0 && reaped == intermediate && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +} diff --git a/src/companion_apps.hpp b/src/companion_apps.hpp new file mode 100644 index 0000000..e6feb1b --- /dev/null +++ b/src/companion_apps.hpp @@ -0,0 +1,12 @@ +#pragma once +#include +#include +#include + +namespace frameyap { +// Resolve an installed launcher once at boot; never search the source checkout. +std::optional find_companion(std::string_view name); +// Launch the installed wrapper (which toggles an existing overlay) without a shell. +// Returns false if fork or exec fails; does not wait for the overlay to exit. +bool launch_companion(const std::filesystem::path& launcher); +} diff --git a/src/overlay.cpp b/src/overlay.cpp index 7bee84f..3c3369d 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -5,6 +5,7 @@ #include "laser_setting.hpp" #include "placement.hpp" #include "panel_surface.hpp" +#include "companion_apps.hpp" #include @@ -93,6 +94,7 @@ struct Overlay::Impl { bool lasers_anytime = false; Config config; PanelSurface surface; + std::optional plan_launcher, keyboard_launcher; Panel panel; std::vector model_actions; StatusIndicators indicators; @@ -139,6 +141,9 @@ struct Overlay::Impl { config(load_config(default_config_path())), surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) { persistence.persist_mount = persist; + plan_launcher = find_companion("tnkplan"); + keyboard_launcher = find_companion("tnkboard"); + surface.set_companions(plan_launcher.has_value(), keyboard_launcher.has_value()); for (auto selected : {Mount::LeftWrist, Mount::RightWrist, Mount::Head}) saved_placements[static_cast(selected)] = load_relative_placement(default_placement_path(selected), selected); @@ -560,7 +565,7 @@ struct Overlay::Impl { if (event.data.scroll.cursorIndex < cursor_positions.size() && cursor_positions[event.data.scroll.cursorIndex]) { const auto [x, y] = *cursor_positions[event.data.scroll.cursorIndex]; - surface.scroll_settings(x, y, event.data.scroll.ydelta); + surface.scroll(x, y, event.data.scroll.ydelta); } break; case vr::VREvent_MouseButtonDown: @@ -579,8 +584,15 @@ struct Overlay::Impl { diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button); 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 || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions; + if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.launch_companion || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions; if (event_result.action) result.push_back(*event_result.action); + if (event_result.launch_companion) { + const auto& launcher = *event_result.launch_companion == SurfaceEvent::Companion::Plan ? + plan_launcher : keyboard_launcher; + if (launcher && !launch_companion(*launcher)) + surface.set_binding_note("Could not open companion app."); + else surface.set_binding_note(""); + } if (event_result.model_action) { model_actions.push_back(*event_result.model_action); reset_input(result); // revoke held PTT, delivery and stale pointer approval diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index b83e803..af4cf55 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -28,7 +28,7 @@ struct Rect { rounded_distance(px, py, x, y, w, h, std::min(16, h / 3)) <= 0.f; } }; -enum class Control { Review, Settings, Bindings, Prev, Next, Record, Cancel, Insert, Enter, Quit, +enum class Control { Review, Settings, Bindings, Plan, Keyboard, Record, Cancel, Insert, Enter, Quit, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert, Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow, ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback }; @@ -39,8 +39,8 @@ constexpr std::array buttons{{ {{226, 138, 180, 46}, Control::Settings, "Settings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"}, {{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"}, - {{32, 496, 154, 44}, Control::Prev, "Previous"}, - {{838, 496, 130, 44}, Control::Next, "Next"}, + {{32, 496, 454, 44}, Control::Plan, "Open Plan"}, + {{514, 496, 454, 44}, Control::Keyboard, "Open Keyboard"}, {{32, 574, 176, 68}, Control::Record, "Record"}, {{222, 574, 176, 68}, Control::Cancel, "Cancel"}, {{412, 574, 176, 68}, Control::Insert, "Type"}, @@ -71,6 +71,8 @@ constexpr std::array buttons{{ {{32, 584, 936, 50}, Control::WristWorldFallback, "Wrist world fallback"}, }}; constexpr Rect settings_view{32, 232, 936, 316}; +constexpr Rect review_view{48, 212, 904, 260}; +constexpr int review_line_height = 42; constexpr int settings_max_scroll = 634 - (settings_view.y + settings_view.h); std::optional action(Control c) { switch (c) { @@ -134,7 +136,9 @@ struct PanelSurface::Impl { bool close_mic_when_idle = false, wrist_world_fallback = true; bool clock_24h = false, layout_locked = false; float settings_scroll = 0.f; - bool clip_settings = false; + std::optional clip_view; + bool plan_present = false, keyboard_present = false; + float review_scroll = 0.f; DateFormat date_format = DateFormat::MonthDayYear; std::time_t clock_time = std::time(nullptr); ClockLabel displayed_clock; @@ -145,14 +149,12 @@ struct PanelSurface::Impl { int hold_progress = 0; int drag_cursor = -1; std::vector lines; - size_t page = 0; size_t model_page = 0; size_t consent_page = 0; size_t consent_rendered_page = size_t(-1); bool install_confirm = false; std::optional consent_snapshot; std::vector consent_lines; - static constexpr size_t lines_per_page = 6; static constexpr size_t consent_lines_per_page = 6; size_t consent_pages() const { return std::max(size_t(1), (consent_lines.size() + consent_lines_per_page - 1) / consent_lines_per_page); @@ -203,15 +205,15 @@ struct PanelSurface::Impl { return result; } void rect(Rect r, Color c) { - for (int y = std::max(r.y, clip_settings ? settings_view.y : 0); - y < std::min(clip_settings ? settings_view.y + settings_view.h : H, r.y + r.h); ++y) - for (int x = std::max(r.x, clip_settings ? settings_view.x : 0); - x < std::min(clip_settings ? settings_view.x + settings_view.w : W, r.x + r.w); ++x) + for (int y = std::max(r.y, clip_view ? clip_view->y : 0); + y < std::min(clip_view ? clip_view->y + clip_view->h : H, r.y + r.h); ++y) + for (int x = std::max(r.x, clip_view ? clip_view->x : 0); + x < std::min(clip_view ? clip_view->x + clip_view->w : W, r.x + r.w); ++x) std::copy(c.begin(), c.end(), pixels.begin() + (size_t(y) * W + x) * 4); } void blend(int x, int y, Color color, float amount) { - if (clip_settings && (x < settings_view.x || x >= settings_view.x + settings_view.w || - y < settings_view.y || y >= settings_view.y + settings_view.h)) return; + if (clip_view && (x < clip_view->x || x >= clip_view->x + clip_view->w || + y < clip_view->y || y >= clip_view->y + clip_view->h)) return; auto* dst = pixels.data() + (size_t(y) * W + x) * 4; // Straight-alpha source-over, including strokes in the transparent margin. const float alpha = amount * color[3] / 255.f; @@ -350,8 +352,8 @@ struct PanelSurface::Impl { 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 || - (clip_settings && (xx < settings_view.x || xx >= settings_view.x + settings_view.w || - yy < settings_view.y || yy >= settings_view.y + settings_view.h))) continue; + (clip_view && (xx < clip_view->x || xx >= clip_view->x + clip_view->w || + yy < clip_view->y || yy >= clip_view->y + clip_view->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) @@ -360,7 +362,9 @@ struct PanelSurface::Impl { x += a; } } - size_t page_count() const { return std::max(size_t(1), (lines.size() + lines_per_page - 1) / lines_per_page); } + float max_review_scroll() const { + return float(lines.size() > 6 ? (lines.size() - 6) * review_line_height : 0); + } bool available(UiAction a) const { if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available); // With nothing to review, Type is an explicit Enter. @@ -376,7 +380,8 @@ struct PanelSurface::Impl { if (c == Control::ModelRow) return tab == Tab::Models && !install_confirm; if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models; if (c == Control::ModelInstall) return tab == Tab::Models; - if (c == Control::Prev || c == Control::Next) return tab == Tab::Review && !panel.quick_open; + if (c == Control::Plan) return tab == Tab::Review && plan_present; + if (c == Control::Keyboard) return tab == Tab::Review && keyboard_present; if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug || c == Control::AutoInsert || c == Control::Clock24h || c == Control::Date || c == Control::LockLayout || @@ -385,8 +390,6 @@ struct PanelSurface::Impl { } 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; if (c == Control::ModelRow) return !panel.model_busy; if (c == Control::ModelInstall) { @@ -451,7 +454,7 @@ struct PanelSurface::Impl { } if (p.transcript != panel.transcript || lines.empty()) { lines = wrap(p.transcript.empty() ? "Your words will appear here.\nReview them, then choose Type." : p.transcript, 32, 904); - page = 0; dirty = true; + review_scroll = 0.f; dirty = true; } 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 || @@ -523,12 +526,19 @@ struct PanelSurface::Impl { } } else if (tab == Tab::Review) { rounded({32, 204, 936, 278}, 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, 240 + int(i) * 42, 32, + clip_view = review_view; + const size_t first = size_t(review_scroll / review_line_height); + for (size_t i = first; i < lines.size() && i <= first + 7; ++i) + text(lines[i], 48, 240 + int(i * review_line_height - review_scroll), 32, panel.transcript.empty() ? muted : ink, 952); - // A Bindings failure is the only note here; it replaces the page count. - if (!binding_note.empty()) text(binding_note, 200, 526, 18, pink, 824); - else text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 526, 23, muted, 790); + clip_view.reset(); + if (max_review_scroll() > 0) { + rect({956, review_view.y, 3, review_view.h}, mix(card, muted, .24f)); + const int thumb = std::max(24, int(review_view.h * review_view.h / + (review_view.h + max_review_scroll()))); + rect({955, review_view.y + int(review_scroll * (review_view.h - thumb) / max_review_scroll()), + 5, thumb}, cyan); + } } else if (tab == Tab::Models) { text("Models: select to restart. Install always needs explicit confirmation.", 32, 202, 19, muted, 968); const auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; }); @@ -556,12 +566,14 @@ struct PanelSurface::Impl { 32, 225, 17, muted, 968); } rect({32, 550, 936, 1}, mix(card, cyan, .17f)); + if (tab == Tab::Review && !panel.quick_open && max_review_scroll() > 0) + text("Right stick: scroll review", 32, 568, 16, muted, 400); for (size_t i = 0; i < buttons.size(); ++i) { const auto& b = buttons[i]; if (!visible(b.id)) continue; if (b.id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue; const Rect r = button_rect(b); - clip_settings = settings_control(b.id); + if (settings_control(b.id)) clip_view = settings_view; bool on = enabled(b.id); bool selected = (b.id == Control::Review && tab == Tab::Review) || (b.id == Control::Settings && tab == Tab::Settings) || @@ -611,7 +623,7 @@ struct PanelSurface::Impl { text(active ? "ON" : "OFF", r.x + r.w - 66, r.y + r.h / 2 + 9, 22, active ? cyan : muted, r.x + r.w - 12); } - clip_settings = false; + clip_view.reset(); } if (tab == Tab::Settings) { rect({976, settings_view.y, 3, settings_view.h}, mix(card, muted, .24f)); @@ -621,6 +633,8 @@ struct PanelSurface::Impl { text("Right stick: scroll", 32, 565, 16, muted, 320); text("OFF: discard idle audio; ON: spike / start latency.", 390, 565, 16, muted, 887); } + if (tab == Tab::Review && !binding_note.empty()) + text(binding_note, 32, 665, 18, pink, 968); if (tab == Tab::Models && !panel.model_note.empty()) { // The install/navigation controls occupy y=530..566; the status // belongs BELOW the shared footer (574..642), not under buttons. @@ -738,13 +752,23 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: impl_->tab = c == Control::Review ? Tab::Review : Tab::Settings; impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true; } - else if (c == Control::Prev) { --impl_->page; impl_->dirty = true; } - else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; } + else if (c == Control::Plan || c == Control::Keyboard) { + result.launch_companion = c == Control::Plan ? SurfaceEvent::Companion::Plan : SurfaceEvent::Companion::Keyboard; + impl_->reset(); + } return result; } -bool PanelSurface::scroll_settings(float x, float y, float vertical_delta) { - if (impl_->tab != Tab::Settings || impl_->drag_cursor >= 0 || - !settings_view.contains(x, y) || !std::isfinite(vertical_delta)) return false; +bool PanelSurface::scroll(float x, float y, float vertical_delta) { + if (impl_->drag_cursor >= 0 || !std::isfinite(vertical_delta)) return false; + if (impl_->tab == Tab::Review && !impl_->panel.quick_open && review_view.contains(x, y)) { + const float next = std::clamp(impl_->review_scroll - std::clamp(vertical_delta, -4.f, 4.f) * 48.f, + 0.f, impl_->max_review_scroll()); + if (next == impl_->review_scroll) return false; + impl_->review_scroll = next; + impl_->reset(); impl_->dirty = true; + return true; + } + if (impl_->tab != Tab::Settings || !settings_view.contains(x, y)) return false; const float next = std::clamp(impl_->settings_scroll - std::clamp(vertical_delta, -4.f, 4.f) * 48.f, 0.f, float(settings_max_scroll)); if (next == impl_->settings_scroll) return false; @@ -753,6 +777,12 @@ bool PanelSurface::scroll_settings(float x, float y, float vertical_delta) { impl_->dirty = true; return true; } +void PanelSurface::set_companions(bool plan, bool keyboard) { + if (impl_->plan_present != plan || impl_->keyboard_present != keyboard) { + impl_->plan_present = plan; impl_->keyboard_present = keyboard; + impl_->reset(); impl_->dirty = true; + } +} void PanelSurface::set_indicators(const StatusIndicators& indicators) { if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; } } diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index 6dd7817..32d53c9 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -12,6 +12,8 @@ namespace frameyap { struct SurfaceEvent { + enum class Companion { Plan, Keyboard }; + std::optional launch_companion; std::optional action; std::optional mount; std::optional lasers_anytime; @@ -54,8 +56,9 @@ public: static constexpr auto quit_hold = std::chrono::milliseconds(900); std::optional 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()); - // OpenVR laser wheel input at the last hovered panel coordinate; no paging. - bool scroll_settings(float x, float y, float vertical_delta); + // OpenVR laser wheel input at the last hovered panel coordinate. + bool scroll(float x, float y, float vertical_delta); + void set_companions(bool plan, bool keyboard); bool dragging(unsigned cursor) const; // OpenVR intersection masks use top-left coordinates, unlike mouse events. std::vector input_regions() const; diff --git a/tests/companion_apps_test.cpp b/tests/companion_apps_test.cpp new file mode 100644 index 0000000..42adc62 --- /dev/null +++ b/tests/companion_apps_test.cpp @@ -0,0 +1,42 @@ +#include "companion_apps.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +int main() { + char name[] = "/tmp/frameyap-companions-XXXXXX"; + const auto root = fs::path(::mkdtemp(name)); + const auto home = root / "home", local = home / ".local/bin", path_dir = root / "path"; + fs::create_directories(local); fs::create_directories(path_dir); + ::setenv("HOME", home.c_str(), 1); + ::setenv("PATH", path_dir.c_str(), 1); + assert(!frameyap::find_companion("tnkplan")); + assert(!frameyap::find_companion("tnkboard")); + assert(!frameyap::find_companion("other")); + const auto plan = local / "tnkplan", keyboard = path_dir / "tnkboard"; + std::ofstream(plan) << "#!/bin/sh\nexit 0\n"; + std::ofstream(keyboard) << "#!/bin/sh\nexit 0\n"; + assert(!frameyap::find_companion("tnkplan")); // a file is not yet executable + ::chmod(plan.c_str(), 0700); + ::chmod(keyboard.c_str(), 0700); + assert(frameyap::find_companion("tnkplan") == plan); + assert(frameyap::find_companion("tnkboard") == keyboard); + ::setenv("PATH", (":" + path_dir.string()).c_str(), 1); + assert(frameyap::find_companion("tnkboard") == keyboard); + const auto marker = root / "launched"; + std::ofstream(plan) << "#!/bin/sh\nprintf launched > '" << marker.string() << "'\n"; + assert(frameyap::launch_companion(plan)); + for (int i = 0; i < 100 && !fs::exists(marker); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(fs::exists(marker)); + fs::remove(plan); + assert(!frameyap::launch_companion(plan)); + fs::remove_all(root); +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 2ec2050..2e5738c 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -14,7 +14,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0) } void no_action(const SurfaceEvent& event) { assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert && - !event.close_mic_when_idle && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action); + !event.close_mic_when_idle && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action && !event.launch_companion); } void snapshot(PanelSurface& surface, const std::string& path) { std::ofstream out(path, std::ios::binary); @@ -328,16 +328,16 @@ int main(int argc, char** argv) { assert(click(surface, 200, 532).close_mic_when_idle == false); surface.set_close_mic_when_idle(false); assert(surface.render(p)); // The Settings viewport scrolls continuously under the laser; it never pages. - assert(!surface.scroll_settings(290, 160, -1.f)); // tab/header, not settings content - assert(!surface.scroll_settings(200, 400, std::numeric_limits::quiet_NaN())); + assert(!surface.scroll(290, 160, -1.f)); // tab/header, not settings content + assert(!surface.scroll(200, 400, std::numeric_limits::quiet_NaN())); surface.pointer_down(0, 200, 532); // pending mic press must not activate after movement - assert(surface.scroll_settings(200, 400, -.5f)); + assert(surface.scroll(200, 400, -.5f)); no_action(surface.pointer_up(0, 200, 532)); assert(surface.render(p)); - assert(!surface.scroll_settings(200, 610, -1.f)); // fixed footer - assert(surface.scroll_settings(200, 400, -2.f)); + assert(!surface.scroll(200, 610, -1.f)); // fixed footer + assert(surface.scroll(200, 400, -2.f)); assert(surface.render(p)); - assert(!surface.scroll_settings(200, 400, -1.f)); // bottom clamp + assert(!surface.scroll(200, 400, -1.f)); // bottom clamp no_action(click(surface, 180, 231)); // scrolled settings cannot be clicked above the viewport auto fallback = click(surface, 200, 520); assert(fallback.wrist_world_fallback == false && !fallback.action); @@ -347,9 +347,9 @@ int main(int argc, char** argv) { assert(fallback.wrist_world_fallback == true); surface.set_wrist_world_fallback(true); assert(surface.render(p)); assert(click(surface, 280, 610).action == UiAction::Cancel); // footer stays fixed - assert(surface.scroll_settings(200, 400, 4.f)); + assert(surface.scroll(200, 400, 4.f)); assert(surface.render(p)); - assert(!surface.scroll_settings(200, 400, 1.f)); // top clamp + assert(!surface.scroll(200, 400, 1.f)); // top clamp 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."); @@ -368,7 +368,7 @@ int main(int argc, char** argv) { no_action(click(surface, 200, 492)); // clock/date controls only exist on Settings no_action(click(surface, 680, 492)); no_action(click(surface, 200, 532)); // mic preference only exists on Settings - assert(!surface.scroll_settings(200, 400, -1.f)); // Review has no Settings scroll + assert(!surface.scroll(200, 400, -1.f)); // short Review needs no scrolling // Bindings opens SteamVR directly, from either tab, without replacing review. surface.pointer_down(1, 480, 610); @@ -391,29 +391,44 @@ int main(int argc, char** argv) { assert(click(surface, 680, 260).mount == Mount::Head); // still in Settings no_action(click(surface, 100, 160)); - // Long UTF-8, newlines and malformed bytes are bounded, paginated and navigable. + // Installed companions alone get launch controls in the old paging row. + no_action(click(surface, 90, 518)); no_action(click(surface, 900, 518)); + surface.set_companions(true, false); assert(surface.render(p)); + assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Plan); + no_action(click(surface, 900, 518)); + surface.set_companions(false, true); assert(surface.render(p)); + no_action(click(surface, 90, 518)); + assert(click(surface, 900, 518).launch_companion == SurfaceEvent::Companion::Keyboard); + surface.set_companions(true, true); assert(surface.render(p)); + assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Plan); + assert(click(surface, 900, 518).launch_companion == SurfaceEvent::Companion::Keyboard); + p.quick_open = true; assert(surface.render(p)); + no_action(click(surface, 90, 518)); no_action(click(surface, 900, 518)); + assert(!surface.scroll(200, 400, -1.f)); + p.quick_open = false; assert(surface.render(p)); + surface.set_companions(false, false); assert(surface.render(p)); + // Long UTF-8, newlines and malformed bytes scroll within the preview. 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, 518)); - surface.reset_pointers(); assert(surface.render(p)); - assert(surface.pixels() != first); - no_action(click(surface, 90, 518)); - surface.reset_pointers(); surface.render(p); - assert(surface.pixels() == first); - for (int i = 0; i < 200; ++i) { no_action(click(surface, 900, 518)); surface.render(p); } - for (int i = 0; i < 200; ++i) { no_action(click(surface, 90, 518)); surface.render(p); } - surface.reset_pointers(); surface.render(p); - assert(surface.pixels() == first); + assert(!surface.scroll(200, 610, -1.f)); // fixed footer cannot scroll + assert(!surface.scroll(200, 400, std::numeric_limits::quiet_NaN())); + surface.pointer_down(0, 100, 160); + assert(surface.scroll(200, 400, -.5f)); // partial line and stale press + no_action(surface.pointer_up(0, 100, 160)); + assert(surface.render(p) && surface.pixels() != first); + assert(surface.scroll(200, 400, .5f)); + assert(surface.render(p) && surface.pixels() == first); + assert(!surface.scroll(200, 400, 1.f)); // top clamp + for (int i = 0; i < 200; ++i) { surface.scroll(200, 400, -4.f); surface.render(p); } + assert(!surface.scroll(200, 400, -1.f)); // bottom clamp + assert(surface.scroll(200, 400, 4.f)); p.transcript = "New result"; - assert(surface.render(p)); - surface.reset_pointers(); surface.render(p); - auto replaced = surface.pixels(); - no_action(click(surface, 900, 518)); - surface.reset_pointers(); surface.render(p); + assert(surface.render(p)); // new text resets scroll + const auto replaced = surface.pixels(); + assert(!surface.scroll(200, 400, -1.f)); assert(surface.pixels() == replaced); p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd'); assert(surface.render(p)); assert(!surface.render(p));