mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-04 22:00:03 +02:00
Add configurable seamless animated panel gradient
This commit is contained in:
1 parent
1e45134755
commit
94b747bb04
13 files changed
+309
-46
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user