mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 02:00:06 +02:00
Scroll review text and launch installed companion overlays
This commit is contained in:
1 parent
4b0b29700d
commit
dd164aacee
9 files changed
+261
-66
No files matched your search
+7
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user