diff --git a/README.md b/README.md index 4b720c3..0cceb2d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ in Wayfire 0.11.0-r19 or newer. Unpatched upstream Wayfire is not supported. | `panel-desktop` | Physical-monitor Show Desktop | Wayfire | | `workspace-backgrounds` | Per-workspace wallpapers | gdk-pixbuf | | `screensaver` | Per-monitor idle fade, black, power-off, keep-awake and auto-lock | Wayfire | +| `find-pointer` | Press Ctrl twice to spotlight the pointer | Wayfire (GLES2 renderer) | -All four build by default. Building a module does not enable it in Wayfire. +All five build by default. Building a module does not enable it in Wayfire. Window-actions owns the desktop's spatial transactions, previews and physical capture identity; it has no storage and no options. It replaces the former `window-state` plugin: in `wayfire.ini`, replace `window-state` with @@ -36,6 +37,7 @@ The installed libraries: - `libpanel-desktop.so`. - `libworkspace-backgrounds.so`. - `libscreensaver.so`. +- `libfind-pointer.so`. The package replaces the standalone implementations. Compatibility ebuilds for `wayfire-window-state` and `wayfire-panel-desktop` depend diff --git a/meson.build b/meson.build index 4f2bea1..7eb9888 100644 --- a/meson.build +++ b/meson.build @@ -2,10 +2,10 @@ project('wayfire-plugins-custom', 'cpp', version: '0.1.0', default_options: ['cpp_std=c++17', 'warning_level=3']) wf = dependency('wayfire', version: '>=0.11.0') -if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') +if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') and not get_option('find-pointer') error('Enable at least one plugin') endif -foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver'] +foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer'] if get_option(plugin) subdir('plugins' / plugin) endif @@ -15,5 +15,6 @@ summary({ 'panel-desktop': get_option('panel-desktop'), 'workspace-backgrounds': get_option('workspace-backgrounds'), 'screensaver': get_option('screensaver'), + 'find-pointer': get_option('find-pointer'), 'tests': get_option('tests'), }, section: 'Custom Wayfire plugins') diff --git a/meson_options.txt b/meson_options.txt index a7b7823..11efc31 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -4,3 +4,4 @@ option('tests', type: 'boolean', value: false, description: 'Build unit tests') option('workspace-backgrounds', type: 'boolean', value: true, description: 'Workspace wallpapers') option('workspace-backgrounds-test-api', type: 'boolean', value: false, description: 'Private compositor test controls; do not enable in installed builds') option('screensaver', type: 'boolean', value: true, description: 'Per-monitor idle fading, black, power-off, keep-awake and auto-lock') +option('find-pointer', type: 'boolean', value: true, description: 'Press Ctrl twice to spotlight the pointer') diff --git a/plugins/find-pointer/LICENSE b/plugins/find-pointer/LICENSE new file mode 100644 index 0000000..51026fa --- /dev/null +++ b/plugins/find-pointer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 find-pointer contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/find-pointer/README.md b/plugins/find-pointer/README.md new file mode 100644 index 0000000..6f1b9e3 --- /dev/null +++ b/plugins/find-pointer/README.md @@ -0,0 +1,57 @@ +# find-pointer + +Press and release Ctrl twice quickly to find the pointer, like PowerToys' +Find My Mouse. Every monitor dims, except for a circle that closes in on the +pointer. The circle follows the pointer, stays for a second and then fades. + +Add `find-pointer` to `[core] plugins`. It needs the GLES2 renderer, which is +Wayfire's default. Under another renderer it logs an error and does nothing. + +## Behaviour + +- **Double tap.** Press and release Ctrl (either one) twice. The second press + must come within 400 ms of the first, and each press must be released + within 400 ms. Ctrl must be pressed alone. Any other key, click or scroll in + between starts over, so Ctrl+C, Ctrl-click and Ctrl+scroll never trigger it. + A single tap does nothing. + - The plugin only watches keys. Clients receive every Ctrl event exactly as + before. + - A double tap is ignored while a fullscreen window has keyboard focus, + because double-tapping Ctrl is common in games. The `activate` shortcut + still works there. + - Nothing happens while the session is locked. +- **Spotlight.** The screen outside the circle darkens to `dim` (default + half). The circle shrinks from nine times `radius` to `radius` (70 px) in + 250 ms, while the darkening fades in. It stays for `duration` (1000 ms) and + then fades out in 200 ms. A thin light ring marks its edge. + - The circle is placed in the global layout. Every monitor is darkened, + and the circle shows on the monitor under the pointer, across the edge + when the pointer is near one. + - Any key press, click, scroll or touch ends it early, with the same + 200 ms fade. + - Repeating the gesture while it is shown starts it again. +- **Cost.** When idle the plugin adds no render hooks and does no per-frame + work. While shown, it adds one post-processing pass per monitor and + redraws every frame for the length of the effect. + +## Options + +| Option | Default | Meaning | +| --- | --- | --- | +| `double_tap_ctrl` | `true` | Find the pointer with a double tap of Ctrl. | +| `activate` | `none` | Shortcut (activator) that does the same. | +| `dim` | `0.5` | How much the screen darkens outside the circle (0.1–0.9). | +| `radius` | `70` | Circle radius in logical pixels (20–400). | +| `duration` | `1000` | Milliseconds the circle stays before fading (0–10000). | + +```ini +[find-pointer] +double_tap_ctrl = true +activate = none +``` + +## Tests + +`meson test` runs `find-pointer-spotlight`. It covers the double-tap rules +(timing, chords, other input, duplicate events) and the shrink, hold and +fade timeline, including ending early. diff --git a/plugins/find-pointer/find-pointer.xml b/plugins/find-pointer/find-pointer.xml new file mode 100644 index 0000000..905c4d6 --- /dev/null +++ b/plugins/find-pointer/find-pointer.xml @@ -0,0 +1,38 @@ + + + + <_short>Find Pointer + <_long>Press and release Ctrl twice to dim the screen around a circle that closes in on the pointer. + Accessibility + + + + + + + diff --git a/plugins/find-pointer/meson.build b/plugins/find-pointer/meson.build new file mode 100644 index 0000000..c391758 --- /dev/null +++ b/plugins/find-pointer/meson.build @@ -0,0 +1,7 @@ +shared_module('find-pointer', 'src/plugin.cpp', dependencies: wf, + install: true, install_dir: wf.get_variable(pkgconfig: 'plugindir')) +install_data('find-pointer.xml', install_dir: wf.get_variable(pkgconfig: 'metadatadir')) +if get_option('tests') + test('find-pointer-spotlight', executable('find-pointer-spotlight-test', 'tests/spotlight-test.cpp'), + timeout: 30) +endif diff --git a/plugins/find-pointer/src/plugin.cpp b/plugins/find-pointer/src/plugin.cpp new file mode 100644 index 0000000..5cbcb6f --- /dev/null +++ b/plugins/find-pointer/src/plugin.cpp @@ -0,0 +1,325 @@ +// find-pointer: double-tap Ctrl (or a shortcut) to dim every monitor except +// a shrinking circle around the pointer, like PowerToys' Find My Mouse. +// Idle, it only watches input: render hooks exist only while it is shown. +#include "spotlight.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +double now() +{ + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +const char *vertex_shader = R"( +#version 100 +attribute highp vec2 position; +attribute highp vec2 uvPosition; +varying highp vec2 uvpos; +void main() +{ + gl_Position = vec4(position.xy, 0.0, 1.0); + uvpos = uvPosition; +} +)"; + +// Darken outside the circle (antialiased edge) and draw a thin light ring. +const char *fragment_shader = R"( +#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +varying highp vec2 uvpos; +uniform sampler2D smp; +uniform vec2 center; +uniform float radius; +uniform float dim; +uniform float ring; +uniform float width; +void main() +{ + vec4 c = texture2D(smp, uvpos); + float d = distance(gl_FragCoord.xy, center); + float outside = smoothstep(radius - 0.75, radius + 0.75, d); + float edge = 1.0 - smoothstep(0.0, 1.0, abs(d - radius - width * 0.5) - width * 0.5); + vec3 rgb = c.rgb * (1.0 - dim * outside); + gl_FragColor = vec4(mix(rgb, vec3(0.92), ring * 0.85 * edge), 1.0); +} +)"; + +class find_pointer_t : public wf::plugin_interface_t +{ + wf::option_wrapper_t double_tap_ctrl{"find-pointer/double_tap_ctrl"}; + wf::option_wrapper_t activate_binding{"find-pointer/activate"}; + wf::option_wrapper_t dim{"find-pointer/dim"}; + wf::option_wrapper_t radius{"find-pointer/radius"}; + wf::option_wrapper_t duration{"find-pointer/duration"}; + + find_pointer::tap_detector_t taps; + find_pointer::timing_t timing; + double started = 0, fade_from = 0; // fade_from counts from started + std::map> hooks; // non-empty while shown + OpenGL::program_t program; + bool usable = false; + wf::wl_timer finish; + + bool locked() + { + for (auto o : wf::get_core().output_layout->get_outputs()) + { + if (o->is_inhibited()) + { + return true; + } + } + + return false; + } + + // Games and videos: a double-tapped Ctrl there is more likely play than a lost pointer. + static bool fullscreen_focus() + { + auto view = wf::toplevel_cast(wf::get_core().seat->get_active_view()); + return view && view->pending_fullscreen(); + } + + void attach(wf::output_t *o) + { + if (hooks.count(o)) + { + return; + } + + auto& hook = hooks[o] = std::make_unique( + [this, o] (wf::auxilliary_buffer_t& source, const wf::render_buffer_t& destination) + { + render(o, source, destination); + }); + o->render->add_post(hook.get()); + o->render->set_redraw_always(true); + } + + void detach(wf::output_t *o) + { + auto it = hooks.find(o); + if (it == hooks.end()) + { + return; + } + + o->render->rem_post(it->second.get()); + o->render->set_redraw_always(false); + hooks.erase(it); + } + + bool show() + { + if (!usable || locked()) + { + return false; + } + + timing.dim = dim; + timing.radius = std::max(1, (int)radius); + timing.hold = std::max(0, (int)duration) / 1000.0; + started = now(); + fade_from = find_pointer::fade_start(timing); + for (auto o : wf::get_core().output_layout->get_outputs()) + { + attach(o); + } + + schedule_finish(); + return true; + } + + // Input ends it early: fade out from where it is now. + void end_early() + { + if (!hooks.empty() && (now() - started < fade_from)) + { + fade_from = now() - started; + schedule_finish(); + } + } + + void schedule_finish() + { + double left = fade_from + timing.fade - (now() - started); + finish.disconnect(); + finish.set_timeout(std::max(1, (int)std::ceil(left * 1000) + 1), [this] { hide(); }); + } + + void hide() + { + finish.disconnect(); + while (!hooks.empty()) + { + detach(hooks.begin()->first); + } + } + + void render(wf::output_t *o, wf::auxilliary_buffer_t& source, const wf::render_buffer_t& destination) + { + static const float vertex_data[] = {-1, -1, 1, -1, 1, 1, -1, 1}; + static const float uv_data[] = {0, 0, 1, 0, 1, 1, 0, 1}; + auto frame = find_pointer::frame_at(now() - started, fade_from, timing); + auto target = o->render->get_target_framebuffer(); + // The circle is placed in the global layout, so it spans monitors near + // an edge; on other monitors only the dimming shows. + auto cursor = o->get_cursor_position(); + auto center = target.framebuffer_geometry_from_geometry_box({cursor.x, cursor.y, 0, 0}); + wf::gles::run_in_context([&] + { + wf::gles::bind_render_buffer(destination); + program.use(wf::TEXTURE_TYPE_RGBA); + GL_CALL(glActiveTexture(GL_TEXTURE0)); + GL_CALL(glBindTexture(GL_TEXTURE_2D, wf::gles_texture_t::from_aux(source).tex_id)); + program.attrib_pointer("position", 2, 0, vertex_data); + program.attrib_pointer("uvPosition", 2, 0, uv_data); + program.uniform2f("center", center.x, center.y); + program.uniform1f("radius", frame.radius * target.scale); + program.uniform1f("dim", frame.dim); + program.uniform1f("ring", frame.ring); + program.uniform1f("width", 2 * target.scale); + GL_CALL(glDisable(GL_BLEND)); + GL_CALL(glDrawArrays(GL_TRIANGLE_FAN, 0, 4)); + GL_CALL(glEnable(GL_BLEND)); + GL_CALL(glBindTexture(GL_TEXTURE_2D, 0)); + program.deactivate(); + }); + } + + wf::activator_callback on_activate = [this] (const wf::activator_data_t&) + { + return show(); + }; + + template + using input_t = wf::signal::connection_t>; + + // Observed, never consumed: clients get every key as before. + input_t on_key = [this] (wf::input_event_signal *ev) + { + bool pressed = ev->event->state == WL_KEYBOARD_KEY_STATE_PRESSED; + if (pressed) + { + end_early(); + } + + uint32_t key = ev->event->keycode; + if ((key != KEY_LEFTCTRL) && (key != KEY_RIGHTCTRL)) + { + taps.other(); + return; + } + + if (!double_tap_ctrl) + { + return; + } + + // Ctrl alone: no other key held on this keyboard. + bool alone = true; + if (ev->device && (ev->device->type == WLR_INPUT_DEVICE_KEYBOARD)) + { + auto kbd = wlr_keyboard_from_input_device(ev->device); + for (size_t i = 0; i < kbd->num_keycodes; i++) + { + alone &= (kbd->keycodes[i] == KEY_LEFTCTRL) || (kbd->keycodes[i] == KEY_RIGHTCTRL); + } + } + + if (taps.ctrl(pressed, now(), alone) && !fullscreen_focus()) + { + show(); + } + }; + + input_t on_button = [this] (wf::input_event_signal *ev) + { + taps.other(); + if (ev->event->state == WL_POINTER_BUTTON_STATE_PRESSED) + { + end_early(); + } + }; + input_t on_axis = [this] (auto) + { + taps.other(); + end_early(); + }; + input_t on_touch = [this] (auto) + { + taps.other(); + end_early(); + }; + + wf::signal::connection_t on_added = [this] (wf::output_added_signal *ev) + { + if (!hooks.empty()) + { + attach(ev->output); + } + }; + wf::signal::connection_t on_removed = [this] (wf::output_pre_remove_signal *ev) + { + detach(ev->output); + }; + + public: + void init() override + { + auto& core = wf::get_core(); + usable = core.is_gles2(); + if (!usable) + { + LOGE("find-pointer: requires the GLES2 renderer"); + return; + } + + wf::gles::run_in_context([&] + { + program.set_simple(OpenGL::compile_program(vertex_shader, fragment_shader)); + }); + core.bindings->add_activator(activate_binding, &on_activate); + core.connect(&on_key); + core.connect(&on_button); + core.connect(&on_axis); + core.connect(&on_touch); + core.output_layout->connect(&on_added); + core.output_layout->connect(&on_removed); + } + + void fini() override + { + hide(); + if (usable) + { + wf::get_core().bindings->rem_binding(&on_activate); + wf::gles::run_in_context_if_gles([&] { program.free_resources(); }); + } + } +}; +} + +DECLARE_WAYFIRE_PLUGIN(find_pointer_t); diff --git a/plugins/find-pointer/src/spotlight.hpp b/plugins/find-pointer/src/spotlight.hpp new file mode 100644 index 0000000..f9a2a2d --- /dev/null +++ b/plugins/find-pointer/src/spotlight.hpp @@ -0,0 +1,112 @@ +#pragma once +// Pure find-pointer logic, no Wayfire dependencies: the double-tap detector +// and the spotlight timeline. +#include +#include + +namespace find_pointer +{ +/* Two taps of Ctrl. Each tap is a press and a release of Ctrl alone; the + * second press must come within `window` seconds of the first, and each tap + * must be released within `window` of its press. Any other key, button or + * scroll in between starts over. */ +struct tap_detector_t +{ + double window = 0.4; + enum state_t { IDLE, DOWN1, UP1, DOWN2 } state = IDLE; + double first = 0, second = 0; + + // A Ctrl press or release at time t (seconds). alone: no other key is + // held. Returns true when this release completes a double tap. + bool ctrl(bool pressed, double t, bool alone) + { + if (pressed) + { + if ((state == DOWN1) || (state == DOWN2)) + { + return false; // the other Ctrl, or a repeated event + } + + if (!alone) + { + state = IDLE; + } else if ((state == UP1) && (t - first <= window)) + { + state = DOWN2, second = t; + } else + { + state = DOWN1, first = t; + } + + return false; + } + + if (state == DOWN1) + { + state = (t - first <= window) ? UP1 : IDLE; + } else if (state == DOWN2) + { + state = IDLE; + return t - second <= window; + } + + return false; + } + + void other() + { + state = IDLE; + } +}; + +struct timing_t +{ + double shrink = 0.25; // seconds from the large circle to `radius` + double hold = 1.0; // seconds it then stays + double fade = 0.2; // seconds to fade out + double radius = 70; // final radius, logical pixels + double start_scale = 9; // the circle starts this many radii wide + double dim = 0.5; // darkening outside the circle, 0..1 +}; + +struct frame_t +{ + double radius = 0; // logical pixels + double dim = 0; // darkening outside the circle + double ring = 0; // opacity of the ring at the circle's edge + bool done = false; +}; + +inline double clamp01(double v) +{ + return std::isfinite(v) ? std::clamp(v, 0.0, 1.0) : 0.0; +} + +inline double ease_out(double p) +{ + p = 1 - clamp01(p); + return 1 - p * p * p; +} + +// When the fade starts if nothing ends the spotlight early. +inline double fade_start(const timing_t& m) +{ + return std::max(0.0, m.shrink) + std::max(0.0, m.hold); +} + +/* The spotlight t seconds after it started. The fade begins at fade_from + * (fade_start(), or earlier when input ends it) and keeps the circle and + * darkening it had then. */ +inline frame_t frame_at(double t, double fade_from, const timing_t& m) +{ + frame_t f; + double s = std::min(t, fade_from); + double p = ease_out(m.shrink > 0 ? s / m.shrink : 1); + double fade = (t <= fade_from) ? 1 : 1 - (m.fade > 0 ? clamp01((t - fade_from) / m.fade) : 1); + f.radius = m.radius * (m.start_scale + (1 - m.start_scale) * p); + f.dim = clamp01(m.dim) * p * fade; + f.ring = p * fade; + f.done = t >= fade_from + std::max(0.0, m.fade); + return f; +} +} diff --git a/plugins/find-pointer/tests/spotlight-test.cpp b/plugins/find-pointer/tests/spotlight-test.cpp new file mode 100644 index 0000000..2e1f145 --- /dev/null +++ b/plugins/find-pointer/tests/spotlight-test.cpp @@ -0,0 +1,99 @@ +// Unit tests for the find-pointer double-tap detector and spotlight timeline. +#include "../src/spotlight.hpp" +#include +#include + +using namespace find_pointer; + +static bool near(double a, double b) { return std::abs(a - b) < 1e-6; } + +// Press and release Ctrl at the given times; true if the last release fires. +static bool taps(tap_detector_t& d, std::initializer_list times, bool alone = true) +{ + bool fired = false, pressed = true; + for (double t : times) + { + fired = d.ctrl(pressed, t, alone); + pressed = !pressed; + } + + return fired; +} + +int main() +{ + // A quick double tap fires on the second release, only once. + tap_detector_t d; + assert(!d.ctrl(true, 10.00, true) && !d.ctrl(false, 10.08, true)); + assert(!d.ctrl(true, 10.20, true)); + assert(d.ctrl(false, 10.28, true)); + assert(d.state == tap_detector_t::IDLE && !d.ctrl(false, 10.30, true)); + // A single tap does nothing. + d = {}; + assert(!taps(d, {1.0, 1.1})); + // Too slow: the second press more than 400 ms after the first. + d = {}; + assert(!taps(d, {1.0, 1.1, 1.45, 1.5})); + // ... but that second tap can start a new double tap. + assert(!d.ctrl(true, 1.7, true) && d.ctrl(false, 1.75, true)); + // A long hold is not a tap, either first or second. + d = {}; + assert(!taps(d, {1.0, 1.5, 1.6, 1.65})); + d = {}; + assert(!taps(d, {1.0, 1.05, 1.2, 1.7})); + // Another key, button or scroll in between starts over (Ctrl+C, Ctrl-click). + d = {}; + d.ctrl(true, 1.0, true); d.ctrl(false, 1.05, true); + d.other(); + assert(!d.ctrl(true, 1.15, true) && !d.ctrl(false, 1.2, true)); + d = {}; + d.ctrl(true, 1.0, true); d.other(); d.ctrl(false, 1.05, true); + assert(!d.ctrl(true, 1.15, true) && !d.ctrl(false, 1.2, true)); + d = {}; + d.ctrl(true, 1.0, true); d.ctrl(false, 1.05, true); d.ctrl(true, 1.15, true); + d.other(); + assert(!d.ctrl(false, 1.2, true)); + // Ctrl pressed while another key is held is a chord, not a tap. + d = {}; + assert(!taps(d, {1.0, 1.05, 1.15, 1.2}, false)); + d = {}; + d.ctrl(true, 1.0, true); d.ctrl(false, 1.05, true); + assert(!d.ctrl(true, 1.15, false) && !d.ctrl(false, 1.2, true)); + // A duplicate press (both Ctrl keys, or a forwarded event) is one press. + d = {}; + d.ctrl(true, 1.0, true); d.ctrl(true, 1.01, true); d.ctrl(false, 1.05, true); d.ctrl(false, 1.06, true); + assert(!d.ctrl(true, 1.15, true) && d.ctrl(false, 1.2, true)); + + // Timeline: large circle shrinking to the radius while the dim grows. + timing_t m; + double end = fade_start(m); + assert(near(end, 1.25)); + auto f = frame_at(0, end, m); + assert(near(f.radius, 630) && near(f.dim, 0) && near(f.ring, 0) && !f.done); + f = frame_at(0.125, end, m); + assert(f.radius < 630 && f.radius > 70 && f.dim > 0.25 && f.dim < 0.5); + f = frame_at(0.25, end, m); + assert(near(f.radius, 70) && near(f.dim, 0.5) && near(f.ring, 1)); + f = frame_at(1.0, end, m); + assert(near(f.radius, 70) && near(f.dim, 0.5) && !f.done); + // Fade out over 200 ms, keeping the radius. + f = frame_at(1.35, end, m); + assert(near(f.radius, 70) && near(f.dim, 0.25) && near(f.ring, 0.5) && !f.done); + f = frame_at(1.46, end, m); + assert(near(f.dim, 0) && f.done); + // Ended early during the shrink: fades from where it was. + auto at = frame_at(0.1, end, m); + f = frame_at(0.1, 0.1, m); + assert(near(f.radius, at.radius) && near(f.dim, at.dim)); + f = frame_at(0.2, 0.1, m); + assert(near(f.radius, at.radius) && near(f.dim, at.dim * 0.5)); + assert(frame_at(0.31, 0.1, m).done && !frame_at(0.29, 0.1, m).done); + // Options: dim is clamped; zero durations do not divide by zero. + m.dim = 3; m.shrink = 0; m.fade = 0; m.hold = 0; + f = frame_at(0, fade_start(m), m); + assert(near(f.dim, 0) || f.done); + f = frame_at(-0.0, 0.5, m); + assert(near(f.dim, 1) && near(f.radius, m.radius)); + std::puts("find-pointer spotlight: all tests passed"); + return 0; +}