From 94b747bb0401d5af44be318a25876a2c658cfe8f Mon Sep 17 00:00:00 2001 From: baketnk Date: Fri, 25 Sep 2026 10:10:15 -0400 Subject: [PATCH] Add configurable seamless animated panel gradient --- assets/config.example.json | 1 + docs/design.md | 17 ++++++++- docs/overlay.md | 43 +++++++++++++++------ install.sh | 13 +++++++ scripts/install_payload.py | 13 +++++++ src/config.cpp | 16 +++++++- src/config.hpp | 6 +++ src/overlay.cpp | 4 +- src/panel_surface.cpp | 64 ++++++++++++++++++++++---------- src/panel_surface.hpp | 8 ++-- tests/config_test.cpp | 24 ++++++++++++ tests/panel_test.cpp | 76 +++++++++++++++++++++++++++++++++++--- tests/test_installer.py | 70 +++++++++++++++++++++++++++++++++++ 13 files changed, 309 insertions(+), 46 deletions(-) diff --git a/assets/config.example.json b/assets/config.example.json index 7b28b9a..18c4421 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -20,6 +20,7 @@ "frame_start": "#1fff91", "frame_end": "#1f70ff" }, + "gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12}, "buttons": { "left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip", diff --git a/docs/design.md b/docs/design.md index 6d41ab9..146c88a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -103,8 +103,21 @@ panel. A small overlay-specific RAII owner in this repository should manage Open handles, input manifest and shutdown. No dependency on external application libraries, assets, build trees or Python environments. -Use one small RGBA panel updated only on UI changes and a bounded recording -indicator cadence. The native implementation uploads the CPU-rasterized panel to +Use one small RGBA panel updated on UI changes, a bounded recording +indicator cadence and, when visible with the optional gradient enabled, at most +one animation redraw per 100 ms on a monotonic clock. Hidden panels do not +repaint for animation. The locally implemented gradient uses a smooth periodic +cosine field: one start/end/start cycle across the canvas width, with a shared +time phase and global canvas coordinates on the perimeter and grab/scale handles. +`gradient.enabled` defaults to true; `period_seconds` defaults to 30 (finite +number 5–300), and `strength` to 0.12 (finite number 0–0.3). Its colors derive +only from `theme.frame_start` and `theme.frame_end`; strength blends those colors +into `theme.background`. Disabled mode restores the static solid background +and existing linear edge/handle gradients. Config is read on restart, with no +in-panel gradient switch. This is local implementation, **not** device-validated +appearance, frame pacing, performance or headset acceptance. See +[config and rendering](overlay.md). +The native implementation uploads the CPU-rasterized panel to a persistent Vulkan image and uses `SetOverlayTexture`; the initial `SetOverlayRaw` proof path has been replaced. No stereo eye targets or per-eye scene rendering. Keep tracking in compositor transforms, diff --git a/docs/overlay.md b/docs/overlay.md index 83d29d0..e988521 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -25,15 +25,20 @@ margins for a thin grab underline and an external L-shaped scale handle. `src/ov persistent Vulkan RGBA8 image and submits it with `SetOverlayTexture`. The image, staging allocation and command buffer are reused; tabs do not create extra overlays or render targets. The rounded mint-to-blue perimeter, -shallow curved accent, and dark cards form the panel's visual language. Rounded +shallow curved accent, and dark cards form the panel's visual language. +With `gradient.enabled` (the default), a low-strength animated field blends +`theme.frame_start` and `theme.frame_end` into `theme.background`; the perimeter +and grab/scale handles share its phase and canvas coordinates. Rounded preview, status and control surfaces use independently rasterized antialiased edges 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 only for changed content, page or settings; laser hover -and button down/up are hit-tested without an upload. Static frames are reused. -The caller may call `draw(Panel)` at 10 ms intervals. Tracking transforms do -not require repainting the canvas. +Rendering/uploads occur for changed content, page 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 +animation is disabled. The caller may call `draw(Panel)` at 10 ms intervals. +Tracking transforms do not require repainting the canvas. The Vulkan instance/device enable the extensions requested by the running SteamVR runtime and use its selected physical device and a graphics queue. @@ -124,6 +129,7 @@ installer creates one with defaults on first install. Copy the shipped "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff" }, + "gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12}, "buttons": { "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", @@ -142,8 +148,21 @@ immediately and save them to the user config when persistence is enabled. A failed save warns and leaves the selection active for this run. Time uses the device's local timezone; these controls do not change system time. -Each theme color is `#RRGGBB`; omitted colors keep the default. `font` is a -TTF/OTF file path (not a family name); a missing file uses the bundled font. +Each theme color is `#RRGGBB`; omitted colors keep the default. The optional +`gradient` object defaults to `{"enabled": true, "period_seconds": 30, "strength": 0.12}`. +`enabled` must be a boolean; `period_seconds` must be a +finite number from 5 to 300 (seconds per full cycle), and `strength` a finite +number from 0 to 0.3. The animation uses a smooth periodic cosine field with +one start/end/start cycle across the canvas width. Its edge and handle colors +use the same global canvas coordinates and time phase; it does not add a +separate handle animation. Colors always come from `theme.frame_start` and +`theme.frame_end`, while `strength` controls how much of those colors blends +into `theme.background`. Set `enabled` to `false` for a static solid background +and the existing linear perimeter/handle gradients. Edit the JSON and restart; +there is no in-panel gradient switch or hot reload. This is locally implemented, +not device validated; headset appearance and rendering cost remain unverified. +`font` is a TTF/OTF file path (not a family name); a missing file uses the +bundled font. `advanced_debug` is a boolean (default `false`, not a string): an opt-in request for full diagnostic logs. Full logs may contain speech/transcribed text and local paths; **raw audio clips are not archived**. The Settings tab shows @@ -215,12 +234,12 @@ action manifest and adjacent bindings are placed in `$XDG_CACHE_HOME/frameyap/bi (or `~/.cache/frameyap/bindings`); the bundled manifest remains unchanged. The config is read once at launch, not hot-reloaded (the in-panel backend selection is saved separately). On install/upgrade the installer fills known missing fields, -including `close_mic_when_idle` and `backend`, removes retired keys and resets -invalid entries. It saves the exact prior bytes under `config.json.backup-*` -before a repair and refuses symlink/oversized config paths; valid customizations +including `close_mic_when_idle`, `backend` and `gradient`, removes retired keys +and unknown `gradient` subkeys, and resets invalid entries. It saves the exact +prior bytes under `config.json.backup-*` before a repair and refuses symlink/oversized config paths; valid customizations remain intact. The installed launcher no longer pins `--font`, so this selection takes effect. Direct native -launches with bad JSON, colors or button mappings fail startup rather than -silently changing input behavior. +launches with bad JSON, gradient fields, colors or button mappings fail startup +rather than silently changing input behavior. ### Models / backends (local implementation) diff --git a/install.sh b/install.sh index ee12b36..e932077 100755 --- a/install.sh +++ b/install.sh @@ -27,6 +27,7 @@ import importlib.util import fcntl import hashlib import json +import math import os from pathlib import Path import platform @@ -62,6 +63,7 @@ CONFIG_DEFAULTS = { "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, + "gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12}, "buttons": {"left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip", "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", @@ -143,6 +145,17 @@ def normalized_config(data): value = source.get(name, default) limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) fixed["wrist"][name] = value if type(value) in (int, float) and limit[0] <= value <= limit[1] else default + gradient = data.get("gradient") + gradient = gradient if isinstance(gradient, dict) else {} + fixed["gradient"] = {} + for name, default in CONFIG_DEFAULTS["gradient"].items(): + value = gradient.get(name, default) + if name == "enabled": + valid = type(value) is bool + else: + low, high = (5, 300) if name == "period_seconds" else (0, 0.3) + valid = type(value) in (int, float) and low <= value <= high and math.isfinite(value) + fixed["gradient"][name] = value if valid else default for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/scripts/install_payload.py b/scripts/install_payload.py index fb9941b..daaa16a 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -7,6 +7,7 @@ import importlib.util import fcntl import hashlib import json +import math import os from pathlib import Path import platform @@ -42,6 +43,7 @@ CONFIG_DEFAULTS = { "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, + "gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12}, "buttons": {"left_grip": "/user/hand/left/input/grip", "right_grip": "/user/hand/right/input/grip", "ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", @@ -123,6 +125,17 @@ def normalized_config(data): value = source.get(name, default) limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) fixed["wrist"][name] = value if type(value) in (int, float) and limit[0] <= value <= limit[1] else default + gradient = data.get("gradient") + gradient = gradient if isinstance(gradient, dict) else {} + fixed["gradient"] = {} + for name, default in CONFIG_DEFAULTS["gradient"].items(): + value = gradient.get(name, default) + if name == "enabled": + valid = type(value) is bool + else: + low, high = (5, 300) if name == "period_seconds" else (0, 0.3) + valid = type(value) in (int, float) and low <= value <= high and math.isfinite(value) + fixed["gradient"][name] = value if valid else default for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/src/config.cpp b/src/config.cpp index 2b41b47..7474ba3 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -139,10 +139,10 @@ Rgba color(const Json& json) { return c; } float bounded_number(const Json& json, std::string_view name, float lower, float upper) { - if (!json.is_number) throw std::runtime_error("Config wrist " + std::string(name) + " must be a number"); + if (!json.is_number) throw std::runtime_error("Config " + std::string(name) + " must be a number"); const float value = std::strtof(json.value.c_str(), nullptr); if (!std::isfinite(value) || value < lower || value > upper) - throw std::runtime_error("Config wrist " + std::string(name) + " out of range"); + throw std::runtime_error("Config " + std::string(name) + " out of range"); return value; } std::filesystem::path xdg_root(const char* variable, const char* fallback) { @@ -251,6 +251,18 @@ Config load_config(const std::filesystem::path& path) { else if (name == "roll_degrees") config.wrist.roll_degrees = bounded_number(v, name, -180.f, 180.f); else throw std::runtime_error("Unknown wrist placement key: " + name); } + } else if (key == "gradient") { + if (!value.is_object) throw std::runtime_error("Config gradient must be an object"); + for (const auto& [name, v] : value.object) { + if (name == "enabled") { + if (!v.is_bool) throw std::runtime_error("Config gradient enabled must be a boolean"); + config.gradient.enabled = v.value == "true"; + } else if (name == "period_seconds") + config.gradient.period_seconds = bounded_number(v, "gradient period_seconds", 5.f, 300.f); + else if (name == "strength") + config.gradient.strength = bounded_number(v, "gradient strength", 0.f, .3f); + else throw std::runtime_error("Unknown gradient key: " + name); + } } else if (key == "theme") { if (!value.is_object) throw std::runtime_error("Config theme must be an object"); for (const auto& [name, v] : value.object) { diff --git a/src/config.hpp b/src/config.hpp index b1dba9e..d491183 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -14,9 +14,15 @@ struct Theme { Rgba accent{31, 240, 164, 255}, warning{255, 110, 135, 255}; Rgba frame_start{31, 255, 145, 255}, frame_end{31, 112, 255, 255}; }; +struct GradientConfig { + bool enabled = true; + float period_seconds = 30.f; + float strength = .12f; // muted edge colors blended into theme.background +}; enum class DateFormat { Off, MonthDayYear, DayMonthYear, Iso }; struct Config { Theme theme; + GradientConfig gradient; std::string font; // absolute TTF/OTF path; empty uses the bundled face std::string backend = "redux"; // selected manifest ID; never triggers a download // Requests OpenVR's experimental global action priority; SteamVR must allow it too. diff --git a/src/overlay.cpp b/src/overlay.cpp index b270ed3..7c15f92 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -126,7 +126,7 @@ struct Overlay::Impl { mount(requested ? *requested : load_mount(persistence.settings_path)), lasers_anytime(load_lasers_anytime(persistence.laser_settings_path)), config(load_config(default_config_path())), - surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme) { + surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) { persistence.persist_mount = persist; const auto action_path = absolute_file(action_manifest(assets, config)); absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json"); @@ -374,7 +374,7 @@ struct Overlay::Impl { void draw(const Panel& p) { panel = p; surface.set_clock_time(std::time(nullptr)); - if (surface.render(p)) { + if (surface.render(p, PanelSurface::Clock::now(), shown)) { gpu_texture->upload(surface.pixels()); auto texture = gpu_texture->texture(); overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)"); diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 611519e..f5c0245 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -114,6 +114,10 @@ struct PanelSurface::Impl { Panel panel; Mount mount; Theme theme; + GradientConfig gradient; + std::optional animation_start; + Clock::time_point last_animation_frame{}; + std::array gradient_columns{}; Color background, card, ink, muted, cyan, pink; Tab tab = Tab::Review; bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; @@ -165,8 +169,8 @@ struct PanelSurface::Impl { install_confirm = true; } - Impl(const std::string& font, Mount m, Theme t) - : mount(m), theme(t), background(t.background), card(t.card), ink(t.ink), muted(t.muted), + Impl(const std::string& font, Mount m, Theme t, GradientConfig g) + : mount(m), theme(t), gradient(g), background(t.background), card(t.card), ink(t.ink), muted(t.muted), cyan(t.accent), pink(t.warning) { if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed"); if (FT_New_Face(library, font.c_str(), 0, &face)) { @@ -217,6 +221,26 @@ struct PanelSurface::Impl { std::clamp(.5f - d, 0.f, 1.f)); } } + void prepare_gradient(Clock::time_point now) { + // A periodic cosine field has matching values AND velocity at both + // spatial and temporal seams. One phase drives the body and handles. + const double elapsed = std::chrono::duration(now - *animation_start).count(); + const double phase = std::fmod(elapsed, gradient.period_seconds) / gradient.period_seconds; + for (int x = 0; x < W; ++x) { + const float t = float(.5 - .5 * std::cos(2. * 3.141592653589793 * (double(x) / W - phase))); + gradient_columns[x] = mix(theme.frame_start, theme.frame_end, t); + } + } + void paint_background() { + if (!gradient.enabled) { rect({0, 0, CW, CH}, background); return; } + // Compute colors once per column, then copy contiguous rows. + for (int x = 0; x < CW; ++x) { + const auto color = mix(background, gradient_columns[x], gradient.strength); + std::copy(color.begin(), color.end(), pixels.begin() + x * 4); + } + for (int y = 1; y < CH; ++y) + std::copy_n(pixels.begin(), CW * 4, pixels.begin() + size_t(y) * W * 4); + } void frame() { // Independently rasterized neon-frame HUD style: // rounded mint-to-blue perimeter and a second shallow curved accent. @@ -225,23 +249,18 @@ struct PanelSurface::Impl { const float distance = rounded_distance(x, y, 10, 10, CW - 20, CH - 20, 18.f); const float edge = std::abs(distance); const float strength = edge <= 1.f ? 1.f : .30f * std::max(0.f, 1.f - (edge - 1.f) / 6.f); - const float t = float(x) / CW; - Color gradient{}; - for (int k = 0; k < 3; ++k) - gradient[k] = static_cast(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t); - gradient[3] = 255; + const auto color = gradient.enabled ? gradient_columns[x] : + mix(theme.frame_start, theme.frame_end, float(x) / CW); auto* dst = pixels.data() + (size_t(y) * W + x) * 4; - for (int k = 0; k < 3; ++k) dst[k] = static_cast(background[k] + (gradient[k] - background[k]) * strength); + for (int k = 0; k < 3; ++k) + dst[k] = static_cast(dst[k] + (color[k] - dst[k]) * strength); dst[3] = distance <= 1.f ? 255 : static_cast(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f)); } for (int x = 32; x < CW - 32; ++x) { const float t = float(x - 32) / (CW - 64); const int y = CH - 21 - int(5 * std::sin(t * 3.14159265f)); - Color gradient{}; - for (int k = 0; k < 3; ++k) - gradient[k] = static_cast(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t); - gradient[3] = 255; - rect({x, y, 1, 1}, gradient); + const auto color = gradient.enabled ? gradient_columns[x] : mix(theme.frame_start, theme.frame_end, t); + rect({x, y, 1, 1}, color); } } int advance(uint32_t cp) { @@ -344,7 +363,10 @@ struct PanelSurface::Impl { pressed.fill(-1); drag_cursor = -1; } - bool render(const Panel& p) { + bool render(const Panel& p, Clock::time_point now, bool animate) { + if (!animation_start) animation_start = now; + if (gradient.enabled && animate && now - last_animation_frame >= std::chrono::milliseconds(100)) + dirty = true; if (p.recording != panel.recording || p.enabled != panel.enabled || p.record_available != panel.record_available || p.transcript != panel.transcript) { reset(); // an old press cannot authorize a changed action or replacement transcript @@ -375,8 +397,10 @@ struct PanelSurface::Impl { if (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true; if (!dirty) return false; displayed_clock = std::move(label); + if (gradient.enabled) prepare_gradient(now); + last_animation_frame = now; std::fill(pixels.begin(), pixels.end(), 0); - rect({0, 0, CW, CH}, background); + paint_background(); frame(); text("FrameYap", 32, 61, 40, ink, 300); text(displayed_clock.time, 475, 58, 28, ink, 735); @@ -507,8 +531,8 @@ struct PanelSurface::Impl { for (int x = bounds.x; x < bounds.x + bounds.w; ++x) { auto* dst = pixels.data() + (size_t(y) * W + x) * 4; if (!dst[3]) continue; - const auto color = mix(theme.frame_start, theme.frame_end, - float(x - bounds.x) / (bounds.w - 1)); + const auto color = gradient.enabled ? gradient_columns[x] : + mix(theme.frame_start, theme.frame_end, float(x - bounds.x) / (bounds.w - 1)); std::copy_n(color.begin(), 3, dst); } } @@ -516,10 +540,10 @@ struct PanelSurface::Impl { return true; } }; -PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme) - : impl_(std::make_unique(font, mount, theme)) {} +PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme, GradientConfig gradient) + : impl_(std::make_unique(font, mount, theme, gradient)) {} PanelSurface::~PanelSurface() = default; -bool PanelSurface::render(const Panel& p) { return impl_->render(p); } +bool PanelSurface::render(const Panel& p, Clock::time_point now, bool animate) { return impl_->render(p, now, animate); } const std::vector& PanelSurface::pixels() const { return impl_->pixels; } bool PanelSurface::available(UiAction a) const { return impl_->available(a); } std::vector PanelSurface::visible_model_review_lines() const { diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index df4178a..61be941 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -33,14 +33,16 @@ public: static constexpr Bounds body{0, 0, 1000, 680}; static constexpr Bounds grab{370, 698, 260, 52}; static constexpr Bounds scale{996, 680, 68, 68}; - PanelSurface(const std::string& font, Mount mount, Theme theme = {}); + using Clock = std::chrono::steady_clock; + PanelSurface(const std::string& font, Mount mount, Theme theme = {}, GradientConfig gradient = {}); ~PanelSurface(); PanelSurface(const PanelSurface&) = delete; PanelSurface& operator=(const PanelSurface&) = delete; - bool render(const Panel& panel); + // Monotonic time is injectable for offline loop/cadence checks. Hidden + // overlays still process UI changes, but do not schedule animation repaints. + bool render(const Panel& panel, Clock::time_point now = Clock::now(), bool animate = true); const std::vector& pixels() const; // Handles capture one cursor and never authorize a UI action on release. - using Clock = std::chrono::steady_clock; static constexpr auto quit_hold = std::chrono::milliseconds(900); std::optional pointer_down(unsigned cursor, float x, float y, Clock::time_point now = Clock::now()); SurfaceEvent pointer_up(unsigned cursor, float x, float y, Clock::time_point now = Clock::now()); diff --git a/tests/config_test.cpp b/tests/config_test.cpp index 3a2749e..ac5c6d6 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -49,6 +49,9 @@ int main(int argc, char** argv) { assert(!load_config(path).clock_24h); assert(load_config(path).date_format == DateFormat::MonthDayYear); assert(load_config(path).wrist.width == .30f); + assert(load_config(path).gradient.enabled); + assert(load_config(path).gradient.period_seconds == 30.f); + assert(load_config(path).gradient.strength == .12f); auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json"); assert(example.buttons.at("ptt") == "/user/hand/right/input/x"); assert(example.buttons.at("quick_chat") == "/user/hand/right/input/y"); @@ -62,6 +65,7 @@ int main(int argc, char** argv) { assert(!example.lock_layout); assert(!example.clock_24h && example.date_format == DateFormat::MonthDayYear); assert(example.wrist.y == .18f && example.wrist.z == .089f); + assert(example.gradient.enabled && example.gradient.period_seconds == 30.f && example.gradient.strength == .12f); std::filesystem::create_directories(path.parent_path()); assert(save_backend(path, "fixture_cpu")); assert(load_config(path).backend == "fixture_cpu"); @@ -203,6 +207,26 @@ int main(int argc, char** argv) { R"({"wrist":{"x":1e999}})"}) { put(path, invalid); fails([&] { load_config(path); }); } + put(path, R"({"gradient":{"enabled":false,"period_seconds":45.5,"strength":0.2}})"); + auto gradient = load_config(path).gradient; + assert(!gradient.enabled && gradient.period_seconds == 45.5f && gradient.strength == .2f); + assert(save_auto_insert(path, true)); // unrelated settings retain the effect + assert(load_config(path).gradient.period_seconds == 45.5f); + put(path, R"({"gradient":{"strength":0}})"); + assert(load_config(path).gradient.enabled && load_config(path).gradient.period_seconds == 30.f); + assert(load_config(path).gradient.strength == 0.f); + put(path, R"({"gradient":{"period_seconds":5,"strength":0.3}})"); + assert(load_config(path).gradient.period_seconds == 5.f && load_config(path).gradient.strength == .3f); + put(path, R"({"gradient":{"period_seconds":300}})"); + assert(load_config(path).gradient.period_seconds == 300.f); + for (auto invalid : {R"({"gradient":true})", R"({"gradient":{"enabled":1}})", + R"({"gradient":{"enabled":"false"}})", R"({"gradient":{"period_seconds":0}})", + R"({"gradient":{"period_seconds":301}})", R"({"gradient":{"period_seconds":1e99}})", + R"({"gradient":{"period_seconds":true}})", R"({"gradient":{"strength":-0.1}})", + R"({"gradient":{"strength":0.31}})", R"({"gradient":{"strength":1e99}})", + R"({"gradient":{"strength":"0.1"}})", R"({"gradient":{"unknown":1}})"}) { + put(path, invalid); fails([&] { load_config(path); }); + } put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})"); auto config = load_config(path); assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255})); diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 65e8852..6461afb 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -27,7 +27,9 @@ void snapshot(PanelSurface& surface, const std::string& path) { } int main(int argc, char** argv) { assert(argc >= 2); - PanelSurface surface(argv[1], Mount::World); + // Interaction tests use the opt-out path; animation has a deterministic + // injected-clock suite below rather than wall-time-sensitive assertions. + PanelSurface surface(argv[1], Mount::World, {}, {.enabled = false}); Panel p{"Ready to record", "", "Focus your destination before Type. Enter is always separate.", false, false}; assert(surface.render(p)); assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4)); @@ -44,7 +46,7 @@ int main(int argc, char** argv) { Theme custom; custom.background = {75, 30, 100, 255}; custom.accent = {245, 110, 15, 255}; - PanelSurface themed(argv[1], Mount::World, custom); + PanelSurface themed(argv[1], Mount::World, custom, {.enabled = false}); assert(themed.render(p)); const auto pixel = size_t((190 * PanelSurface::width + 500) * 4); assert(themed.pixels()[pixel] == 75 && themed.pixels()[pixel + 1] == 30 && themed.pixels()[pixel + 2] == 100); @@ -100,7 +102,7 @@ int main(int argc, char** argv) { Theme gradient_theme; gradient_theme.frame_start = {255, 0, 0, 255}; gradient_theme.frame_end = {0, 0, 255, 255}; - PanelSurface gradient_surface(argv[1], Mount::World, gradient_theme); + PanelSurface gradient_surface(argv[1], Mount::World, gradient_theme, {.enabled = false}); gradient_surface.render(p); const auto channel = [&](int x, int y, int c) { return gradient_surface.pixels()[(y * PanelSurface::width + x) * 4 + c]; @@ -109,6 +111,70 @@ int main(int argc, char** argv) { assert(channel(1012, 723, 0) > 220 && channel(1040, 705, 2) > 220); assert(channel(1030, 704, 3) == 0 && channel(800, 724, 3) == 0); assert(channel(401, 721, 3) == alpha(401, 721)); + { + // Animated colors share one global field; timestamps never depend on the + // civil clock, and full cycles return byte-identical pixels (including alpha). + using namespace std::chrono_literals; + const auto epoch = PanelSurface::Clock::time_point{}; + PanelSurface animated(argv[1], Mount::World, gradient_theme); + animated.set_clock_time(std::time_t{1704211440}); + assert(animated.render(p, epoch)); + const auto first = animated.pixels(); + const auto rgb = [](const auto& pixels, int x, int y) { + const auto i = (y * PanelSurface::width + x) * 4; + return Rgba{pixels[i], pixels[i + 1], pixels[i + 2], 255}; + }; + assert(rgb(first, 100, 190) != rgb(first, 500, 190)); // spatial, not a flat color fade + for (int x : {410, 500, 590}) + assert(rgb(first, x, 723) == rgb(first, x, 10)); // handle and perimeter same phase + const auto bg = rgb(first, 500, 190), edge = rgb(first, 500, 10); + for (int k = 0; k < 3; ++k) + assert(bg[k] == static_cast(gradient_theme.background[k] * .88f + edge[k] * .12f)); + assert(!animated.render(p, epoch + 99ms)); + assert(animated.render(p, epoch + 100ms)); + assert(!animated.render(p, epoch + 150ms)); + assert(animated.render(p, epoch + 7500ms)); + const auto quarter = animated.pixels(); + assert(rgb(quarter, 500, 190) != bg); + assert(rgb(quarter, 500, 10) != edge); + assert(rgb(quarter, 500, 723) != rgb(first, 500, 723)); + assert(rgb(quarter, 1040, 705) != rgb(first, 1040, 705)); + for (size_t i = 3; i < first.size(); i += 4) assert(first[i] == quarter[i]); + assert(animated.render(p, epoch + 29900ms)); + const auto before_loop = animated.pixels(); + assert(animated.render(p, epoch + 30s)); + assert(animated.pixels() == first); + assert(animated.render(p, epoch + 30100ms)); + for (auto point : {std::pair{500, 190}, std::pair{500, 10}, std::pair{500, 723}, std::pair{1040, 705}}) { + const auto a = rgb(before_loop, point.first, point.second); + const auto b = rgb(first, point.first, point.second); + const auto c = rgb(animated.pixels(), point.first, point.second); + for (int k = 0; k < 3; ++k) { + assert(std::abs(int(a[k]) - b[k]) <= 3); + assert(std::abs(int(c[k]) - b[k]) <= 3); + assert(std::abs((int(b[k]) - a[k]) - (int(c[k]) - b[k])) <= 2); + } + } + // Hidden panels skip animation; showing again catches up without replaying + // missed frames. A frame never cancels an already approved pointer press. + assert(!animated.render(p, epoch + 40s, false)); + assert(animated.render(p, epoch + 40s)); + animated.pointer_down(0, 100, 610, epoch + 40s); + assert(animated.render(p, epoch + 41s)); + assert(animated.pointer_up(0, 100, 610, epoch + 41s).action == UiAction::BeginRecord); + assert(!gradient_surface.render(p, epoch + 300s)); // disabled has no timed redraws + PanelSurface faster(argv[1], Mount::World, gradient_theme, {.period_seconds = 10.f, .strength = 0.f}); + assert(faster.render(p, epoch)); + const auto fast_first = faster.pixels(); + assert(rgb(fast_first, 500, 190) == gradient_theme.background); + assert(faster.render(p, epoch + 2500ms) && faster.pixels() != fast_first); + assert(faster.render(p, epoch + 10s) && faster.pixels() == fast_first); + if (argc >= 3) { + PanelSurface preview(argv[1], Mount::World); + preview.render(p, epoch); + snapshot(preview, std::string(argv[2]) + "-gradient.ppm"); + } + } auto regions = surface.input_regions(); assert(regions.size() == 3); assert(regions[2].x == 996 && regions[2].y == 680); // masks use TOP-left, not GL mouse Y @@ -321,7 +387,7 @@ int main(int argc, char** argv) { assert(!surface.render(p)); // Model page: selection is separate from installation; the first Install // click reveals the source/size/license and cannot launch a child. - PanelSurface chooser(argv[1], Mount::World); + PanelSurface chooser(argv[1], Mount::World, {}, {.enabled = false}); Panel model_panel{"Ready", "", "", true, false}; model_panel.selected_backend = "redux"; model_panel.models = {{"redux", "Parakeet Redux", "not_installed", "https://example.org/model", @@ -374,7 +440,7 @@ int main(int argc, char** argv) { assert(install.model_action && install.model_action->manifest_sha256 == std::string(64, 'c')); // Max-length metadata must have every byte represented on navigable pages, // including the source tail, long license text, attribution and exact hash. - PanelSurface long_review(argv[1], Mount::World); + PanelSurface long_review(argv[1], Mount::World, {}, {.enabled = false}); Panel long_panel{"Ready", "", "", true, false}; long_panel.selected_backend = "redux"; auto long_model = model_panel.models[0]; diff --git a/tests/test_installer.py b/tests/test_installer.py index 511a891..d29c400 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -86,6 +86,8 @@ class InstallTests(unittest.TestCase): self.assertIs(json.loads(config.read_text())["auto_insert"], False) self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], False) self.assertEqual(json.loads(config.read_text())["backend"], "redux") + self.assertEqual(json.loads(config.read_text())["gradient"], + {"enabled": True, "period_seconds": 30, "strength": 0.12}) self.assertIs(json.loads(config.read_text())["lock_layout"], False) self.assertEqual(list(config.parent.glob("config.json.backup-*")), []) self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) @@ -244,6 +246,74 @@ class InstallTests(unittest.TestCase): self.install("0.1.202609241530", archive, digest) self.assertTrue(config.is_symlink()) + def test_gradient_config_defaults_preservation_and_repair(self): + archive, digest = self.package("0.1.202609241530") + config = self.home / ".config/frameyap/config.json" + config.parent.mkdir(parents=True) + defaults = installer.CONFIG_DEFAULTS["gradient"] + + # Older configs receive defaults; a non-object section is replaced. + for value in (None, [], "animated", 1): + with self.subTest(section=value): + original = json.dumps({"font": "/custom.ttf", "gradient": value}).encode() + config.write_bytes(original) + self.install("0.1.202609241530", archive, digest) + fixed = json.loads(config.read_text()) + self.assertEqual(fixed["gradient"], defaults) + self.assertEqual(fixed["font"], "/custom.ttf") + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + original = b'{"font":"/custom.ttf"}' + config.write_bytes(original) + self.install("0.1.202609241530", archive, digest) + self.assertEqual(json.loads(config.read_text())["gradient"], defaults) + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + original = b'{"gradient":{"enabled":false},"font":"/custom.ttf"}' + config.write_bytes(original) + self.install("0.1.202609241530", archive, digest) + self.assertEqual(json.loads(config.read_text())["gradient"], + {"enabled": False, "period_seconds": 30, "strength": 0.12}) + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + + # A complete valid section, including numeric boundaries and explicit + # disabled state, retains exact user bytes and gets no new backup. + for gradient in ({"enabled": False, "period_seconds": 5, "strength": 0}, + {"enabled": True, "period_seconds": 300.0, "strength": 0.3}, + {"enabled": True, "period_seconds": 17.5, "strength": 0.125}): + with self.subTest(valid=gradient): + full = installer.normalized_config({"gradient": gradient}) + compact = json.dumps(full, separators=(",", ":")).encode() + config.write_bytes(compact) + backups = len(list(config.parent.glob("config.json.backup-*"))) + self.install("0.1.202609241530", archive, digest) + self.assertEqual(config.read_bytes(), compact) + self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), backups) + + for name, invalids in ( + ("enabled", ("true", 1, 0, None, [], {})), + ("period_seconds", (True, False, 4.99, 301, 10**400, "30", None, float("nan"), float("inf"), -float("inf"))), + ("strength", (True, False, -0.001, 0.301, 10**400, "0.12", None, float("nan"), float("inf"), -float("inf"))), + ): + for invalid in invalids: + with self.subTest(field=name, invalid=invalid): + gradient = {"enabled": False, "period_seconds": 19.5, "strength": 0.21} + gradient[name] = invalid + original = json.dumps({"gradient": gradient, "theme": {"card": "#AbCdEf"}}).encode() + config.write_bytes(original) + self.install("0.1.202609241530", archive, digest) + fixed = json.loads(config.read_text()) + self.assertEqual(fixed["gradient"][name], defaults[name]) + for valid_name in set(gradient) - {name}: + self.assertEqual(fixed["gradient"][valid_name], gradient[valid_name]) + self.assertEqual(fixed["theme"]["card"], "#AbCdEf") + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + + original = b'{"gradient":{"enabled":false,"period_seconds":7,"strength":0.2,"retired":1}}' + config.write_bytes(original) + self.install("0.1.202609241530", archive, digest) + self.assertEqual(json.loads(config.read_text())["gradient"], + {"enabled": False, "period_seconds": 7, "strength": 0.2}) + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + def test_debug_boolean_repair_backs_up_invalid_values(self): archive, digest = self.package("0.1.202609241530") config = self.home / ".config/frameyap/config.json"