Scroll review text and launch installed companion overlays

This commit is contained in:
baketnk committed 2026-09-28 18:30:14 -04:00
1 parent 4b0b29700d
commit dd164aacee
9 files changed
+261 -66

No files matched your search

+7 -1
View File
@@ -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)
+8 -3
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
#include "companion_apps.hpp"
#include <cerrno>
#include <cstdlib>
#include <string>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
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<std::filesystem::path> 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<char*>(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;
}
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string_view>
namespace frameyap {
// Resolve an installed launcher once at boot; never search the source checkout.
std::optional<std::filesystem::path> 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);
}
+14 -2
View File
@@ -5,6 +5,7 @@
#include "laser_setting.hpp"
#include "placement.hpp"
#include "panel_surface.hpp"
#include "companion_apps.hpp"
#include <openvr.h>
@@ -93,6 +94,7 @@ struct Overlay::Impl {
bool lasers_anytime = false;
Config config;
PanelSurface surface;
std::optional<std::filesystem::path> plan_launcher, keyboard_launcher;
Panel panel;
std::vector<ModelAction> 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<size_t>(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
+61 -31
View File
@@ -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<Button, 34> 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<Button, 34> 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<UiAction> 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<Rect> 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<std::string> 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<ModelOption> consent_snapshot;
std::vector<std::string> 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; }
}
+5 -2
View File
@@ -12,6 +12,8 @@
namespace frameyap {
struct SurfaceEvent {
enum class Companion { Plan, Keyboard };
std::optional<Companion> launch_companion;
std::optional<UiAction> action;
std::optional<Mount> mount;
std::optional<bool> lasers_anytime;
@@ -54,8 +56,9 @@ public:
static constexpr auto quit_hold = std::chrono::milliseconds(900);
std::optional<PanelDragKind> pointer_down(unsigned cursor, float x, float y, Clock::time_point now = Clock::now());
SurfaceEvent pointer_up(unsigned cursor, float x, float y, Clock::time_point now = Clock::now());
// 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<Bounds> input_regions() const;
+42
View File
@@ -0,0 +1,42 @@
#include "companion_apps.hpp"
#include <cassert>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <sys/stat.h>
#include <unistd.h>
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);
}
+42 -27
View File
@@ -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<float>::quiet_NaN()));
assert(!surface.scroll(290, 160, -1.f)); // tab/header, not settings content
assert(!surface.scroll(200, 400, std::numeric_limits<float>::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<float>::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));