Add configurable seamless animated panel gradient

This commit is contained in:
baketnk committed 2026-09-25 10:10:15 -04:00
1 parent 1e45134755
commit 94b747bb04
13 files changed
+309 -46

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
"frame_start": "#1fff91", "frame_start": "#1fff91",
"frame_end": "#1f70ff" "frame_end": "#1f70ff"
}, },
"gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12},
"buttons": { "buttons": {
"left_grip": "/user/hand/left/input/grip", "left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip", "right_grip": "/user/hand/right/input/grip",
+15 -2
View File
@@ -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 handles, input manifest and shutdown. No dependency on external application
libraries, assets, build trees or Python environments. libraries, assets, build trees or Python environments.
Use one small RGBA panel updated only on UI changes and a bounded recording Use one small RGBA panel updated on UI changes, a bounded recording
indicator cadence. The native implementation uploads the CPU-rasterized panel to 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 a persistent Vulkan image and uses `SetOverlayTexture`; the initial
`SetOverlayRaw` proof path has been replaced. No stereo `SetOverlayRaw` proof path has been replaced. No stereo
eye targets or per-eye scene rendering. Keep tracking in compositor transforms, eye targets or per-eye scene rendering. Keep tracking in compositor transforms,
+31 -12
View File
@@ -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, persistent Vulkan RGBA8 image and submits it with `SetOverlayTexture`. The image,
staging allocation and command buffer are reused; tabs do not create extra staging allocation and command buffer are reused; tabs do not create extra
overlays or render targets. The rounded mint-to-blue perimeter, 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 preview, status and control surfaces use independently rasterized antialiased edges
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 only for changed content, page or settings; laser hover Rendering/uploads occur for changed content, page or settings, and, when the
and button down/up are hit-tested without an upload. Static frames are reused. animated gradient is enabled and visible, at most every 100 ms (10 fps) on a
The caller may call `draw(Panel)` at 10 ms intervals. Tracking transforms do monotonic clock. There is no animation redraw while hidden. Laser hover and
not require repainting the canvas. 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 The Vulkan instance/device enable the extensions requested by the running
SteamVR runtime and use its selected physical device and a graphics queue. 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", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff" "frame_start": "#1fff91", "frame_end": "#1f70ff"
}, },
"gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12},
"buttons": { "buttons": {
"ptt": "/user/hand/right/input/x", "ptt": "/user/hand/right/input/x",
"cancel": "/user/hand/right/input/b", "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 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. 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 Each theme color is `#RRGGBB`; omitted colors keep the default. The optional
TTF/OTF file path (not a family name); a missing file uses the bundled font. `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 `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 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 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 (or `~/.cache/frameyap/bindings`); the bundled manifest remains unchanged. The
config is read once at launch, not hot-reloaded (the in-panel backend selection 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, is saved separately). On install/upgrade the installer fills known missing fields,
including `close_mic_when_idle` and `backend`, removes retired keys and resets including `close_mic_when_idle`, `backend` and `gradient`, removes retired keys
invalid entries. It saves the exact prior bytes under `config.json.backup-*` and unknown `gradient` subkeys, and resets invalid entries. It saves the exact
before a repair and refuses symlink/oversized config paths; valid customizations 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 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 launches with bad JSON, gradient fields, colors or button mappings fail startup
silently changing input behavior. rather than silently changing input behavior.
### Models / backends (local implementation) ### Models / backends (local implementation)
+13
View File
@@ -27,6 +27,7 @@ import importlib.util
import fcntl import fcntl
import hashlib import hashlib
import json import json
import math
import os import os
from pathlib import Path from pathlib import Path
import platform import platform
@@ -62,6 +63,7 @@ CONFIG_DEFAULTS = {
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"}, "frame_start": "#1fff91", "frame_end": "#1f70ff"},
"gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12},
"buttons": {"left_grip": "/user/hand/left/input/grip", "buttons": {"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip", "right_grip": "/user/hand/right/input/grip",
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", "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) value = source.get(name, default)
limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) 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 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"): for section in ("theme", "buttons"):
source = data.get(section) source = data.get(section)
source = source if isinstance(source, dict) else {} source = source if isinstance(source, dict) else {}
+13
View File
@@ -7,6 +7,7 @@ import importlib.util
import fcntl import fcntl
import hashlib import hashlib
import json import json
import math
import os import os
from pathlib import Path from pathlib import Path
import platform import platform
@@ -42,6 +43,7 @@ CONFIG_DEFAULTS = {
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"}, "frame_start": "#1fff91", "frame_end": "#1f70ff"},
"gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12},
"buttons": {"left_grip": "/user/hand/left/input/grip", "buttons": {"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip", "right_grip": "/user/hand/right/input/grip",
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b", "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) value = source.get(name, default)
limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) 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 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"): for section in ("theme", "buttons"):
source = data.get(section) source = data.get(section)
source = source if isinstance(source, dict) else {} source = source if isinstance(source, dict) else {}
+14 -2
View File
@@ -139,10 +139,10 @@ Rgba color(const Json& json) {
return c; return c;
} }
float bounded_number(const Json& json, std::string_view name, float lower, float upper) { 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); const float value = std::strtof(json.value.c_str(), nullptr);
if (!std::isfinite(value) || value < lower || value > upper) 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; return value;
} }
std::filesystem::path xdg_root(const char* variable, const char* fallback) { 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 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 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") { } else if (key == "theme") {
if (!value.is_object) throw std::runtime_error("Config theme must be an object"); if (!value.is_object) throw std::runtime_error("Config theme must be an object");
for (const auto& [name, v] : value.object) { for (const auto& [name, v] : value.object) {
+6
View File
@@ -14,9 +14,15 @@ struct Theme {
Rgba accent{31, 240, 164, 255}, warning{255, 110, 135, 255}; Rgba accent{31, 240, 164, 255}, warning{255, 110, 135, 255};
Rgba frame_start{31, 255, 145, 255}, frame_end{31, 112, 255, 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 }; enum class DateFormat { Off, MonthDayYear, DayMonthYear, Iso };
struct Config { struct Config {
Theme theme; Theme theme;
GradientConfig gradient;
std::string font; // absolute TTF/OTF path; empty uses the bundled face std::string font; // absolute TTF/OTF path; empty uses the bundled face
std::string backend = "redux"; // selected manifest ID; never triggers a download std::string backend = "redux"; // selected manifest ID; never triggers a download
// Requests OpenVR's experimental global action priority; SteamVR must allow it too. // Requests OpenVR's experimental global action priority; SteamVR must allow it too.
+2 -2
View File
@@ -126,7 +126,7 @@ struct Overlay::Impl {
mount(requested ? *requested : load_mount(persistence.settings_path)), mount(requested ? *requested : load_mount(persistence.settings_path)),
lasers_anytime(load_lasers_anytime(persistence.laser_settings_path)), lasers_anytime(load_lasers_anytime(persistence.laser_settings_path)),
config(load_config(default_config_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; persistence.persist_mount = persist;
const auto action_path = absolute_file(action_manifest(assets, config)); const auto action_path = absolute_file(action_manifest(assets, config));
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json"); absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
@@ -374,7 +374,7 @@ struct Overlay::Impl {
void draw(const Panel& p) { void draw(const Panel& p) {
panel = p; panel = p;
surface.set_clock_time(std::time(nullptr)); surface.set_clock_time(std::time(nullptr));
if (surface.render(p)) { if (surface.render(p, PanelSurface::Clock::now(), shown)) {
gpu_texture->upload(surface.pixels()); gpu_texture->upload(surface.pixels());
auto texture = gpu_texture->texture(); auto texture = gpu_texture->texture();
overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)"); overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)");
+44 -20
View File
@@ -114,6 +114,10 @@ struct PanelSurface::Impl {
Panel panel; Panel panel;
Mount mount; Mount mount;
Theme theme; Theme theme;
GradientConfig gradient;
std::optional<Clock::time_point> animation_start;
Clock::time_point last_animation_frame{};
std::array<Color, W> gradient_columns{};
Color background, card, ink, muted, cyan, pink; Color background, card, ink, muted, cyan, pink;
Tab tab = Tab::Review; Tab tab = Tab::Review;
bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false;
@@ -165,8 +169,8 @@ struct PanelSurface::Impl {
install_confirm = true; install_confirm = true;
} }
Impl(const std::string& font, Mount m, Theme t) Impl(const std::string& font, Mount m, Theme t, GradientConfig g)
: mount(m), theme(t), background(t.background), card(t.card), ink(t.ink), muted(t.muted), : mount(m), theme(t), gradient(g), background(t.background), card(t.card), ink(t.ink), muted(t.muted),
cyan(t.accent), pink(t.warning) { cyan(t.accent), pink(t.warning) {
if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed"); if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed");
if (FT_New_Face(library, font.c_str(), 0, &face)) { 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)); 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<double>(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() { void frame() {
// Independently rasterized neon-frame HUD style: // Independently rasterized neon-frame HUD style:
// rounded mint-to-blue perimeter and a second shallow curved accent. // 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 distance = rounded_distance(x, y, 10, 10, CW - 20, CH - 20, 18.f);
const float edge = std::abs(distance); 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 strength = edge <= 1.f ? 1.f : .30f * std::max(0.f, 1.f - (edge - 1.f) / 6.f);
const float t = float(x) / CW; const auto color = gradient.enabled ? gradient_columns[x] :
Color gradient{}; mix(theme.frame_start, theme.frame_end, float(x) / CW);
for (int k = 0; k < 3; ++k)
gradient[k] = static_cast<unsigned char>(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t);
gradient[3] = 255;
auto* dst = pixels.data() + (size_t(y) * W + x) * 4; auto* dst = pixels.data() + (size_t(y) * W + x) * 4;
for (int k = 0; k < 3; ++k) dst[k] = static_cast<unsigned char>(background[k] + (gradient[k] - background[k]) * strength); for (int k = 0; k < 3; ++k)
dst[k] = static_cast<unsigned char>(dst[k] + (color[k] - dst[k]) * strength);
dst[3] = distance <= 1.f ? 255 : static_cast<unsigned char>(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f)); dst[3] = distance <= 1.f ? 255 : static_cast<unsigned char>(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f));
} }
for (int x = 32; x < CW - 32; ++x) { for (int x = 32; x < CW - 32; ++x) {
const float t = float(x - 32) / (CW - 64); const float t = float(x - 32) / (CW - 64);
const int y = CH - 21 - int(5 * std::sin(t * 3.14159265f)); const int y = CH - 21 - int(5 * std::sin(t * 3.14159265f));
Color gradient{}; const auto color = gradient.enabled ? gradient_columns[x] : mix(theme.frame_start, theme.frame_end, t);
for (int k = 0; k < 3; ++k) rect({x, y, 1, 1}, color);
gradient[k] = static_cast<unsigned char>(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t);
gradient[3] = 255;
rect({x, y, 1, 1}, gradient);
} }
} }
int advance(uint32_t cp) { int advance(uint32_t cp) {
@@ -344,7 +363,10 @@ struct PanelSurface::Impl {
pressed.fill(-1); pressed.fill(-1);
drag_cursor = -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 || if (p.recording != panel.recording || p.enabled != panel.enabled ||
p.record_available != panel.record_available || p.transcript != panel.transcript) { p.record_available != panel.record_available || p.transcript != panel.transcript) {
reset(); // an old press cannot authorize a changed action or replacement 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 (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true;
if (!dirty) return false; if (!dirty) return false;
displayed_clock = std::move(label); displayed_clock = std::move(label);
if (gradient.enabled) prepare_gradient(now);
last_animation_frame = now;
std::fill(pixels.begin(), pixels.end(), 0); std::fill(pixels.begin(), pixels.end(), 0);
rect({0, 0, CW, CH}, background); paint_background();
frame(); frame();
text("FrameYap", 32, 61, 40, ink, 300); text("FrameYap", 32, 61, 40, ink, 300);
text(displayed_clock.time, 475, 58, 28, ink, 735); 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) { for (int x = bounds.x; x < bounds.x + bounds.w; ++x) {
auto* dst = pixels.data() + (size_t(y) * W + x) * 4; auto* dst = pixels.data() + (size_t(y) * W + x) * 4;
if (!dst[3]) continue; if (!dst[3]) continue;
const auto color = mix(theme.frame_start, theme.frame_end, const auto color = gradient.enabled ? gradient_columns[x] :
float(x - bounds.x) / (bounds.w - 1)); mix(theme.frame_start, theme.frame_end, float(x - bounds.x) / (bounds.w - 1));
std::copy_n(color.begin(), 3, dst); std::copy_n(color.begin(), 3, dst);
} }
} }
@@ -516,10 +540,10 @@ struct PanelSurface::Impl {
return true; return true;
} }
}; };
PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme) PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme, GradientConfig gradient)
: impl_(std::make_unique<Impl>(font, mount, theme)) {} : impl_(std::make_unique<Impl>(font, mount, theme, gradient)) {}
PanelSurface::~PanelSurface() = default; 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<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; } const std::vector<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; }
bool PanelSurface::available(UiAction a) const { return impl_->available(a); } bool PanelSurface::available(UiAction a) const { return impl_->available(a); }
std::vector<std::string> PanelSurface::visible_model_review_lines() const { std::vector<std::string> PanelSurface::visible_model_review_lines() const {
+5 -3
View File
@@ -33,14 +33,16 @@ public:
static constexpr Bounds body{0, 0, 1000, 680}; static constexpr Bounds body{0, 0, 1000, 680};
static constexpr Bounds grab{370, 698, 260, 52}; static constexpr Bounds grab{370, 698, 260, 52};
static constexpr Bounds scale{996, 680, 68, 68}; 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();
PanelSurface(const PanelSurface&) = delete; PanelSurface(const PanelSurface&) = delete;
PanelSurface& operator=(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<unsigned char>& pixels() const; const std::vector<unsigned char>& pixels() const;
// Handles capture one cursor and never authorize a UI action on release. // 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); 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());
+24
View File
@@ -49,6 +49,9 @@ int main(int argc, char** argv) {
assert(!load_config(path).clock_24h); assert(!load_config(path).clock_24h);
assert(load_config(path).date_format == DateFormat::MonthDayYear); assert(load_config(path).date_format == DateFormat::MonthDayYear);
assert(load_config(path).wrist.width == .30f); 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"); 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("ptt") == "/user/hand/right/input/x");
assert(example.buttons.at("quick_chat") == "/user/hand/right/input/y"); 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.lock_layout);
assert(!example.clock_24h && example.date_format == DateFormat::MonthDayYear); assert(!example.clock_24h && example.date_format == DateFormat::MonthDayYear);
assert(example.wrist.y == .18f && example.wrist.z == .089f); 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()); std::filesystem::create_directories(path.parent_path());
assert(save_backend(path, "fixture_cpu")); assert(save_backend(path, "fixture_cpu"));
assert(load_config(path).backend == "fixture_cpu"); assert(load_config(path).backend == "fixture_cpu");
@@ -203,6 +207,26 @@ int main(int argc, char** argv) {
R"({"wrist":{"x":1e999}})"}) { R"({"wrist":{"x":1e999}})"}) {
put(path, invalid); fails([&] { load_config(path); }); 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":""}})"); 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); auto config = load_config(path);
assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255})); assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255}));
+71 -5
View File
@@ -27,7 +27,9 @@ void snapshot(PanelSurface& surface, const std::string& path) {
} }
int main(int argc, char** argv) { int main(int argc, char** argv) {
assert(argc >= 2); 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}; Panel p{"Ready to record", "", "Focus your destination before Type. Enter is always separate.", false, false};
assert(surface.render(p)); assert(surface.render(p));
assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4)); assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4));
@@ -44,7 +46,7 @@ int main(int argc, char** argv) {
Theme custom; Theme custom;
custom.background = {75, 30, 100, 255}; custom.background = {75, 30, 100, 255};
custom.accent = {245, 110, 15, 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)); assert(themed.render(p));
const auto pixel = size_t((190 * PanelSurface::width + 500) * 4); 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); 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; Theme gradient_theme;
gradient_theme.frame_start = {255, 0, 0, 255}; gradient_theme.frame_start = {255, 0, 0, 255};
gradient_theme.frame_end = {0, 0, 255, 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); gradient_surface.render(p);
const auto channel = [&](int x, int y, int c) { const auto channel = [&](int x, int y, int c) {
return gradient_surface.pixels()[(y * PanelSurface::width + x) * 4 + 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(1012, 723, 0) > 220 && channel(1040, 705, 2) > 220);
assert(channel(1030, 704, 3) == 0 && channel(800, 724, 3) == 0); assert(channel(1030, 704, 3) == 0 && channel(800, 724, 3) == 0);
assert(channel(401, 721, 3) == alpha(401, 721)); 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<unsigned char>(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(); auto regions = surface.input_regions();
assert(regions.size() == 3); assert(regions.size() == 3);
assert(regions[2].x == 996 && regions[2].y == 680); // masks use TOP-left, not GL mouse Y 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)); assert(!surface.render(p));
// Model page: selection is separate from installation; the first Install // Model page: selection is separate from installation; the first Install
// click reveals the source/size/license and cannot launch a child. // 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}; Panel model_panel{"Ready", "", "", true, false};
model_panel.selected_backend = "redux"; model_panel.selected_backend = "redux";
model_panel.models = {{"redux", "Parakeet Redux", "not_installed", "https://example.org/model", 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')); assert(install.model_action && install.model_action->manifest_sha256 == std::string(64, 'c'));
// Max-length metadata must have every byte represented on navigable pages, // Max-length metadata must have every byte represented on navigable pages,
// including the source tail, long license text, attribution and exact hash. // 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}; Panel long_panel{"Ready", "", "", true, false};
long_panel.selected_backend = "redux"; long_panel.selected_backend = "redux";
auto long_model = model_panel.models[0]; auto long_model = model_panel.models[0];
+70
View File
@@ -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())["auto_insert"], False)
self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], 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())["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.assertIs(json.loads(config.read_text())["lock_layout"], False)
self.assertEqual(list(config.parent.glob("config.json.backup-*")), []) self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text())
@@ -244,6 +246,74 @@ class InstallTests(unittest.TestCase):
self.install("0.1.202609241530", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertTrue(config.is_symlink()) 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): def test_debug_boolean_repair_backs_up_invalid_values(self):
archive, digest = self.package("0.1.202609241530") archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json" config = self.home / ".config/frameyap/config.json"