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_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",
+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
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,
+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,
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)
+13
View File
@@ -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 {}
+13
View File
@@ -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 {}
+14 -2
View File
@@ -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) {
+6
View File
@@ -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.
+2 -2
View File
@@ -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)");
+44 -20
View File
@@ -114,6 +114,10 @@ struct PanelSurface::Impl {
Panel panel;
Mount mount;
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;
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<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() {
// 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<unsigned char>(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<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));
}
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<unsigned char>(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<Impl>(font, mount, theme)) {}
PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme, GradientConfig gradient)
: impl_(std::make_unique<Impl>(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<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; }
bool PanelSurface::available(UiAction a) const { return impl_->available(a); }
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 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<unsigned char>& 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<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());
+24
View File
@@ -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}));
+71 -5
View File
@@ -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<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();
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];
+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())["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"