在按压 RipplerContainer 时使用控件变色代替涟漪效果 (#7171)

This commit is contained in:
Glavo authored and GitHub committed 2026-11-03 11:01:43 +08:00
1 parent 5dd6ab5908
commit 8912ee2661
5 files changed
+485 -453

No files matched your search

@@ -3,229 +3,150 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.animation.AnimationTimer;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
/// Draws hover, focus, and static press backgrounds with transient circle waveforms.
/// All construction, property changes, and operations must occur on the JavaFX application thread.
/// The owner supplies dimensions, input states, and lifecycle notifications; this layer installs
/// no input handlers and does not own content. Waveforms share a rounded rectangular clip;
/// the layer itself retains rectangular pointer picking, including transparent corners.
/// Solid state colors use the region background; other paints use a lazily created region
/// so their intrinsic transparency is multiplied by the state opacity.
/// State opacities follow the
/// [AndroidX Material 3 state tokens](https://github.com/androidx/androidx/blob/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/StateTokens.kt).
/// Animated presses are represented by waveforms over the existing hover or focus state;
/// presses without a waveform use a single static state layer instead.
/// Draws uniform feedback behind the content of [RipplerContainer].
/// Selection adds a persistent base tint; press takes priority over focus and hover.
/// Node opacity multiplies the paint's alpha for colors, gradients, and image patterns.
/// State changes use a constant linear rate, including interrupted and reversed transitions.
/// Picking remains rectangular, including transparent corners and zero-opacity idle states.
/// All operations must occur on the JavaFX application thread.
@NotNullByDefault
public final class InteractionLayer extends Region {
/// The hover state opacity from Material 3 StateTokens v0_210.
final class InteractionLayer extends Region {
/// The hover tint opacity.
private static final double HOVER_OPACITY = 0.08;
/// The focus state opacity from Material 3 StateTokens v0_210.
private static final double FOCUS_OPACITY = 0.10;
/// The focus tint opacity.
private static final double FOCUS_OPACITY = 0.12;
/// The pressed waveform or static press opacity from Material 3 StateTokens v0_210.
private static final double PRESSED_OPACITY = 0.10;
/// The press tint opacity.
private static final double PRESSED_OPACITY = 0.12;
/// The duration of transitions within the hover opacity range, in nanoseconds.
private static final long HOVER_DURATION = (long) (Motion.SHORT2.toMillis() * 1_000_000);
/// The persistent tint added for selection, independently of pointer or focus feedback.
private static final double SELECTED_OPACITY = 0.08;
/// The duration of other state changes and ripple release, in nanoseconds.
private static final long STATE_DURATION = (long) (Motion.SHORT4.toMillis() * 1_000_000);
/// The time per unit opacity, calibrated so idle-to-hover takes 150 ms.
private static final double NANOS_PER_OPACITY = Motion.SHORT3.toMillis() * 1_000_000 / HOVER_OPACITY;
/// The duration of ripple expansion while held, in nanoseconds.
private static final long EXPANSION_DURATION = (long) (Motion.LONG1.toMillis() * 1_000_000);
/// Whether this instance schedules real pulses; deterministic tests advance it explicitly.
/// Whether the layer advances automatically.
private final boolean automaticPulses;
/// The current waveforms, including released waves that have not finished fading.
private final ArrayList<Ripple> ripples = new ArrayList<>(0);
/// The feedback paint; null hides drawing without affecting input.
private @Nullable Paint fill = Color.BLACK;
/// The lazily created waveform group; it is attached only while waveforms exist.
private @Nullable Group waveformGroup;
/// The feedback corners, with percentages resolved against this region.
private CornerRadii cornerRadii = CornerRadii.EMPTY;
/// The shared waveform clip, detached when no waveforms remain.
private @Nullable Region waveformClip;
/// The lazily created state region for paints that cannot encode an additional alpha.
private @Nullable Region paintBackground;
/// The paint last applied to the persistent state background.
private @Nullable Paint backgroundPaint;
/// The opacity last applied to the persistent state background.
private double backgroundOpacity;
/// The corner radii last applied to the persistent state background.
private CornerRadii backgroundRadii = CornerRadii.EMPTY;
/// The currently held waveform, or null for a static press or no press.
private @Nullable Ripple activeRipple;
/// Whether state changes animate, initially taken from the loaded settings.
private boolean animationEnabled = AnimationUtils.isAnimationEnabled();
/// The lazily allocated, reusable pulse source.
private @Nullable AnimationTimer timer;
/// Whether there is unfinished animation work.
private boolean running;
/// Whether the owner reports hover.
private boolean hovered;
/// Whether the owner reports visual focus.
private boolean focusActive;
/// Whether a press has started without a release or reset.
/// Whether a press is held.
private boolean pressActive;
/// The currently displayed state opacity, excluding waveforms.
private double stateOpacity;
/// Whether the owner reports persistent selection.
private boolean selected;
/// The opacity at the beginning of the current state transition.
/// The opacity at the start of the current transition.
private double startOpacity;
/// The desired state opacity.
/// The target opacity of the current transition.
private double targetOpacity;
/// The monotonic start time of the current state transition.
/// The monotonic start timestamp of the current transition.
private long stateStart;
/// Whether the state opacity has not yet reached its target.
/// Whether the target opacity has not yet been reached.
private boolean transitioning;
/// The optional owner notification for a completed or unnecessary state transition.
/// The optional callback after a transition settles; reset does not invoke it.
private @Nullable Runnable onStateSettled;
/// Sets an owner notification delivered after publishing a settled state, independently of waves.
/// Reset does not notify; disabling animation does. Assigning a listener does not invoke it.
/// Creates feedback after settings have been loaded, using their animation preference.
InteractionLayer() {
this(true);
}
/// Creates feedback with automatic pulses or explicit advancement through [#advance(long)].
InteractionLayer(boolean automaticPulses) {
this.automaticPulses = automaticPulses;
setPickOnBounds(true);
setOpacity(0);
updateBackground();
}
/// Updates the paint without restarting the current transition.
void setFill(@Nullable Paint value) {
if (fill != value) {
fill = value;
updateBackground();
}
}
/// Updates the corners without restarting the current transition.
void setCornerRadii(CornerRadii value) {
if (!cornerRadii.equals(value)) {
cornerRadii = value;
updateBackground();
}
}
/// Applies the configured paint and corners.
private void updateBackground() {
setBackground(fill == null ? null : new Background(new BackgroundFill(fill, cornerRadii, Insets.EMPTY)));
}
/// Registers a settlement callback; null clears it. Registration does not invoke the callback.
void setOnStateSettled(@Nullable Runnable listener) {
onStateSettled = listener;
}
/// Returns whether the persistent state is still transitioning, independently of waves.
/// Returns whether the feedback is still transitioning.
boolean isStateTransitioning() {
return transitioning;
}
/// Notifies the owner after state visuals have reached their current target.
/// Notifies the owner after feedback has reached its current target.
private void notifyStateSettled() {
if (onStateSettled != null)
onStateSettled.run();
}
/// Creates an empty layer with black feedback, square corners, and the animation default from
/// [AnimationUtils#isAnimationEnabled()]. Settings must have been loaded before construction.
public InteractionLayer() {
this(true);
/// Disabling animation immediately settles feedback; enabling it does not replay a transition.
void setAnimationEnabled(boolean value) {
if (animationEnabled == value)
return;
animationEnabled = value;
if (!value) {
settle();
notifyStateSettled();
}
}
/// Creates a layer with optional automatic pulses; manual timing uses the timestamped operations.
InteractionLayer(boolean automaticPulses) {
this.automaticPulses = automaticPulses;
setPickOnBounds(true);
fill.addListener(observable -> updateVisuals());
animationEnabled.addListener(observable -> {
if (!isAnimationEnabled()) {
clearRipples();
activeRipple = null;
settle();
notifyStateSettled();
}
});
}
/// The paint applied to all feedback, including existing waveforms.
private final ObjectProperty<@Nullable Paint> fill = new SimpleObjectProperty<>(this, "fill", Color.BLACK);
/// Returns the feedback paint property. Null suppresses drawing without changing input or state.
public ObjectProperty<@Nullable Paint> fillProperty() {
return fill;
}
/// Returns the paint used for both the state layer and waveforms, or null if hidden.
public @Nullable Paint getFill() {
return fill.get();
}
/// Sets the feedback paint; its intrinsic transparency multiplies the feedback opacity.
public void setFill(@Nullable Paint value) {
fill.set(value);
}
/// The corner radii shared by the state background and waveform clip.
private final ObjectProperty<@Nullable CornerRadii> cornerRadii =
new SimpleObjectProperty<>(this, "cornerRadii", CornerRadii.EMPTY) {
/// Applies changed radii to state feedback and the existing shared clip.
@Override
protected void invalidated() {
updateWaveformClip();
updateVisuals();
}
};
/// Returns the corner radii property. Null means square corners. Percentage radii are relative
/// to this layer's dimensions; JavaFX background rendering normalizes overlapping corners.
public ObjectProperty<@Nullable CornerRadii> cornerRadiiProperty() {
return cornerRadii;
}
/// Returns the configured corner radii, or null for square corners.
public @Nullable CornerRadii getCornerRadii() {
return cornerRadii.get();
}
/// Sets the radii used by state feedback and existing waveforms without restarting animation.
public void setCornerRadii(@Nullable CornerRadii value) {
cornerRadii.set(value);
}
/// Whether subsequent state changes and presses animate.
private final BooleanProperty animationEnabled = new SimpleBooleanProperty(this, "animationEnabled", AnimationUtils.isAnimationEnabled());
/// Returns the animation switch. Disabling it clears waveforms and immediately displays
/// the current state, including a held press. Enabling it does not replay a held press;
/// its static press feedback remains until release or reset.
/// Initially uses [AnimationUtils#isAnimationEnabled()]; global settings are not observed.
public BooleanProperty animationEnabledProperty() {
return animationEnabled;
}
/// Returns whether new changes animate.
public boolean isAnimationEnabled() {
return animationEnabled.get();
}
/// Enables animation for subsequent changes, or immediately settles existing feedback.
public void setAnimationEnabled(boolean value) {
animationEnabled.set(value);
}
/// Reports hover. The idle hover opacity is 8%, below focus and static press priority.
/// An animated press draws a waveform over this state without increasing the state opacity.
public void setHovered(boolean value) {
/// Reports hover, below focus and press priority.
void setHovered(boolean value) {
setHovered(value, System.nanoTime());
}
/// Reports hover at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Reports hover at a monotonic nanosecond timestamp.
void setHovered(boolean value, long now) {
if (hovered != value) {
hovered = value;
@@ -233,13 +154,12 @@ public final class InteractionLayer extends Region {
}
}
/// Reports visual focus independently of the layer's actual focus. Focus opacity is 10%,
/// above hover priority. An animated press may draw a waveform over the focus state.
public void setFocusActive(boolean value) {
/// Reports visual focus independently of the node's actual focus.
void setFocusActive(boolean value) {
setFocusActive(value, System.nanoTime());
}
/// Reports visual focus at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Reports visual focus at a monotonic nanosecond timestamp.
void setFocusActive(boolean value, long now) {
if (focusActive != value) {
focusActive = value;
@@ -247,170 +167,114 @@ public final class InteractionLayer extends Region {
}
}
/// Starts a press at finite local coordinates. With animation enabled, a waveform with
/// up to 10% paint opacity overlays the hover or focus state, without a separate press layer.
/// With animation disabled, a single 10% state layer replaces the hover or focus state.
/// Repeated calls before release or reset have no effect. Coordinates may lie outside
/// the layer; drawing remains clipped.
///
/// @throws IllegalArgumentException if either coordinate is not finite
public void press(double x, double y) {
press(x, y, System.nanoTime());
/// Reports persistent selection without changing hover, focus, or press state.
void setSelected(boolean value) {
setSelected(value, System.nanoTime());
}
/// Starts a press at a monotonic timestamp in nanoseconds; otherwise follows [#press(double, double)].
void press(double x, double y, long now) {
if (!Double.isFinite(x) || !Double.isFinite(y))
throw new IllegalArgumentException("Press coordinates must be finite");
if (pressActive)
return;
pressActive = true;
if (isAnimationEnabled()) {
Circle circle = new Circle();
circle.setManaged(false);
circle.setMouseTransparent(true);
activeRipple = new Ripple(x, y, now, circle);
ripples.add(activeRipple);
if (waveformGroup == null) {
waveformGroup = new Group();
waveformGroup.setManaged(false);
waveformGroup.setMouseTransparent(true);
waveformClip = new Region();
updateWaveformClip();
}
if (waveformGroup.getParent() == null) {
getChildren().add(waveformGroup);
waveformGroup.setClip(waveformClip);
}
waveformGroup.getChildren().add(circle);
updateGeometry();
/// Reports selection at a monotonic nanosecond timestamp.
void setSelected(boolean value, long now) {
if (selected != value) {
selected = value;
changeState(now);
}
changeState(now);
}
/// Releases the current press. Its waveform expands and fades for 200 ms when animation
/// is enabled. Repeated calls do nothing; previously released waveforms continue fading.
public void release() {
/// Starts uniform press feedback at the shared linear rate; repeated presses have no effect.
void press() {
press(System.nanoTime());
}
/// Starts a press at a monotonic nanosecond timestamp.
void press(long now) {
if (!pressActive) {
pressActive = true;
changeState(now);
}
}
/// Releases press feedback while retaining selection, hover, and focus; repeated releases do nothing.
void release() {
release(System.nanoTime());
}
/// Releases the press at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Releases a press at a monotonic nanosecond timestamp.
void release(long now) {
if (!pressActive)
return;
updateAnimations(now);
pressActive = false;
if (activeRipple != null) {
activeRipple.releaseProgress = activeRipple.progress;
activeRipple.released = true;
activeRipple.start = now;
activeRipple = null;
if (pressActive) {
pressActive = false;
changeState(now);
}
changeState(now);
}
/// Immediately clears all waveforms and the press, stops pulses, and displays the current
/// hover or focus state. Repeated calls have no additional effect.
public void reset() {
/// Cancels the press and settles selection, hover, and focus without notifying settlement.
void reset() {
pressActive = false;
activeRipple = null;
clearRipples();
settle();
}
/// Returns the persistent state opacity, leaving animated press feedback to its waveform.
/// Adds the selected base tint to the highest-priority transient state.
private double desiredOpacity() {
// A press interrupted by disabling animation stays static even if animation is re-enabled.
if (pressActive && activeRipple == null)
return PRESSED_OPACITY;
return focusActive ? FOCUS_OPACITY : hovered ? HOVER_OPACITY : 0;
double interaction = pressActive ? PRESSED_OPACITY : focusActive ? FOCUS_OPACITY : hovered ? HOVER_OPACITY : 0;
return (selected ? SELECTED_OPACITY : 0) + interaction;
}
/// Starts a transition from the currently displayed state rather than its previous target.
/// Samples the current fade and retargets it without changing the opacity rate.
private void changeState(long now) {
updateAnimations(now);
if (!isAnimationEnabled()) {
updateAnimation(now);
if (!animationEnabled) {
settle();
notifyStateSettled();
return;
}
double target = desiredOpacity();
if (target != targetOpacity) {
startOpacity = stateOpacity;
startOpacity = getOpacity();
targetOpacity = target;
stateStart = now;
transitioning = startOpacity != targetOpacity;
setTransitioning(startOpacity != targetOpacity);
}
if (transitioning)
setRunning(true);
updateVisuals();
if (!transitioning)
notifyStateSettled();
}
/// Applies the exact static state and releases the pulse source.
/// Stops pulses and displays the current state immediately.
private void settle() {
transitioning = false;
stateOpacity = targetOpacity = desiredOpacity();
setRunning(false);
updateVisuals();
setTransitioning(false);
targetOpacity = desiredOpacity();
setOpacity(targetOpacity);
}
/// Advances all animations to a monotonic timestamp in nanoseconds, then updates their nodes.
/// The timestamp must use the same time origin as input operations.
/// Package access permits deterministic tests without exposing a public timing API.
/// Advances feedback and reports completion once. Timestamps must share the input clock's origin.
void advance(long now) {
boolean wasTransitioning = transitioning;
updateAnimations(now);
updateVisuals();
updateAnimation(now);
if (wasTransitioning && !transitioning)
notifyStateSettled();
}
/// Advances logical animation state without publishing intermediate visual changes.
private void updateAnimations(long now) {
if (transitioning) {
// Hover-only fades use a uniform opacity change in both directions.
boolean hoverOnly = startOpacity <= HOVER_OPACITY && targetOpacity <= HOVER_OPACITY;
double progress = fraction(now - stateStart, hoverOnly ? HOVER_DURATION : STATE_DURATION);
Interpolator interpolator = hoverOnly ? Interpolator.LINEAR : Motion.STANDARD;
stateOpacity = progress == 0 ? startOpacity : progress == 1 ? targetOpacity
: interpolator.interpolate(startOpacity, targetOpacity, progress);
transitioning = progress < 1;
}
boolean moving = transitioning;
for (int i = ripples.size() - 1; i >= 0; i--) {
Ripple ripple = ripples.get(i);
double progress = fraction(now - ripple.start, ripple.released ? STATE_DURATION : EXPANSION_DURATION);
if (ripple.released && progress == 1) {
ripples.remove(i);
removeWaveform(ripple.circle);
continue;
}
double eased = progress == 0 ? 0 : progress == 1 ? 1 : Motion.STANDARD.interpolate(0.0, 1.0, progress);
ripple.progress = ripple.released ? ripple.releaseProgress + (1 - ripple.releaseProgress) * eased : eased;
ripple.opacity = ripple.released ? PRESSED_OPACITY * (1 - eased) : PRESSED_OPACITY;
moving |= progress < 1;
}
setRunning(moving);
}
/// Clamps elapsed time to a unit interval, including exact animation endpoints.
private static double fraction(long elapsed, long duration) {
return Math.max(0, Math.min(1, (double) elapsed / duration));
}
/// Starts or stops the single reusable pulse source when unfinished work changes.
private void setRunning(boolean value) {
if (running == value)
/// Samples the transition without notifying the owner during an input-state change.
private void updateAnimation(long now) {
if (!transitioning)
return;
running = value;
double distance = targetOpacity - startOpacity;
long elapsed = Math.max(0, now - stateStart);
// Compare rounded clock ticks so floating-point subtraction cannot delay settlement.
boolean finished = elapsed >= Math.max(1, Math.round(Math.abs(distance) * NANOS_PER_OPACITY));
setOpacity(finished ? targetOpacity : startOpacity + Math.copySign(elapsed / NANOS_PER_OPACITY, distance));
setTransitioning(!finished);
}
/// Keeps transition status and the reusable pulse source in sync.
private void setTransitioning(boolean value) {
if (transitioning == value)
return;
transitioning = value;
if (!automaticPulses)
return;
if (value) {
if (timer == null) {
timer = new AnimationTimer() {
/// Samples the system monotonic clock used by input operations.
/// Samples the same monotonic clock used by input operations.
@Override
public void handle(long now) {
advance(System.nanoTime());
@@ -422,156 +286,4 @@ public final class InteractionLayer extends Region {
timer.stop();
}
}
/// Reports whether unfinished animation work requires another pulse.
boolean isAnimating() {
return running;
}
/// Returns the current state opacity, excluding waveforms.
double stateOpacity() {
return stateOpacity;
}
/// Returns the number of held and fading waveforms.
int rippleCount() {
return ripples.size();
}
/// Updates both dimensions before adjusting backgrounds, clipping, and waveform radii.
@Override
public void resize(double width, double height) {
if (width == getWidth() && height == getHeight())
return;
super.resize(width, height);
updateGeometry();
}
/// Applies the same background geometry to the shared clip, including per-axis percentage radii.
private void updateWaveformClip() {
if (waveformClip != null)
waveformClip.setBackground(new Background(new BackgroundFill(Color.BLACK, getCornerRadii(), Insets.EMPTY)));
}
/// Updates size-dependent geometry without restarting an existing animation.
private void updateGeometry() {
if (waveformClip != null && !ripples.isEmpty())
waveformClip.resize(getWidth(), getHeight());
if (paintBackground != null)
paintBackground.resize(getWidth(), getHeight());
for (int i = 0; i < ripples.size(); i++) {
Ripple ripple = ripples.get(i);
double dx = Math.max(Math.abs(ripple.x), Math.abs(getWidth() - ripple.x));
double dy = Math.max(Math.abs(ripple.y), Math.abs(getHeight() - ripple.y));
ripple.circle.setRadius(Math.hypot(dx, dy));
}
updateVisuals();
}
/// Publishes changed state and waveform properties without replaying drawing commands.
private void updateVisuals() {
@Nullable Paint paint = getFill();
@Nullable CornerRadii radii = getCornerRadii();
updateBackground(paint, radii == null ? CornerRadii.EMPTY : radii);
if (waveformGroup != null)
waveformGroup.setVisible(paint != null && getWidth() > 0 && getHeight() > 0);
for (int i = 0; i < ripples.size(); i++) {
Ripple ripple = ripples.get(i);
Circle circle = ripple.circle;
circle.setFill(paint);
circle.setCenterX(ripple.x);
circle.setCenterY(ripple.y);
circle.setScaleX(ripple.progress);
circle.setScaleY(ripple.progress);
circle.setOpacity(ripple.opacity);
}
}
/// Updates the persistent background only when its paint, opacity, or corner radii change.
private void updateBackground(@Nullable Paint paint, CornerRadii radii) {
boolean geometryOrPaintChanged = paint != backgroundPaint || !radii.equals(backgroundRadii);
if (!geometryOrPaintChanged && stateOpacity == backgroundOpacity)
return;
backgroundPaint = paint;
backgroundOpacity = stateOpacity;
backgroundRadii = radii;
if (paintBackground != null && (paint instanceof Color || paint == null || stateOpacity == 0)) {
getChildren().remove(paintBackground);
paintBackground.setBackground(null);
}
if (paint == null || stateOpacity == 0) {
setBackground(null);
} else if (paint instanceof Color color) {
Color tinted = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * stateOpacity);
setBackground(new Background(new BackgroundFill(tinted, backgroundRadii, Insets.EMPTY)));
} else {
// A node opacity also supports ImagePattern without rewriting its source pixels.
setBackground(null);
if (paintBackground == null) {
paintBackground = new Region();
paintBackground.setManaged(false);
paintBackground.setMouseTransparent(true);
}
paintBackground.resize(getWidth(), getHeight());
if (geometryOrPaintChanged || paintBackground.getBackground() == null)
paintBackground.setBackground(new Background(new BackgroundFill(paint, radii, Insets.EMPTY)));
paintBackground.setOpacity(stateOpacity);
if (paintBackground.getParent() == null)
getChildren().add(0, paintBackground);
}
}
/// Detaches all waves and releases their animation state.
private void clearRipples() {
for (int i = ripples.size() - 1; i >= 0; i--)
removeWaveform(ripples.remove(i).circle);
}
/// Removes one waveform node and detaches the shared clip when its group becomes empty.
private void removeWaveform(Circle circle) {
if (waveformGroup != null) {
waveformGroup.getChildren().remove(circle);
if (waveformGroup.getChildren().isEmpty()) {
getChildren().remove(waveformGroup);
waveformGroup.setClip(null);
}
}
circle.setFill(null);
}
/// Stores animation progress alongside its waveform node until completion or cancellation.
@NotNullByDefault
private static final class Ripple {
/// The horizontal origin in local coordinates.
private final double x;
/// The vertical origin in local coordinates.
private final double y;
/// The unmanaged circle displaying this waveform.
private final Circle circle;
/// The start of the current expansion or release phase.
private long start;
/// Whether the release phase has begun.
private boolean released;
/// The fraction of the covering radius currently displayed.
private double progress;
/// The expansion fraction captured on release.
private double releaseProgress;
/// The additional paint opacity of this waveform.
private double opacity = PRESSED_OPACITY;
/// Creates an unexpanded held waveform at the supplied position and time.
private Ripple(double x, double y, long start, Circle circle) {
this.x = x;
this.y = y;
this.start = start;
this.circle = circle;
}
}
}
@@ -33,7 +33,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/// Wraps content with hover, focus, press, and ripple feedback behind it.
/// Wraps content with uniform selection, hover, focus, and press color transitions behind it.
/// Feedback covers padding without replacing the background. Corners follow the first background
/// fill of this pane, then the wrapped [Region], otherwise remaining square. Fill insets are ignored;
/// percentage radii use this pane's dimensions.
@@ -108,7 +108,7 @@ public class RipplerContainer extends StackPane {
addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (isBackgroundEvent(event) && event.getButton() == MouseButton.PRIMARY
&& isFeedbackAvailable() && isPressFeedbackEnabled())
interactionLayer.press(event.getX(), event.getY());
interactionLayer.press();
});
addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (isBackgroundEvent(event) && event.getButton() == MouseButton.PRIMARY)
@@ -181,7 +181,7 @@ public class RipplerContainer extends StackPane {
updateVisualState(false);
}
/// Updates persistent states and cancels presses when requested or feedback is unavailable.
/// Updates hover and focus and cancels unavailable presses, preserving selection.
private void updateVisualState(boolean cancelPress) {
boolean available = isFeedbackAvailable();
interactionLayer.setHovered(available && pointerInside);
@@ -284,19 +284,14 @@ public class RipplerContainer extends StackPane {
/// The registration is cleared before invocation.
///
/// Leaving before settlement postpones scheduling until another entry. Once queued, only replacement
/// or cancellation prevents invocation. Scheduling does not wait for waveforms or display presentation.
/// or cancellation prevents invocation. Scheduling does not wait for display presentation.
public void setPrepareAction(@Nullable Runnable action) {
if (action == getPrepareAction())
return;
if (preparation != null) {
interactionLayer.setOnStateSettled(null);
preparation = null;
}
if (action != null) {
preparation = new HoverPreparation(action);
interactionLayer.setOnStateSettled(preparation);
preparation = action == null ? null : new HoverPreparation(action);
interactionLayer.setOnStateSettled(preparation);
if (preparation != null)
preparation.run();
}
}
/// The CSS-configurable paint shared by all visual feedback states.
@@ -319,7 +314,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FEEDBACK_FILL;
}
/// Repaints existing states and waveforms with the new paint.
/// Repaints the feedback background with the new paint.
@Override
protected void invalidated() {
interactionLayer.setFill(get());
@@ -344,7 +339,7 @@ public class RipplerContainer extends StackPane {
/// Whether background presses produce visual feedback.
private final BooleanProperty pressFeedbackEnabled = new SimpleBooleanProperty(this, "pressFeedbackEnabled", true) {
/// Updates the CSS state and cancels existing feedback when disabled.
/// Updates the CSS state and clears press feedback when disabled.
@Override
protected void invalidated() {
pseudoClassStateChanged(PRESS_FEEDBACK_ENABLED, get());
@@ -354,8 +349,8 @@ public class RipplerContainer extends StackPane {
}
};
/// Returns the press feedback switch, initially true. Disabling it clears all waveforms
/// and the held press, preserving hover and focus feedback and normal event delivery.
/// Returns the press feedback switch, initially true. Disabling it clears the held press
/// and settles selection, hover, and focus feedback, preserving normal event delivery.
/// The default stylesheet applies a hand cursor only while this property is true.
/// Enabling it does not replay an existing press.
public BooleanProperty pressFeedbackEnabledProperty() {
@@ -372,7 +367,15 @@ public class RipplerContainer extends StackPane {
pressFeedbackEnabled.set(value);
}
/// Clears all held and fading waveforms and immediately restores the current hover/focus state.
/// Sets persistent selection feedback for compound controls such as tab headers.
/// Hidden or disabled containers settle immediately; selection survives transient resets.
final void setSelectedFeedback(boolean selected) {
interactionLayer.setSelected(selected);
if (!isFeedbackAvailable())
interactionLayer.reset();
}
/// Clears press feedback and immediately restores the current selection, hover, and focus.
/// Suitable for clearing transient feedback when a virtualized cell is reused.
public void resetFeedback() {
interactionLayer.reset();
@@ -429,7 +432,7 @@ public class RipplerContainer extends StackPane {
/// Reports whether CSS can assign this paint property.
@Override
public boolean isSettable(RipplerContainer styleable) {
return styleable.feedbackFill == null || !styleable.feedbackFill.isBound();
return !styleable.feedbackFill.isBound();
}
/// Returns the paint property receiving stylesheet values.
@@ -287,14 +287,16 @@ public class TabHeader extends Control {
setOnAction(event -> getSkinnable().getController().selectTab(tab));
}
/// Reflects the controller's selected tab in the label's pseudo-class.
/// Updates persistent background feedback and the label's selected pseudo-class.
private void updateSelection() {
tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE,
getSkinnable().getController().getSelectedTab() == tab);
boolean selected = getSkinnable().getController().getSelectedTab() == tab;
setSelectedFeedback(selected);
tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, selected);
}
/// Releases the tab's text binding.
/// Stops background feedback animation and releases the tab's text binding.
private void dispose() {
resetFeedback();
tabText.textProperty().unbind();
}
}
@@ -476,6 +476,10 @@
-fx-background-color: derive(-monet-primary-container, -30%);
}
.tab-header > .rippler-container {
-jfx-feedback-fill: -monet-primary;
}
/* TODO: It seems not actually used */
.tab-rippler {
-jfx-rippler-fill: derive(-monet-primary-container, 30%);
@@ -0,0 +1,311 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.LinearGradient;
import javafx.scene.paint.Stop;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.parallel.Isolated;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/// Verifies interaction continuity, state restoration, and persistent tab selection.
@NotNullByDefault
@EnabledIf("org.jackhuang.hmcl.JavaFXLauncher#isStarted")
@Isolated("Temporarily supplies settings while initializing the cached animation policy")
public final class InteractionLayerTest {
/// Initializes animation defaults without reading or changing persisted settings.
@BeforeAll
static void initializeAnimationPolicy() throws Exception {
Field settingsField = SettingsManager.class.getDeclaredField("launcherSettings");
settingsField.setAccessible(true);
@Nullable Object previous = settingsField.get(null);
if (previous == null)
settingsField.set(null, new LauncherSettings());
try {
AnimationUtils.init();
} finally {
settingsField.set(null, previous);
}
}
/// Selection remains visible after pointer feedback ends, and selected controls still respond to presses.
@ParameterizedTest
@ValueSource(booleans = {false, true})
void pointerFeedbackRestoresPersistentState(boolean selected) throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
layer.setSelected(selected, 0);
settleAnimation(layer, 0);
double baseline = layer.getOpacity();
assertEquals(selected, baseline > 0);
layer.setHovered(true, SECOND);
settleAnimation(layer, SECOND);
double hovered = layer.getOpacity();
assertTrue(hovered > baseline);
layer.press(2 * SECOND);
settleAnimation(layer, 2 * SECOND);
assertTrue(layer.getOpacity() > hovered);
layer.release(3 * SECOND);
settleAnimation(layer, 3 * SECOND);
assertEquals(hovered, layer.getOpacity());
layer.setHovered(false, 4 * SECOND);
settleAnimation(layer, 4 * SECOND);
assertEquals(baseline, layer.getOpacity());
layer.setSelected(false, 5 * SECOND);
settleAnimation(layer, 5 * SECOND);
assertEquals(0, layer.getOpacity());
});
}
/// Retargeting hover to press preserves the displayed tint and the linear rate.
@Test
void retargetingPreservesLinearRate() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
layer.setHovered(true, 0);
layer.advance(SAMPLE);
double first = layer.getOpacity();
assertTrue(first > 0);
assertTrue(layer.isStateTransitioning());
// No pulse at the input timestamp: input must sample the current fade itself.
layer.press(2 * SAMPLE);
assertEquals(2 * first, layer.getOpacity(), EPSILON);
layer.advance(3 * SAMPLE);
assertEquals(3 * first, layer.getOpacity(), EPSILON);
layer.press(3 * SAMPLE);
layer.advance(4 * SAMPLE);
assertEquals(4 * first, layer.getOpacity(), EPSILON);
});
}
/// Early release reverses immediately; a second press continues from the fading tint.
@Test
void rapidClicksRemainContinuous() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
layer.press(0);
layer.advance(2 * SAMPLE);
double pressed = layer.getOpacity();
assertTrue(layer.isStateTransitioning());
layer.release(2 * SAMPLE);
assertEquals(pressed, layer.getOpacity());
layer.advance(3 * SAMPLE);
double fading = layer.getOpacity();
assertEquals(pressed / 2, fading, EPSILON);
layer.press(3 * SAMPLE);
assertEquals(fading, layer.getOpacity());
layer.advance(4 * SAMPLE);
assertEquals(pressed, layer.getOpacity(), EPSILON);
layer.release(4 * SAMPLE);
// A fade retraces the same distance in the same time, without a fixed release delay.
layer.advance(6 * SAMPLE);
assertEquals(0, layer.getOpacity());
assertFalse(layer.isStateTransitioning());
assertTrue(layer.contains(100, 20));
});
}
/// Reset clears a held press without losing selection or focus; disabled animation settles immediately.
@Test
void resetPreservesSelectionAndFocus() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
layer.setAnimationEnabled(false);
layer.setSelected(true, 0);
double selected = layer.getOpacity();
layer.setHovered(true, 0);
double hovered = layer.getOpacity();
layer.setFocusActive(true, 0);
double focused = layer.getOpacity();
assertTrue(focused >= hovered);
layer.press(0);
layer.reset();
assertEquals(focused, layer.getOpacity());
assertFalse(layer.isStateTransitioning());
layer.setFocusActive(false, 0);
assertEquals(hovered, layer.getOpacity());
layer.setHovered(false, 0);
assertEquals(selected, layer.getOpacity());
layer.setAnimationEnabled(true);
assertFalse(layer.isStateTransitioning());
layer.setSelected(false, 0);
layer.advance(SAMPLE);
assertTrue(layer.isStateTransitioning());
layer.setAnimationEnabled(false);
assertEquals(0, layer.getOpacity());
assertFalse(layer.isStateTransitioning());
});
}
/// Theme paint and corner changes preserve interaction progress; hidden paint remains pickable.
@Test
void appearanceChangesPreserveFeedback() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
var gradient = new LinearGradient(0, 0, 1, 1, true, CycleMethod.NO_CYCLE,
new Stop(0, Color.color(1, 0, 0, 0.5)), new Stop(1, Color.TRANSPARENT));
layer.setFill(gradient);
layer.press(0);
layer.advance(SAMPLE);
assertEquals(gradient, Objects.requireNonNull(layer.getBackground()).getFills().get(0).getFill());
double opacity = layer.getOpacity();
var radii = new CornerRadii(0.5, true);
layer.setCornerRadii(radii);
layer.resize(300, 60);
assertEquals(opacity, layer.getOpacity());
assertEquals(radii, Objects.requireNonNull(layer.getBackground()).getFills().get(0).getRadii());
layer.setFill(null);
assertNull(layer.getBackground());
assertTrue(layer.contains(150, 30));
assertEquals(opacity, layer.getOpacity());
Color paint = Color.color(0, 0, 1, 0.5);
layer.setFill(paint);
assertEquals(paint, Objects.requireNonNull(layer.getBackground()).getFills().get(0).getFill());
assertEquals(opacity, layer.getOpacity());
});
}
/// Completion notifies once; reset cancels silently and disabling animation reports settlement.
@Test
void settlementCallbackFollowsCompletion() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
var notifications = new AtomicInteger();
layer.setOnStateSettled(notifications::incrementAndGet);
layer.setHovered(true, 0);
settleAnimation(layer, 0);
layer.advance(2 * SECOND);
assertEquals(1, notifications.get());
layer.press(2 * SECOND);
layer.reset();
settleAnimation(layer, 2 * SECOND);
assertEquals(1, notifications.get());
layer.press(3 * SECOND);
layer.setAnimationEnabled(false);
assertEquals(2, notifications.get());
assertFalse(layer.isStateTransitioning());
});
}
/// Reversing selection mid-fade is continuous and reset retains the latest selection.
@Test
void interruptedSelectionRemainsContinuous() throws Exception {
onFxThread(() -> {
InteractionLayer layer = createLayer();
layer.setSelected(true, 0);
layer.advance(2 * SAMPLE);
double selecting = layer.getOpacity();
layer.setSelected(false, 2 * SAMPLE);
assertEquals(selecting, layer.getOpacity());
layer.advance(3 * SAMPLE);
double fading = layer.getOpacity();
assertEquals(selecting / 2, fading, EPSILON);
layer.setSelected(true, 3 * SAMPLE);
layer.press(3 * SAMPLE);
assertEquals(fading, layer.getOpacity());
layer.reset();
double baseline = layer.getOpacity();
assertTrue(baseline > selecting);
assertFalse(layer.isStateTransitioning());
layer.release(4 * SAMPLE);
assertEquals(baseline, layer.getOpacity());
});
}
/// Checks that controller selection updates tab backgrounds even without a showing window.
@Test
void tabSelectionUpdatesBackgrounds() throws Exception {
onFxThread(() -> {
var first = new TabItem<Region>();
var second = new TabItem<Region>();
TabHeader header = new TabHeader(first, second);
var root = new StackPane(header);
new Scene(root, 300, 100);
root.applyCss();
root.layout();
@Unmodifiable List<RipplerContainer> views = header.lookupAll(".rippler-container").stream()
.map(RipplerContainer.class::cast).toList();
assertEquals(2, views.size());
assertEquals(1, views.stream().filter(view ->
feedbackOpacity(view) > 0).count());
RipplerContainer previous = views.stream().filter(view ->
feedbackOpacity(view) > 0).findFirst().orElseThrow();
double selectedOpacity = feedbackOpacity(previous);
try {
header.getController().selectTab(second);
assertEquals(0, feedbackOpacity(previous));
assertEquals(1, views.stream().filter(view ->
feedbackOpacity(view) > 0).count());
header.getController().getTabs().remove(second);
assertEquals(selectedOpacity, feedbackOpacity(previous));
} finally {
Objects.requireNonNull(header.getSkin()).dispose();
}
});
}
/// Finds feedback by node type rather than depending on the wrapper's child order.
private static double feedbackOpacity(RipplerContainer view) {
return view.getChildren().stream().filter(InteractionLayer.class::isInstance)
.findFirst().orElseThrow().getOpacity();
}
/// A generous completion interval, independent of the visual timing tokens.
private static final long SECOND = 1_000_000_000L;
/// A short sampling interval for observing uninterrupted and interrupted fades.
private static final long SAMPLE = 10_000_000L;
/// Tolerance for comparing independently sampled opacity values.
private static final double EPSILON = 1e-12;
/// Advances well past a normal feedback transition and verifies it stops.
private static void settleAnimation(InteractionLayer layer, long startedAt) {
layer.advance(startedAt + SECOND);
assertFalse(layer.isStateTransitioning());
}
/// Creates an animated layer whose pulses are controlled entirely by each test.
private static InteractionLayer createLayer() {
InteractionLayer layer = new InteractionLayer(false);
layer.setAnimationEnabled(true);
layer.resize(200, 40);
return layer;
}
/// Runs assertions on the JavaFX thread and propagates failures to the test worker.
private static void onFxThread(Runnable action) throws Exception {
FutureTask<@Nullable Void> task = new FutureTask<>(action, null);
Platform.runLater(task);
task.get(10, TimeUnit.SECONDS);
}
}