Skip to content

Add combo-driven effects: keyframe-animated shape layers following the mouse - #103

Open
masasj wants to merge 42 commits into
petoncle:mainfrom
masasj:effects-94
Open

masasj wants to merge 42 commits into
petoncle:mainfrom
masasj:effects-94

Conversation

@masasj

@masasj masasj commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

What

Combo-driven effects: short, named animations drawn around the mouse, started and stopped by combos (start-effect.<name> / stop-effect.<name>). An effect is a stack of simple shape layers (rect, polygon, star, line, cross, arc, text, a path of your own points, plus the aliases dot, circle, triangle) animated over one cycle with keyframes, in an area that follows the mouse or stays where the effect started.

normal-mode.effect.click-ripple.duration-millis=250
normal-mode.effect.click-ripple.follow-mouse=false
normal-mode.effect.click-ripple.layer1-shape=circle
normal-mode.effect.click-ripple.layer1-thickness=2
normal-mode.effect.click-ripple.layer1-keyframes=0 size=10 opacity=0.8 | 100 size=36 opacity=0
normal-mode.start-effect.click-ripple=-leftbutton   # your click key (alias)
effect-demo.mp4

The idea was raised with the ripple-on-click use case; the indicator cannot do it (it is one persistent shape), and a mode's to + timeout chain is a heavy way to fake an animation.

Design

The model fits in one sentence: an effect is layers of shapes; every layer value is written once, and any number or color among them can be animated with keyframes. To keep the surface small and easy to extend:

  • EffectProperty is a single table (key, kind, default, range) that drives parsing, defaults, keyframe interpolation and the docs. Adding an animatable property is one enum line plus one use in the renderer; EffectPropertyDocsTest fails until the key is documented.
  • The engine resolves, the renderer draws. EffectManager turns time into fully-resolved EffectFrames (no Qt, unit-testable); EffectRenderer is a dumb draw loop over resolved numbers.
  • Existing code is reused rather than duplicated: the indicator's TransparentWindow + child widget pattern (on macOS; on Windows the overlay draws the same frames into a layered window of its own, updated in place with UpdateLayeredWindow and no destination position, because a Qt widget flush passes the position along and Windows re-picks the cursor on every frame: an animation over a window edge flickered between the resize cursor and the arrow), its polygonPath (same edge-count convention), its fill-angle convention for arcs (0 = 12 o'clock, clockwise), hint gradients' OkLab color mixing (HintGradientColor.mix), and Easing (same values as zoom.animation-easing).

Effect settings

duration-millis, repeat (once / loop / a count), direction (forward / alternate), easing, area (48 or 64x32), follow-mouse, enabled (like indicator.enabled: switch an effect off without removing it), exclude-from-capture (keeps the effect out of screenshots and recordings while it runs, the way the zoom window is: for a camera effect on the screenshot key itself, started with _{leftwin} #printscreen so the key still reaches Windows).

Layer settings (hold for the layer's life)

shape, filled, points (the corners of a path layer: a diamond, a parallelogram, an arrow, a check mark are one line each), speed (layer timeline relative to the cycle), delay (staggers layers: a sonar is three delayed rings with the same keyframes); for text layers, text, font-name, font-weight, font-italic, text-align. In text, {key} is the key that completed the start-effect combo and {keys} the keys that (re)started the effect while it showed ({move} / {moves} with the combo's + or -): a keycast, to check what you typed or to show the keys in a recording (the combo's key event was already handed to every command; StartEffect now carries it). max-width wraps a text at its spaces; keep-on-screen (default) moves a text inwards at a screen edge so a label stays readable there.

Layer values (each settable once and animatable by keyframes)

x, y, size (or area), scale, rotation about a pivot (a pivot at the area center makes an orbit), rotation-x / rotation-y (3D-projected tilt and card flips), color (mixed in OkLab), opacity, thickness, dash (<on>,<off> lengths) with an animatable dash-offset (a travelling marquee), corner-radius, edge-count (polygon), arc-start / arc-length (arc, pie when filled), font-size / background-color / outline-color / outline-thickness / padding (text), plus show / hide and a per-segment easing=. Keyframe positions are a percent of the cycle or a time (150ms), mixable.

Behavior

  • A one-shot effect finishes even if the mode changes; a looping effect is stopped by a mode change (its stop-effect combo may not exist in the new mode). A mutation of the current mode is not a mode change: loops keep running through it.
  • Restarting a running effect restarts its cycle and re-anchors it (its key history is kept).
  • Performance: resolving 10 effects x 4 layers with 4 keyframes each costs ~90us per tick on the engine side; the overlay is only redrawn when a resolved value (or the mouse, for a following effect) changed, a stall never advances an effect by more than 100ms per tick, a failing effect is dropped with an error instead of taking the loop down, and text fonts are cached. A following effect is moved with the mouse between main-loop iterations (the way the indicator window is), in a window that keeps a stable size, so it trails the cursor no more than the indicator does.
  • Effect properties do not support mutation branches (the | separator belongs to keyframes).
  • Every bad value is a configuration error that names the key, the value and the expected form (range, true/false, #RRGGBB, the easing names), and for a keyframe token, which keyframe of which layer it was in; nothing surfaces as a NumberFormatException.

Files

  • New: EffectProperty, EffectConfiguration, EffectLayer, EffectKeyframe, EffectShape, EffectText, EffectFrame, EffectManager, renderer/EffectRenderer, platform/windows/WindowsEffectWindow.
  • Touched: ConfigurationParser (effect block), Mode (effects map), Overlay (setEffects / hideEffects), Mousemaster (wiring, mouse position), HintGradientColor (mix extracted), IndicatorRenderer (IndicatorWidget package-private for polygonPath), native-image configs.
  • Tests: EffectTest (parsing, interpolation, loop wrap, speed, easing, hide, axis rotations, dashes, ms keyframes, text layers, error messages, range edges), EffectManagerTest (restart re-anchors, hide once, mode change vs loops/one-shots, resolution depends only on elapsed time), EffectPropertyDocsTest (every property documented), EffectRecipesDocsTest (every recipe in the reference parses).
  • Docs: "Effect properties" section in configuration-reference.md, written as a walk-through (first effect line by line, loops, layers, how keyframes work) followed by reference tables grouped by purpose, eleven recipes each naming its trick (a custom-shape one and a keycast among them), the rules, and a "nothing appears?" checklist. Every recipe is parsed by EffectRecipesDocsTest.

Not in this PR

  • Gradients / images as layers.
  • Effects triggered by state rather than combos (e.g. on every click regardless of mode).
  • Animating the indicator itself with the same keyframes (possible follow-up: the table could drive it).

Testing

effects-camera-record.mp4
  • Windows 11, JIS keyboard, on 2fb37e7 (the branch was rebased onto it and re-checked on the machine), with the attached demo config (mousemaster-demo.properties, run with --configuration-file), where every effect answers one real event: F2 switches idle/normal with an "IDLE > NORMAL" / "NORMAL > IDLE" tag, space clicks with an anchored ripple, m / u wheel with arrows on a track, d drags with a crane claw seen from the side (path fingers, star scatter on the release; the claw comes down on a 250ms hold, or closes on the spot when a move key is pressed with d held, and a plain click shows nothing), Win+Shift+S / Win+PrtScn shows a camera that stays out of the screenshot (exclude-from-capture), and F3 enters a record-mode that also shows every key pressed and released ({moves}). Five one-trick samples stay on F7-F11 in idle-mode. Together they use every property.
    mousemaster-demo.properties.txt
    (The recording GIF was made with the camera's exclude-from-capture switched off, since a screen recorder is a capture too and would not see it otherwise.)
  • mvnw clean package (JUnit) green on the fork's nightly workflow, on Windows and on both macOS jobs.
  • macOS: compiled, unit-tested and native-image-linked in CI only; I have no Mac, so the overlay there (MacosOverlay.setEffects, 29 lines that mirror the indicator's window setup) is untested on a real machine. Nothing happens on macOS unless a config uses start-effect.

@petoncle

Copy link
Copy Markdown
Owner

This looks very impressive 👀
What are your thoughts on the interaction and the feature overlap between this and the current indicator? Do you think there is a way to make everything consistent and reduce the overlap to a minimum (we don't want two ways to achieve the same thing)?

@masasj

masasj commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Thank you - and it is the right question to ask, because on screen the two really do look
like the same thing.

The way I think about it: the indicator answers while, an effect answers when. The
indicator is a function of state - the current mode, plus a mutation branch per mouse or
keyboard state - and it always shows the current value of that function; it has no notion
of a moment. An effect is a function of an event - a combo completed - and it has a
duration, after which it is gone; it has no notion of a state. That is why effect
properties deliberately refuse mutation branches (there, | belongs to keyframes):
"green while the button is down" is something the indicator already says, and I did not
want a second way to say it.

I did start from the other end, by trying to give the indicator more properties, and the
shape of the thing is what sent me here instead: the indicator is one polygon with one
label, and the only time-varying behaviour it has is a fade on show/hide and a single
eased transition between two configurations. A ring that expands and vanishes, three
rings staggered by 40ms, a claw that comes down and opens again - none of those are
reachable by adding properties to a single persistent shape, whatever triggers it. So I
do not think this is a second way to do the same thing; it is a way to do things the
indicator structurally cannot.

What the two genuinely share is the drawing vocabulary - a polygon with an edge count, a
size, a colour, an opacity, outlines, a text with a font - and there I tried to reuse
rather than invent: the same edge-count meaning and the same polygonPath, the same
OkLab mixing as hint gradients, the same easing names as zoom.animation-easing,
enabled in the sense of indicator.enabled.

There is one place where you can honestly do the same thing twice: an effect with
repeat=loop and follow-mouse=true, started when a mode is entered, is a second
indicator. I would rather not forbid that shape, since it is also what a "recording" badge
or a drag affordance is made of, but I have pushed a short "Indicator or effect?" section
to the reference that says plainly which question each one answers, and that mode state
belongs to the indicator.

If you want the overlap gone rather than documented, the honest answer is to make the
indicator be an effect: describe it with the same property table and the same layer
model, keep every indicator.* name exactly as it is today, and let
transition-animation-* become what it already is, a two-keyframe animation with an
easing. This PR is built with that in mind - EffectProperty is a single table that
drives parsing, defaults and interpolation, and the renderer is a dumb loop over resolved
numbers - so the indicator could be resolved through the same path. It touches existing
configurations, so I think it belongs in its own PR rather than bolted onto this one, and
I am happy to write it after this lands.

And if, having read that, you still see this as overlap you do not want in the project,
say so and I will drop it without hard feelings - it is your call to make, and I would
rather you make it now than carry a feature you are not sure about.

@petoncle

Copy link
Copy Markdown
Owner

If you want the overlap gone rather than documented, the honest answer is to make the
indicator be an effect

I think that could make sense. And if we did that maybe effects would just be called "indicator that can be animated (with key frames) if needed".

I'm currently looking at the demo properties file you shared and then I'll also look at the code. I will just share my comments as I progress.

  1. I find unfortunate that effect properties can't work with property mutations, because most existing properties are working with mutations. I think it could be useful, especially if we're merging indicator and effects into the same feature.
  2. I tried to keep the number of property levels to a maximum of 3 (x.y.z=c). For example, recently I added named position histories:
    browser-position-history.max-size=4
    normal-mode.browser-position-history.save-position=_{browserapp} +f2
    As you can see, I kind of followed how modes are suffixed with -mode and went for <named-mode>.<named-position-history>.<command> instead of <named-mode>.position-history.<name>.command. Maybe effects should do the same?
  3. I see some files in the PR that seem to have been committed accidentally? E.g. javac.20260910_063520.args. Not sure what those are?
  4. I think you already saw the work I've done on gradient colors:
    hint-mode.hint.box-color=across-screen per-pixel left-to-right #FF6B00 #FF006E
    I'm still playing around with it, using it for my ui hints. The syntax is a little complicated for my taste (it's a sequence of "magic" keywords like per-pixel followed by a sequence of hex codes) but I haven't found a way to make it simpler (yet). Not sure if you're using any of it, do you have any thoughts about how it was implemented? I feel like it's close to how gradients are defined in CSS, but with a different syntax. In fact, the comparison with CSS is interesting because here with effects, it looks like we also are tending towards what CSS can do.
  5. Related to point 1. and 4., the keyframe syntax is doing a lot of work. Maybe we could split it to one keyframe per property, like:
    (...).layer1-keyframe0=y=-95 opacity=0
    (...).layer1-keyframe10=opacity=1
    It's also unfortunate that we have = in the property value.
    I haven't looked at the code yet but I was wondering how it works when an effect field (like y and opacity) is defined both in a keyframe and as standalone (e.g. (...).layer1-opacity=0.5). It sounds like there's two ways to do the same thing: layer1-opacity=0.5 is equivalent to layer1-keyframes=0 opacity=0.5? Also, as you already pointed out, the use of | is conflicting with the mutation syntax.

I'll stop here for now, I think my comment is long enough 😊
Thank you again for your hard work on this.

masasj and others added 26 commits September 13, 2026 10:42
…e mouse

A mode can define named effects - stacks of simple shape layers (dot, circle,
square, triangle, line, cross) animated over one cycle by keyframes (size,
rotation, opacity, position, color, show/hide) in an area centered on the
mouse. start-effect.<name> and stop-effect.<name> combos start and stop them,
so an effect can play on a click, loop exactly while a key is held, or flash
on a release. EffectManager resolves each frame (testable without a UI); the
Qt EffectRenderer only draws.
rotation-x / rotation-y tilt a layer with Qt's 3D-projected axis rotation
(the sign picks the spin direction, so 0 -> -360 flips backward);
layer<n>-speed runs one layer's timeline faster or slower than the effect's
cycle (2 = twice per cycle); and an easing= token on a keyframe shapes the
acceleration of the segment that ends at it. Keyframe sizes may now reach 0,
since shrinking a layer to nothing is a legitimate way to end it.
…ebug logs

The bare-QWidget effect window drew nothing on a real Windows 11 machine even
though the start-effect combo fired (no error logged). The indicator and hint
meshes render through a TransparentWindow with a child widget, so the effects
now use the same proven pattern, positioned with moveAndResizeInPixels (which
also fixes macOS point/pixel handling). DEBUG logs on the first frames and on
hide record the window geometry, visibility and paint count, so a machine
where it still fails can report where the pipeline stops.
On a real Windows 11 machine, the first effect frame froze the main loop hard
enough that the console control handler had to kill the process. Creating the
effect window lazily on that first frame dispatches native messages mid-loop,
which can re-enter the low-level keyboard hook; the indicator avoids exactly
this by creating its window at pre-warm, so the effect window now does the
same (lazy creation remains as a fallback for configs loaded without a
pre-warm). More step-level debug logs pin down the first frame's progress.
…uilt

The native build printed 'vtable setup failed:
EffectRenderer.EffectWidget::paintEvent' at startup: QtJambi binds overridden
Qt virtuals through JNI, and a QWidget subclass missing from
reflect-config.json / jni-config.json silently loses its paintEvent in the
GraalVM native image - the effect window existed but never painted. Register
EffectWidget the same way as GridWidget and the other widget subclasses.
Mode property mutation rebuilds the Mode record reflectively
(ModePropertyMutator.createWithField), so the record's new effects component
needs its accessor and the new canonical constructor registered in the
native-image configs. Without them, any config using mutation branches (e.g.
state-dependent indicator colors) crashed with
MissingReflectionRegistrationError on the first virtual-key refresh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…olygons, delay, repeat, color mixing

Every animatable layer value now comes from the EffectProperty table (key,
kind, default, range), which the parser, the keyframe resolver and the docs
test all read: adding a property is one enum line plus its use in the
renderer. New capabilities: rotation about a pivot (orbits), scale, animated
thickness, corner-radius, polygon (indicator's edge-count convention), arc
(indicator's fill-angle convention, pie when filled), per-layer delay, repeat
counts and alternate direction, follow-mouse=false anchoring (EffectManager
listens to mouse moves), and colors mixed in OkLab through the hint gradient
code. The effect window is the union of the running effects' areas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…h-offset

dash sets the dash and gap lengths in logical pixels (solid by default) for
outlines, lines, crosses and arcs; dash-offset moves the pattern along the
outline, so animating it over one pattern period makes the dashes travel (a
marquee). Both come from the EffectProperty table like every other value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A keyframe position may be a time (150ms) as well as a percent of the cycle,
and the two can be mixed. Times are converted once the effect's duration is
known (duration-millis may be written after the keyframes), which is also
where the increasing order is now checked; a time beyond the duration is an
error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d and outline

shape=text draws layer<n>-text in font-name / font-weight / font-italic,
aligned on the layer's x by text-align, through the hint font machinery (same
family fallback and antialiasing as hints and the indicator label). Its
animatable values come from the table: font-size, color, opacity, a
background-color box (rounded by corner-radius, grown by padding) and an
outline-color drawn thickness wide. Text settings on a non-text layer, and a
text layer without text, are configuration errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
arc-sweep becomes arc-length (the arc's length in degrees, no jargon); a text
outline's width is outline-thickness, pairing with outline-color like the
indicator's; square becomes rect, since size=WxH already made it one (square
is still accepted). dot, circle and triangle stay as aliases of polygon /
rect shorthands and are documented as such, so the shape list reads as six
shapes plus three shorthands rather than nine unrelated names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and the expected form

Effect values were parsed with the raw Double/Integer/Boolean parsers, so a
typo surfaced as "For input string: abc" and "yes" silently meant false. The
effect section now parses through helpers that say which key, which value,
and what was expected (the range, true or false, a whole number of
milliseconds, the #RRGGBB form, the easing names); compound values (size,
area, pivot, dash) describe their own form; and a bad keyframe token adds
which keyframe of which layer it was in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t example

Every EffectProperty carries a one-line meaning and an example, and the
effect and layer settings have the same in the parser, so a message reads
"Invalid edge-count value 1001: edge-count is the number of sides of a
polygon: 3 triangle, 4 square, 6 hexagon, 100 or more looks like a circle;
expected a number between 3 and 1000, for example layer1-edge-count=6" --
enough to fix the line without opening the reference. Structural errors
(no shape, gaps in layer numbers, a text layer without text, an undefined
effect name, keyframes out of order or past the duration) say what to add
or change rather than what rule was broken.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ine's boundaries

A keyframe with a position and nothing after it, and a trailing | (which
String.split used to drop silently), are now errors that say what a keyframe
needs. Tests cover every range's exact edges and one step outside, and the
engine at its seams: a keyframe at 0% over the base, a single keyframe (base,
interpolation, hold), the timeline end at speed 2, alternate at the cycle
boundary, delay equal to the elapsed time, duration-millis=0, and a one-shot
ending on its final value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…parsing

EffectManagerTest drives the manager against a stub overlay: restarting an
effect restarts its cycle and re-anchors it, the overlay is hidden exactly
once when the last effect ends and not touched while idle, a mode change
stops loops but lets one-shots finish, stopping a stopped effect and
starting an unknown name are harmless, and one long step resolves the same
frame as many short ones. EffectRecipesDocsTest parses every effect example
in the configuration reference, so the docs cannot drift from the parser
(the click-ripple recipe now declares the leftbutton alias it uses).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ModeListeners hear about mutations of the current mode (a property branch
such as _{isleftmousepressing} -> ... flipping) as well as mode switches.
EffectManager treated both as a switch and stopped every loop, so in a
configuration with mutation branches a loop started by a wheel key died on
the very press that started it. Loops are now stopped only when the mode
name changes; the mutated mode still replaces the current one so start-effect
sees its definitions. Found by running effects against the real configuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…one effect take the loop down

The engine side is cheap (10 effects x 4 layers x 4 keyframes: ~90us per
tick), so the pass goes after what the overlay is asked to do: setEffects is
skipped when the resolved frames equal the last ones and no following effect
saw the mouse move, so a hold keyframe, a delay or a hidden layer costs no
repaint. A tick never advances an effect by more than 100ms: after a
main-loop stall a one-shot is still on screen instead of gone and a loop
slows instead of jumping. A player that throws is dropped with an error and
the paint loop skips a layer it cannot draw (logged once), so an effect can
fail without stopping mousemaster. Text fonts are cached per family, size,
weight and style (bounded, since an animated size makes many), and each
layer resolves only the properties its keyframes mention.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The section now opens with the three things that describe an effect (when it
plays, what it draws, how it moves), builds a first effect line by line, adds
loops and layers, and explains keyframes with a worked example read back in
words before the reference tables, which are grouped by purpose (where and
how big, rotation, look, shape-specific, text). Nine recipes each name the
trick they use, the rules are collected in one place, and a "nothing
appears?" checklist closes it. Every example block is parsed by
EffectRecipesDocsTest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Like indicator.enabled: enabled=false keeps the definition (its combos still
load and its lines stay for later) but start-effect does nothing, which is
what one wants while trying effects out or tracking one down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Windows checkout gives docs/configuration-reference.md CRLF line endings,
and the recipe test's pattern expected a bare newline after the code fence,
so it found no recipes at all on the Windows CI job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n the native image

QtJambi analyzes a QWidget subclass reflectively when it is first
instantiated (fields and methods, to build its meta-object). In the GraalVM
native image that analysis hung at "new EffectWidget" once the widget carried
the frame state (a List<double[]> field and an anonymous map subclass): the
process sat in the pre-warm with hooks never installed. The widget is now an
inner class with nothing but paintEvent, like the indicator's, and the frames,
centers, font cache and paint counter live in the enclosing renderer, which
Qt never looks at. Found with a stderr trace after a bisect showed the base
was innocent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ursor

The system draws the cursor, indicator included, above every window, so the
"nothing appears?" list now says to leave the cursor clear: rings wider than
the indicator, or shapes offset from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…efore starting it

A selection key that both picks a hint and switches mode runs its start-effect
after the switch: the commands wait for the hint's mouse move while the
SwitchMode runs at once. The effect was then looked up in the new mode and,
not found there, ignored with a warning. EffectManager now remembers the mode
before the last switch and falls back to it, so the combo's own effect plays,
where the mouse landed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…w that stops growing

Two things made an effect trail the cursor. The effect window's rectangle was
the union of every area seen while showing, so a following effect dragged an
ever-growing window behind it, resized and repainted whole every frame. The
window now keeps its largest size (like the indicator's) and is placed over
the current frames. And the window was only moved once per main-loop
iteration, with the tick's frames: the platform now moves a following effect
as soon as the mouse moves, inside its sleep, the way it repositions the
indicator window, and only repaints when an anchored frame shares the window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
masasj and others added 16 commits September 13, 2026 10:42
A text layer's text may contain {key}, replaced by the key that completed the
start-effect combo, and {keys}, the keys that started or restarted the effect
while it was running: restarting keeps the history, so a burst of typing
reads as one line beside the mouse. The combo's key was already handed to
every command; StartEffect now passes it on. Recipe and tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ove}/{moves} placeholders

A label beside the mouse was cut at a screen edge: a text layer is now moved
inwards by what would stick out (keep-on-screen, on by default; shapes are
left alone, a ripple at the edge is cut like anything drawn there), and the
effect window grows to cover it. A text that varies in length (a keycast)
wraps at its spaces past max-width. {move} and {moves} are {key} and {keys}
with the combo's + or -, so a press and its release both show; the command
runner is handed the key event rather than the key alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tests that stub the command runner override run(Command, Key); routing
the combo watcher through a new run(Command, KeyEvent) bypassed them. The
key event overload now records whether the event is a press and delegates
to run(Command, Key), which StartEffect reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The area clip set per frame cut a text layer that keep-on-screen had moved
outside the area at a screen edge; the clip is lifted for that layer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A filled star is what a small "done" or "placed" mark wants to be, and two
crossed lines only approximate it. Drawn like the odd polygons (a point at
the top), with the inner vertices at 0.4 of the radius.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
layer<n>-points lists the corners in pixels from the layer's center, so a
diamond, a parallelogram, an arrow or a check mark are one line each; scale,
rotation, filled, thickness and the colors apply as for any shape, the size
is not used. A recipe shows a check mark over a spinning diamond.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pture

A camera effect on the screenshot key (started with #printscreen, so the
key still reaches Windows) must not end up in the screenshot. While an
effect with exclude-from-capture=true runs, the effect window gets
WDA_EXCLUDEFROMCAPTURE, as the zoom window always has; otherwise effects
are captured like anything on screen, so a recording of them still works.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Effects were centered on the mouse position, the arrow's tip at its top
left, while the indicator sits on the cursor's visual center some pixels
down and right: an effect above the cursor looked farther than the same
one below it, and one drawn just under the tip overlapped the indicator.
The Windows overlay now hands the renderer the same offset the indicator
uses, applied to following and anchored effects alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every frame of an animation re-placed the native window even when nothing
had moved, and a SetWindowPos under the mouse makes Windows re-pick the
cursor: over a window edge the resize cursor flickered against the arrow
for as long as the effect played.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Skipping the window move was not enough: a Qt widget flush hands the
window's position to UpdateLayeredWindow, which Windows treats as a move
and re-picks the cursor for, so an animation over a window edge still
flickered between the resize cursor and the arrow every frame. The Windows
overlay now owns a plain layered popup (WindowsEffectWindow) and the
renderer paints each frame into a QImage handed to it; the window is
updated with UpdateLayeredWindow and no destination point, and only moved
when the effects move. macOS keeps the Qt window. The capture exclusion
of the effect window combines the zoom's rule with the effects' own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
QtJambi's QImage has bits(), not constBits(); the rows are copied one by
one since Qt pads scanlines to four bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
That SetWindowPos(HWND_TOPMOST), issued every 200ms while nothing else
animates, made DWM recompose the layered effect window (a frame without
it) and Windows re-pick the cursor under it: over a window edge the
resize cursor and the arrow alternated five times a second for as long
as an effect played. The window is created topmost and, like the zoom
window during a zoom, is not re-ordered while it shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The offset from the mouse position to the cursor's visual center depends
on the cursor shown. Read every frame over a window edge, it fed a loop:
the resize cursor gave another offset, the effect window moved, Windows
re-picked the cursor under it, the arrow came back with its own offset,
the window moved again. The cursor flickered and the effect jittered for
as long as the effect played. The offset is now read when the effects
appear and kept until they are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@masasj

masasj commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for reading it so closely.

The stray files

They were mine: javac.*.args are argument files a javac run left behind, and a careless
git add swept in five of them. They are gone from every commit now (force-pushed), and
the branch touches nothing outside src/ and docs/.

Naming

I had not connected the -position-history convention to this, and it fits.
Today, and what I would change it to:

# today
normal-mode.effect.click-ripple.duration-millis=250
normal-mode.effect.click-ripple.layer1-shape=circle
normal-mode.effect.click-ripple.layer1-keyframes=0 size=10 opacity=0.8 | 100 size=36 opacity=0
normal-mode.start-effect.click-ripple=-leftbutton

# proposed
normal-mode.click-ripple-effect.duration-millis=250
normal-mode.click-ripple-effect.layer1-shape=circle
normal-mode.click-ripple-effect.layer1-size=0% 10; 100% 36
normal-mode.click-ripple-effect.layer1-opacity=0% 0.8; 100% 0
normal-mode.click-ripple-effect.start=-leftbutton

Three levels everywhere, and start / stop read like save-position / clear do.

I would keep the definition inside a mode rather than making it global like
browser-position-history.max-size, for two reasons: a mode is what gives a property its
mutations, and slow-mode=normal-mode then inherits the effects too. Say the word if you
would rather have them global.

Your points 1, 5 and 6 are the same problem

Mutations are not missing by design. They are missing because | was already taken by the
keyframe list, and that was the wrong trade. The second block above makes the trade the
other way: the timeline goes on the property itself, and | goes back to mutations.

  • Your 6 (two ways to set a value): gone. layer1-opacity=0.5 and
    layer1-opacity=0% 0.8; 100% 0 are the same key. A property holds a constant or a
    timeline.
  • Your 5 (the keyframe line does too much, and = inside a value): gone. No = in a
    value, and no line carrying every property of one moment.
  • Your 1 (mutations): back, like any other property:
    layer1-color=#FF0000 | _{ismousepressing} -> #00FF00.

A bonus: size and opacity no longer have to share keyframe positions.

The separator is the one thing I am unsure about. ; is free, but it is also a key name.
, is taken inside dash=2,4. I would use ; unless you prefer something else. An easing
would sit in the segment: 0% 10; 60% 30 smootherstep; 100% 36.

Your layerN-keyframe0= shape frees | too, but it keeps = inside the value and it
keeps one line per moment rather than per property, so I prefer the above. It is your
syntax though, so say which you want.

Nothing here breaks an existing configuration

Effects have never been in a release, so renaming every effect property costs no one
anything. This is the only moment when the syntax is free, which is why I would rather do
it now than after it ships.

The merge is the opposite: every indicator.* name and behaviour has to keep working
exactly as it does today. If it cannot be done without touching them, it is not worth
doing.

Gradients

I do not use any of it yet - effect colors are plain hex. Having read the reference, I
would not apologise for that syntax: the three axes (direction, what one sweep covers, what
shares one color) are orthogonal and can be written in any order, which is what makes it
learnable. CSS is not simpler, only more familiar; the one thing it has that you do not is
explicit stops (#FF0000 20%).

And if the indicator and effects end up on one path, effect colors go through Color /
GradientColor like everything else and inherit all of it, with across-area meaning the
effect's own area. One more reason to merge rather than keep two vocabularies.

Next

The naming and the timeline change are a parser, docs, tests and demo-config rewrite, and I
would rather not do it twice. Tell me whether the shape above is what you want, and whether
effects should be mode-scoped or global, and I will rewrite the PR around it.

No hurry - keep the comments coming as you read, and I will fold them all in at once.

@petoncle

petoncle commented Sep 13, 2026

Copy link
Copy Markdown
Owner

I think I need some time to think about this, I wouldn't want you to make changes and then change our mind later.

I like this:

 0% 10; 60% 30 smootherstep; 100% 36

Like you, I'm not sure about the syntax but I like the idea.

To be honest, I would like to leverage this work to replace my current indicator animation that relies on virtual keys. Virtual keys seem like a bad substitute for keyframes (too complex). I think that the keyframe approach would simplify it, and also make it more flexible. I don't like some of the properties that were recently introduced, like transition-animation-switch-at:

_indicator-mode.indicator.transition-animation-switch-at=start

I also don't like indicator.inner-outline and outer-outline, which were introduced because I wanted to have 2 outlines. Now, I think having 2 outlines would just be implemented as 2 layers; and we can have a third outline if we want to.

So this is what I'm wondering: is there anything missing in the current effect implementation that would make a migration of this animation not possible? Would it be possible to add the missing parts?

2026-09-13-Ql8Y3RWDij.mp4

Off the top of my head, the things that could be challenging are:

  1. Ring (currently implemented as an indicator "outline") with animated thickness (starts as a full disk, ends as a thin subpixel ring).
  2. When holding a click, a shadow is drawn, but only on the outside of the indicator (not on the inside: even if the inside is translucent, we don't see the shadow behind it). I don't know if this could be generalized cleanly. It's a problem about pixel composition for when you have to draw several translucent pixels on top of each other (related to: CompositionMode, redrawSourceOverShadow()).
  3. The cursor is hidden during the "burst" part of the animation (and visible for the rest of animation).
  4. (Ring with gradient color. Probably not that difficult.)
  5. Holding the click or moving the mouse accelerates the part of the animation where it's fading out the thin ring (_indicator-mode.macro.cutanimation).

For reference, here is a cleaned-up Claude description of the animation:

_indicator-mode.macro.clickanimation in configuration/author.properties.

Resting: 26 px disk, fill 0.2, 0.5 px ring, red-gradient.

Timeline

t what happens over
0 solid 26 px disk: size 1, thickness 13, fill opacity 0. Color switches to the button's ramp, cursor hides instant
+1 tick the disk opens into a ring: size → 78, thickness → 0 500 ms, exponential-out-10
250 ms inner-outline-opacity → 0 250 ms, 0.7
500 ms back to resting, cursor returns on the same frame 80 ms, 0.7

Three virtual keys drive it: mouseburst marks the disk and is released a tick later,
mouseanimation spans the whole thing, mousefade starts the opacity ramp.

The outline draws outward from the fill radius, so thickness sets the opening disk.
switch-at=start is why the color is the button's from the first frame, and red from the first
frame of the return.

The expansion is one continuous decay — every 50 ms the remaining distance halves — running the
full 500 ms. The stroke goes sub-pixel around 245 ms, and from there antialiasing renders it as a
progressively fainter circle. That does most of the visible fading; the opacity ramp only finishes
it off.

Interrupts

If a button is still down at the 250 ms mark, or the mouse is moving then, cutanimation ends the
animation 40 ms later:

_{ismousepressing | ismoving} +mousefade -> wait-40 ~mouseanimation ~mousefade …

Another click restarts from t=0.

Over hints

_hint-indicator-mode, same clock:

  • ring opens to 180 from a 60 px disk (thickness 30) — its
    stroke being centered on the path so radius 15 stroked 30 covers 0 to 30
  • the t=500 return is instant (transition-animation-duration-millis default 0)
  • enabled=false unless a button is held, so it usually just disappears at t=500

@masasj

masasj commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Take all the time you want — I would rather wait than rewrite twice, and you thinking it
through is the reason this will be worth having.

1. Your click animation, property by property

I went through it against what is in the branch today. Three of the five are already there
or nearly there, and two need real code.

The ring with animated thickness — works today. This is the part I expected to be hard
and it is not. thickness is animatable, and the effect easing parser delegates to the same
parseEasing as everything else, so exponential-out-10 already parses in a keyframe. The
stroke is centered on the path, the same as the indicator's outline, so a radius-13 circle
stroked 26 wide is a solid disk:

layer1-shape=circle
layer1-size=0% 26; 100% 78 exponential-out-10
layer1-thickness=0% 13; 100% 0 exponential-out-10

(written in the proposed syntax). One layer, and the sub-pixel fade at the end comes from
antialiasing exactly as it does now.

The shadow drawn only outside — missing, but not from scratch. Effects have no shadow at
all today. What they would need is layer properties (shadow-color, shadow-blur-radius,
shadow-offset) and the outside-only composition. The part I would have worried about is
already in the tree: QtDropShadowEffect — Qt's drop shadow with the blur swapped for
ExpBlur, which you already made run off the GUI thread. So this is plumbing an existing
renderer into the layer loop, not writing a blur. And it lands the same way
inner-outline / outer-outline does: two layers give two shadows for free. I have not
written or measured this; it is the one item where I would want to prototype before
promising a shape.

Hiding the cursor for part of the animation — missing. An effect cannot touch the
cursor. The smallest thing that would do it is a switch on the timeline next to show /
hide. But I suspect this one dissolves in the merge rather than needing a property: the
cursor is already the indicator's business (render-as-cursor, hide-cursor), and if the
indicator is expressed as an effect, whatever expresses "hide the cursor now" belongs to
that side. I would leave it until the merge shape is settled.

Gradient colors — missing because I made it so. Effect colors go through Color.parse
and I then reject anything that is not a flat hex:

if (Color.parse(value) instanceof GradientColor color && !color.gradient())
    return color.hexColor();

That was caution, not design. Keeping the GradientColor instead of flattening it, and
handing it to the renderer, is the whole change. The one real question is interpolation: a
timeline segment between two gradients has to mix stop by stop. OkLab already gives you that
per color, so it is arithmetic rather than a new idea — but it is a decision, not a detail.

Accelerating the fade on hold / move — this is what mutations buy. With the timeline on
the property and | back for mutations, your cutanimation is one line:

layer1-speed=1 | _{ismousepressing | ismoving} -> 4

No new property. But there is an engine change behind it, and it is worth naming because it
is the same question as the merge: EffectPlayer holds
private final EffectConfiguration effect — captured when the effect starts. A mutation of
the mode does not reach a running effect. Making mutations work on a live effect means
resolving from the current (mutated) mode every tick instead of from the configuration it
started with. That is what the indicator already does, so if the two end up on one path it
is not an extra cost — it is the path.

ring with animated thickness, your easing already there
gradient ring small — stop flattening the color
cut the animation short on hold / move small — mutations, plus resolving from the live mode
shadow, outside only new code, but QtDropShadowEffect / ExpBlur already exist
hide the cursor mid-animation new, and probably belongs to the merge

Nothing there looks like a wall to me. The shadow is the only one I would want to build
before saying more about it.


2. What it already does, with no source change

While waiting I pushed the existing feature as far as it would go, to find out where it
stops. Everything below is written only with what is in this PR — the engine is untouched.

Ten effects, 1,449 layers, 618,576 keyframes, 34.0MB. It parses in 7.8s and every effect
renders. Attached: the configuration, which you can load with --configuration-file and
drive with the function keys (hold one); a sheet of all ten; and recordings of the three that
lean hardest on the model.

effects-ten

wait-G-properties.zip

Three things surprised me:

Afterimages and crowds are free. Duplicating a layer with a delay shows the same
animation a moment behind. Four copies of a walk cycle at 55ms apart is a motion trail; five
copies spread sideways is a chorus line. I did not design for this and it is probably the
most useful thing in the feature.

Stepped motion is a placement question, not a feature. Holding a value and then jumping
to the next gives popping — the robot dance is nothing but where the keyframes sit.

A line layer is a general-purpose drawing primitive. Not just a stroke: with x, y,
rotation and scale on a timeline, a few dozen short ones are a curve you can deform.

The three that go furthest:

Boxer (28 layers). Bones are line layers, the head a dot. The far arm and far leg are
drawn first at 45% opacity and the near ones over the top, so an overlap reads as depth
rather than as mush — which is what lets the guard sit at the chin, which is what gives the
punches room to travel. Hands are points measured from the head and turned into shoulder and
elbow angles by two-bone IK; I check glove-to-head, glove-to-torso, forearm-to-head,
glove-to-glove and whether the arms cross, on every frame of the loop.

boxer

Evolution (352 layers, 2.6s). A single cell becomes a fish, a tetrapod, an ape and then a
person, and direction=alternate runs the whole thing backwards for free. The creature is
defined as a set of capsules — spine, tail, neck, head, snout, limbs — combined with a smooth
minimum so the joins fillet instead of creasing. I evaluate that distance field on a grid,
take the zero contour, resample it to a fixed number of points and emit one short line per
edge, aligning each frame to the last so the points do not rotate. The result is one
continuous outline: head, body and legs with no visible seam.

evolution

Hand seals (936 layers, 2.6s). Two hands forming twelve of the zodiac seals, one every
216ms. A hand is 22 capsules — five metacarpals, the two muscle pads, the wrist, fourteen
phalanges — and the same smooth minimum gives the outline, except that adjacent fingers are
joined with a plain min instead: the web between them stays round while the valley between
them stays a valley. Around the 190-segment outline there are another 190 short lines inside
it — nails, joint creases, the knuckle ridge, the extensor tendons, the three palm creases.
Which of those show is decided by one number per hand, how far it has turned about its own
vertical axis, so a hand rolling from palm to back trades its palm lines for its tendons on
the way over.

handseal

One thing I would not have found without building it: scale multiplies width and height but
not thickness (EffectManager.resolveLayer). That is the right choice — it is what lets a
chain of short line layers at different scales keep one even stroke — but my own canvas
preview scaled the pen along with the geometry and rendered the outline as a string of beads,
and I spent a while blaming the contour. Worth a sentence in the docs.


3. Where the model got tight

I am not proposing any of the above for the repository — it is a stress test. The reason I am
posting it is that it is evidence about the model rather than about the drawings, and it
points at five things, none of which is a new shape.

Here is where the bytes are, in the heaviest effect:

layers bytes
outline (two hands, 190 short line layers each) 380 4.55MB
interior lines (nails, creases, tendons, palm lines) 370 2.43MB
fill (the same shape rebuilt out of thick lines and dots) 134 1.73MB
trails, burst, aura 52 0.37MB
total 936 9.30MB

And inside that effect's keyframe text: 9% is the time, 50% is the property name, 38% is
the value. Half of a generated effect is the word rotation= written 125,000 times.

1. Let points animate, and let one layer both fill and stroke. A deforming outline is
one polyline. Today it has to be N independent line layers, each carrying x, y,
rotation and scale — four numbers per frame where the point itself is two, plus N times
the layer overhead. If points were an animatable value, interpolated componentwise between
equal-length lists (the same rule as every other property, only vector-valued), 380 outline
layers become 2 and 4.55MB becomes ~0.66MB. filled exists but a shape is either filled or
stroked; a fill-color alongside color removes the 134-layer, 1.73MB fill I had to fake
out of thick lines and dots. That effect goes from 936 layers / 9.30MB to roughly
135 layers / 2.2MB — same picture. Cost looks like one new Kind in EffectProperty, a
componentwise lerp, and letting points through the keyframe parser; the renderer already
draws paths.

2. Groups with their own transform. layer3-children=4,5,6, or a group shape: children
written in the group's local coordinates, inheriting its x, y, rotation, scale,
opacity, visible. This is the one that changes the work rather than the size. "The hand
rotates" currently has to be baked into all 190 segments by a generator, which is why this
effect can only be written by a program; with a group a hand is a unit, and a person could
write it. It also gives depth for free — a group draws as a block, so "the near hand covers
the far one" is just "put it later in the list". Without it I had to evaluate a signed
distance field of the front hand on every frame and bake a per-segment opacity into the back
one.

3. Instancing. layer7-copy-of=layer2 with overrides, and a repeat form:

layer2-copies=4
layer2-copy-delay=55
layer2-copy-opacity=0.6

The afterimage trick above is the most useful thing in the feature and today it costs a full
copy of the text per ghost. Parse-time expansion; the renderer does not change.

4. Halve the keyframe text. Either one-letter aliases inside keyframe tokens, or a
positional track that declares its columns once:

layer5-track=x,y,rotation
layer5-keyframes=0 12,-4,0 | 3.2 13,-4,2 | 7.1 15,-3,9

Hand-written effects keep the long form; generated ones stop paying for it.

5. Two small ones. line-cap=butt|round — when a curve is built out of short line
layers, butt caps leave a hairline notch at every joint, and I overlap every segment by a
fraction of its stroke width to hide them. And a step (hold) easing — to hold a value and
then jump you currently write the value twice, at both ends of the hold.

What I am not asking for is more shapes. "Richer visuals come from stacking layers and
animating them with keyframes, not from adding shapes" is right, and everything above is
inside that sentence. The gap is not primitives — it is composition: a shape whose geometry
can move, a way to group, and a way to repeat.

If I had to pick an order: (1) first, because it is the biggest size win with the smallest
surface; (2) second, because depth stops being the author's problem; then (5), (3), (4).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants