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_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}"
FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}") FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}")
if(CMAKE_SYSTEM_NAME STREQUAL "Linux") 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) add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp src/placement.cpp)
target_include_directories(frameyap_mount PUBLIC src) target_include_directories(frameyap_mount PUBLIC src)
add_library(frameyap_worker src/worker.cpp) 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 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") "${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_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) target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1)
set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib")
endif() 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") add_test(NAME frameyap.panel COMMAND frameyap_panel_test "${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/Inconsolata-Regular.ttf")
endif() endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Linux") 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) add_executable(frameyap_config_test tests/config_test.cpp)
target_link_libraries(frameyap_config_test PRIVATE frameyap_config) target_link_libraries(frameyap_config_test PRIVATE frameyap_config)
target_compile_options(frameyap_config_test PRIVATE -UNDEBUG) 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 and restrained baked neon halos rather than GPU bloom. The recording indicator and
selected controls remain distinguishable by their labels, not color alone. Rounded selected controls remain distinguishable by their labels, not color alone. Rounded
control hit areas exclude their clipped corners. 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 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 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 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 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. no checks and no network access; Settings returns. Recording controls stay live.
The complete transcript preview is paginated by glyph width and six-line The transcript wraps by glyph width and scrolls in its review viewport with
height; Previous and Next navigate it without changing the source transcript. 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. 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), 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 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 "laser_setting.hpp"
#include "placement.hpp" #include "placement.hpp"
#include "panel_surface.hpp" #include "panel_surface.hpp"
#include "companion_apps.hpp"
#include <openvr.h> #include <openvr.h>
@@ -93,6 +94,7 @@ struct Overlay::Impl {
bool lasers_anytime = false; bool lasers_anytime = false;
Config config; Config config;
PanelSurface surface; PanelSurface surface;
std::optional<std::filesystem::path> plan_launcher, keyboard_launcher;
Panel panel; Panel panel;
std::vector<ModelAction> model_actions; std::vector<ModelAction> model_actions;
StatusIndicators indicators; StatusIndicators indicators;
@@ -139,6 +141,9 @@ struct Overlay::Impl {
config(load_config(default_config_path())), config(load_config(default_config_path())),
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) { surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) {
persistence.persist_mount = persist; 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}) for (auto selected : {Mount::LeftWrist, Mount::RightWrist, Mount::Head})
saved_placements[static_cast<size_t>(selected)] = saved_placements[static_cast<size_t>(selected)] =
load_relative_placement(default_placement_path(selected), selected); load_relative_placement(default_placement_path(selected), selected);
@@ -560,7 +565,7 @@ struct Overlay::Impl {
if (event.data.scroll.cursorIndex < cursor_positions.size() && if (event.data.scroll.cursorIndex < cursor_positions.size() &&
cursor_positions[event.data.scroll.cursorIndex]) { cursor_positions[event.data.scroll.cursorIndex]) {
const auto [x, y] = *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; break;
case vr::VREvent_MouseButtonDown: case vr::VREvent_MouseButtonDown:
@@ -579,8 +584,15 @@ struct Overlay::Impl {
diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button); diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
if (event.data.mouse.button == vr::VRMouseButton_Left) { 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); 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.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) { if (event_result.model_action) {
model_actions.push_back(*event_result.model_action); model_actions.push_back(*event_result.model_action);
reset_input(result); // revoke held PTT, delivery and stale pointer approval 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; 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, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert,
Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow, Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow,
ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback }; ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback };
@@ -39,8 +39,8 @@ constexpr std::array<Button, 34> buttons{{
{{226, 138, 180, 46}, Control::Settings, "Settings"}, {{226, 138, 180, 46}, Control::Settings, "Settings"},
{{420, 138, 180, 46}, Control::Bindings, "Bindings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"},
{{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"}, {{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"},
{{32, 496, 154, 44}, Control::Prev, "Previous"}, {{32, 496, 454, 44}, Control::Plan, "Open Plan"},
{{838, 496, 130, 44}, Control::Next, "Next"}, {{514, 496, 454, 44}, Control::Keyboard, "Open Keyboard"},
{{32, 574, 176, 68}, Control::Record, "Record"}, {{32, 574, 176, 68}, Control::Record, "Record"},
{{222, 574, 176, 68}, Control::Cancel, "Cancel"}, {{222, 574, 176, 68}, Control::Cancel, "Cancel"},
{{412, 574, 176, 68}, Control::Insert, "Type"}, {{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"}, {{32, 584, 936, 50}, Control::WristWorldFallback, "Wrist world fallback"},
}}; }};
constexpr Rect settings_view{32, 232, 936, 316}; 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); constexpr int settings_max_scroll = 634 - (settings_view.y + settings_view.h);
std::optional<UiAction> action(Control c) { std::optional<UiAction> action(Control c) {
switch (c) { switch (c) {
@@ -134,7 +136,9 @@ struct PanelSurface::Impl {
bool close_mic_when_idle = false, wrist_world_fallback = true; bool close_mic_when_idle = false, wrist_world_fallback = true;
bool clock_24h = false, layout_locked = false; bool clock_24h = false, layout_locked = false;
float settings_scroll = 0.f; 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; DateFormat date_format = DateFormat::MonthDayYear;
std::time_t clock_time = std::time(nullptr); std::time_t clock_time = std::time(nullptr);
ClockLabel displayed_clock; ClockLabel displayed_clock;
@@ -145,14 +149,12 @@ struct PanelSurface::Impl {
int hold_progress = 0; int hold_progress = 0;
int drag_cursor = -1; int drag_cursor = -1;
std::vector<std::string> lines; std::vector<std::string> lines;
size_t page = 0;
size_t model_page = 0; size_t model_page = 0;
size_t consent_page = 0; size_t consent_page = 0;
size_t consent_rendered_page = size_t(-1); size_t consent_rendered_page = size_t(-1);
bool install_confirm = false; bool install_confirm = false;
std::optional<ModelOption> consent_snapshot; std::optional<ModelOption> consent_snapshot;
std::vector<std::string> consent_lines; std::vector<std::string> consent_lines;
static constexpr size_t lines_per_page = 6;
static constexpr size_t consent_lines_per_page = 6; static constexpr size_t consent_lines_per_page = 6;
size_t consent_pages() const { size_t consent_pages() const {
return std::max(size_t(1), (consent_lines.size() + consent_lines_per_page - 1) / consent_lines_per_page); 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; return result;
} }
void rect(Rect r, Color c) { void rect(Rect r, Color c) {
for (int y = std::max(r.y, clip_settings ? settings_view.y : 0); for (int y = std::max(r.y, clip_view ? clip_view->y : 0);
y < std::min(clip_settings ? settings_view.y + settings_view.h : H, r.y + r.h); ++y) 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_settings ? settings_view.x : 0); for (int x = std::max(r.x, clip_view ? clip_view->x : 0);
x < std::min(clip_settings ? settings_view.x + settings_view.w : W, r.x + r.w); ++x) 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); std::copy(c.begin(), c.end(), pixels.begin() + (size_t(y) * W + x) * 4);
} }
void blend(int x, int y, Color color, float amount) { void blend(int x, int y, Color color, float amount) {
if (clip_settings && (x < settings_view.x || x >= settings_view.x + settings_view.w || if (clip_view && (x < clip_view->x || x >= clip_view->x + clip_view->w ||
y < settings_view.y || y >= settings_view.y + settings_view.h)) return; y < clip_view->y || y >= clip_view->y + clip_view->h)) return;
auto* dst = pixels.data() + (size_t(y) * W + x) * 4; auto* dst = pixels.data() + (size_t(y) * W + x) * 4;
// Straight-alpha source-over, including strokes in the transparent margin. // Straight-alpha source-over, including strokes in the transparent margin.
const float alpha = amount * color[3] / 255.f; const float alpha = amount * color[3] / 255.f;
@@ -350,8 +352,8 @@ struct PanelSurface::Impl {
for (unsigned col = 0; col < b.width; ++col) { for (unsigned col = 0; col < b.width; ++col) {
int xx = x + glyph->bitmap_left + int(col), yy = baseline - glyph->bitmap_top + int(row); 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 || if (xx < 0 || xx >= right || xx >= W || yy < 0 || yy >= H ||
(clip_settings && (xx < settings_view.x || xx >= settings_view.x + settings_view.w || (clip_view && (xx < clip_view->x || xx >= clip_view->x + clip_view->w ||
yy < settings_view.y || yy >= settings_view.y + settings_view.h))) continue; yy < clip_view->y || yy >= clip_view->y + clip_view->h))) continue;
unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)]; unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)];
auto* dst = pixels.data() + (size_t(yy) * W + xx) * 4; auto* dst = pixels.data() + (size_t(yy) * W + xx) * 4;
for (int k = 0; k < 3; ++k) for (int k = 0; k < 3; ++k)
@@ -360,7 +362,9 @@ struct PanelSurface::Impl {
x += a; 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 { bool available(UiAction a) const {
if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available); 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. // 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::ModelRow) return tab == Tab::Models && !install_confirm;
if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models; if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models;
if (c == Control::ModelInstall) 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 || if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
c == Control::AdvancedDebug || c == Control::AutoInsert || c == Control::AdvancedDebug || c == Control::AutoInsert ||
c == Control::Clock24h || c == Control::Date || c == Control::LockLayout || c == Control::Clock24h || c == Control::Date || c == Control::LockLayout ||
@@ -385,8 +390,6 @@ struct PanelSurface::Impl {
} }
bool enabled(Control c) const { bool enabled(Control c) const {
if (auto a = action(c)) return available(*a); 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::Recenter) return mount == Mount::World;
if (c == Control::ModelRow) return !panel.model_busy; if (c == Control::ModelRow) return !panel.model_busy;
if (c == Control::ModelInstall) { if (c == Control::ModelInstall) {
@@ -451,7 +454,7 @@ struct PanelSurface::Impl {
} }
if (p.transcript != panel.transcript || lines.empty()) { 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); 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 || 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 || 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) { } else if (tab == Tab::Review) {
rounded({32, 204, 936, 278}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); 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) clip_view = review_view;
text(lines[page * lines_per_page + i], 48, 240 + int(i) * 42, 32, 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); panel.transcript.empty() ? muted : ink, 952);
// A Bindings failure is the only note here; it replaces the page count. clip_view.reset();
if (!binding_note.empty()) text(binding_note, 200, 526, 18, pink, 824); if (max_review_scroll() > 0) {
else text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 526, 23, muted, 790); 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) { } else if (tab == Tab::Models) {
text("Models: select to restart. Install always needs explicit confirmation.", 32, 202, 19, muted, 968); 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; }); 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); 32, 225, 17, muted, 968);
} }
rect({32, 550, 936, 1}, mix(card, cyan, .17f)); 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) { for (size_t i = 0; i < buttons.size(); ++i) {
const auto& b = buttons[i]; const auto& b = buttons[i];
if (!visible(b.id)) continue; if (!visible(b.id)) continue;
if (b.id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue; if (b.id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue;
const Rect r = button_rect(b); 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 on = enabled(b.id);
bool selected = (b.id == Control::Review && tab == Tab::Review) || bool selected = (b.id == Control::Review && tab == Tab::Review) ||
(b.id == Control::Settings && tab == Tab::Settings) || (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, text(active ? "ON" : "OFF", r.x + r.w - 66, r.y + r.h / 2 + 9, 22,
active ? cyan : muted, r.x + r.w - 12); active ? cyan : muted, r.x + r.w - 12);
} }
clip_settings = false; clip_view.reset();
} }
if (tab == Tab::Settings) { if (tab == Tab::Settings) {
rect({976, settings_view.y, 3, settings_view.h}, mix(card, muted, .24f)); 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("Right stick: scroll", 32, 565, 16, muted, 320);
text("OFF: discard idle audio; ON: spike / start latency.", 390, 565, 16, muted, 887); 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()) { if (tab == Tab::Models && !panel.model_note.empty()) {
// The install/navigation controls occupy y=530..566; the status // The install/navigation controls occupy y=530..566; the status
// belongs BELOW the shared footer (574..642), not under buttons. // 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_->tab = c == Control::Review ? Tab::Review : Tab::Settings;
impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true; 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::Plan || c == Control::Keyboard) {
else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; } result.launch_companion = c == Control::Plan ? SurfaceEvent::Companion::Plan : SurfaceEvent::Companion::Keyboard;
impl_->reset();
}
return result; return result;
} }
bool PanelSurface::scroll_settings(float x, float y, float vertical_delta) { bool PanelSurface::scroll(float x, float y, float vertical_delta) {
if (impl_->tab != Tab::Settings || impl_->drag_cursor >= 0 || if (impl_->drag_cursor >= 0 || !std::isfinite(vertical_delta)) return false;
!settings_view.contains(x, y) || !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, const float next = std::clamp(impl_->settings_scroll - std::clamp(vertical_delta, -4.f, 4.f) * 48.f,
0.f, float(settings_max_scroll)); 0.f, float(settings_max_scroll));
if (next == impl_->settings_scroll) return false; 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; impl_->dirty = true;
return 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) { void PanelSurface::set_indicators(const StatusIndicators& indicators) {
if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; } if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; }
} }
+5 -2
View File
@@ -12,6 +12,8 @@
namespace frameyap { namespace frameyap {
struct SurfaceEvent { struct SurfaceEvent {
enum class Companion { Plan, Keyboard };
std::optional<Companion> launch_companion;
std::optional<UiAction> action; std::optional<UiAction> action;
std::optional<Mount> mount; std::optional<Mount> mount;
std::optional<bool> lasers_anytime; std::optional<bool> lasers_anytime;
@@ -54,8 +56,9 @@ public:
static constexpr auto quit_hold = std::chrono::milliseconds(900); 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()); 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()); 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. // OpenVR laser wheel input at the last hovered panel coordinate.
bool scroll_settings(float x, float y, float vertical_delta); bool scroll(float x, float y, float vertical_delta);
void set_companions(bool plan, bool keyboard);
bool dragging(unsigned cursor) const; bool dragging(unsigned cursor) const;
// OpenVR intersection masks use top-left coordinates, unlike mouse events. // OpenVR intersection masks use top-left coordinates, unlike mouse events.
std::vector<Bounds> input_regions() const; 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) { void no_action(const SurfaceEvent& event) {
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert && 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) { void snapshot(PanelSurface& surface, const std::string& path) {
std::ofstream out(path, std::ios::binary); 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); assert(click(surface, 200, 532).close_mic_when_idle == false);
surface.set_close_mic_when_idle(false); assert(surface.render(p)); surface.set_close_mic_when_idle(false); assert(surface.render(p));
// The Settings viewport scrolls continuously under the laser; it never pages. // 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(290, 160, -1.f)); // tab/header, not settings content
assert(!surface.scroll_settings(200, 400, std::numeric_limits<float>::quiet_NaN())); 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 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)); no_action(surface.pointer_up(0, 200, 532));
assert(surface.render(p)); assert(surface.render(p));
assert(!surface.scroll_settings(200, 610, -1.f)); // fixed footer assert(!surface.scroll(200, 610, -1.f)); // fixed footer
assert(surface.scroll_settings(200, 400, -2.f)); assert(surface.scroll(200, 400, -2.f));
assert(surface.render(p)); 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 no_action(click(surface, 180, 231)); // scrolled settings cannot be clicked above the viewport
auto fallback = click(surface, 200, 520); auto fallback = click(surface, 200, 520);
assert(fallback.wrist_world_fallback == false && !fallback.action); assert(fallback.wrist_world_fallback == false && !fallback.action);
@@ -347,9 +347,9 @@ int main(int argc, char** argv) {
assert(fallback.wrist_world_fallback == true); assert(fallback.wrist_world_fallback == true);
surface.set_wrist_world_fallback(true); assert(surface.render(p)); surface.set_wrist_world_fallback(true); assert(surface.render(p));
assert(click(surface, 280, 610).action == UiAction::Cancel); // footer stays fixed 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.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, 180, 260).mount == Mount::World);
assert(click(surface, 280, 610).action == UiAction::Cancel); assert(click(surface, 280, 610).action == UiAction::Cancel);
surface.set_placement_note("Wrist not tracked - using world space until it returns."); 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, 200, 492)); // clock/date controls only exist on Settings
no_action(click(surface, 680, 492)); no_action(click(surface, 680, 492));
no_action(click(surface, 200, 532)); // mic preference only exists on Settings 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. // Bindings opens SteamVR directly, from either tab, without replacing review.
surface.pointer_down(1, 480, 610); 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 assert(click(surface, 680, 260).mount == Mount::Head); // still in Settings
no_action(click(surface, 100, 160)); 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(); p.transcript.clear();
for (int i = 0; i < 60; ++i) p.transcript += "Line " + std::to_string(i) + ": café / 日本語 / naïve / Ω\n"; 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); p.transcript += std::string("invalid: \xff\xc0\xe0\x80", 13);
surface.render(p); surface.render(p);
surface.reset_pointers(); surface.render(p);
const auto first = surface.pixels(); const auto first = surface.pixels();
no_action(click(surface, 900, 518)); assert(!surface.scroll(200, 610, -1.f)); // fixed footer cannot scroll
surface.reset_pointers(); assert(surface.render(p)); assert(!surface.scroll(200, 400, std::numeric_limits<float>::quiet_NaN()));
assert(surface.pixels() != first); surface.pointer_down(0, 100, 160);
no_action(click(surface, 90, 518)); assert(surface.scroll(200, 400, -.5f)); // partial line and stale press
surface.reset_pointers(); surface.render(p); no_action(surface.pointer_up(0, 100, 160));
assert(surface.pixels() == first); assert(surface.render(p) && surface.pixels() != first);
for (int i = 0; i < 200; ++i) { no_action(click(surface, 900, 518)); surface.render(p); } assert(surface.scroll(200, 400, .5f));
for (int i = 0; i < 200; ++i) { no_action(click(surface, 90, 518)); surface.render(p); } assert(surface.render(p) && surface.pixels() == first);
surface.reset_pointers(); surface.render(p); assert(!surface.scroll(200, 400, 1.f)); // top clamp
assert(surface.pixels() == first); 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"; p.transcript = "New result";
assert(surface.render(p)); assert(surface.render(p)); // new text resets scroll
surface.reset_pointers(); surface.render(p); const auto replaced = surface.pixels();
auto replaced = surface.pixels(); assert(!surface.scroll(200, 400, -1.f));
no_action(click(surface, 900, 518));
surface.reset_pointers(); surface.render(p);
assert(surface.pixels() == replaced); assert(surface.pixels() == replaced);
p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd'); p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd');
assert(surface.render(p)); assert(!surface.render(p)); assert(surface.render(p)); assert(!surface.render(p));