use crate::{Brush, DrawContext, PressWave, PressWaves, WidgetInteractionState};
use vello::kurbo::{Point, Rect, RoundedRectRadii};
use vello::peniko::Color;
const RIPPLE_MINIMUM_DIAMETER: f64 = 48.0;
pub const HOVER_STATE_LAYER_OPACITY: f32 = 0.08;
pub const FOCUS_STATE_LAYER_OPACITY: f32 = 0.1;
pub const PRESSED_STATE_LAYER_OPACITY: f32 = 0.1;
pub const DRAGGED_STATE_LAYER_OPACITY: f32 = 0.16;
fn resolved_state_layer_opacity(state: WidgetInteractionState) -> f32 {
if state.focus_visible {
state.focus_progress * FOCUS_STATE_LAYER_OPACITY
} else if state.state_layer_opacity > 0.0 {
state.state_layer_opacity
} else if state.hovered {
HOVER_STATE_LAYER_OPACITY
} else {
0.0
}
}
fn resolved_press_waves(state: WidgetInteractionState) -> PressWaves {
if !state.press_waves.is_empty() {
return state.press_waves;
}
let mut waves = PressWaves::EMPTY;
if state.pressed {
waves.push(PressWave {
origin: None,
progress: 1.0,
opacity: PRESSED_STATE_LAYER_OPACITY,
});
}
waves
}
const RIPPLE_INITIAL_SCALE: f64 = 0.4;
pub fn ripple_diameter(bounds: Rect) -> f64 {
bounds
.width()
.hypot(bounds.height())
.max(RIPPLE_MINIMUM_DIAMETER)
}
fn ripple_kinematics(target: Point, diameter: f64, wave: PressWave) -> (Point, f64) {
let progress = f64::from(wave.progress.clamp(0.0, 1.0));
let origin = wave.origin.unwrap_or(target);
let center = Point::new(
(target.x - origin.x).mul_add(progress, origin.x),
(target.y - origin.y).mul_add(progress, origin.y),
);
let scale = (1.0 - RIPPLE_INITIAL_SCALE).mul_add(progress, RIPPLE_INITIAL_SCALE);
(center, diameter * 0.5 * scale)
}
fn fill_waves(
draw: &mut dyn DrawContext,
target: Point,
diameter: f64,
color: Color,
waves: PressWaves,
) {
for wave in waves.iter() {
if wave.opacity <= 0.0 {
continue;
}
let (center, radius) = ripple_kinematics(target, diameter, wave);
let brush = Brush::from(color.with_alpha(wave.opacity.clamp(0.0, 1.0)));
draw.fill_circle(center, radius, &brush);
}
}
fn draw_state_tint_rounded(
draw: &mut dyn DrawContext,
bounds: Rect,
radii: RoundedRectRadii,
color: Color,
state_opacity: f32,
) {
if state_opacity > 0.0 {
draw.push_rounded_layer(state_opacity, bounds, radii);
draw.fill_rounded_rect(bounds, radii, &Brush::from(color));
draw.pop_layer();
}
}
pub fn draw_bounded(
draw: &mut dyn DrawContext,
bounds: Rect,
radii: RoundedRectRadii,
color: Color,
state: WidgetInteractionState,
) {
draw_state_tint_rounded(
draw,
bounds,
radii,
color,
resolved_state_layer_opacity(state),
);
let waves = resolved_press_waves(state);
if waves.is_empty() {
return;
}
draw.push_rounded_layer(1.0, bounds, radii);
fill_waves(draw, bounds.center(), ripple_diameter(bounds), color, waves);
draw.pop_layer();
}
pub fn draw_unbounded_circle(
draw: &mut dyn DrawContext,
center: Point,
radius: f64,
color: Color,
state: WidgetInteractionState,
) {
let state_opacity = resolved_state_layer_opacity(state);
if state_opacity > 0.0 {
draw.push_layer(state_opacity, None);
draw.fill_circle(center, radius, &Brush::from(color));
draw.pop_layer();
}
let waves = resolved_press_waves(state);
if waves.is_empty() {
return;
}
draw.push_layer(1.0, None);
fill_waves(draw, center, radius * 2.0, color, waves);
draw.pop_layer();
}
#[cfg(test)]
mod tests {
use super::{PRESSED_STATE_LAYER_OPACITY, RIPPLE_MINIMUM_DIAMETER, ripple_diameter};
use crate::{
Brush, DrawContext, PressWave, PressWaves, WidgetInteractionState, theme::state_layer,
};
use std::path::Path;
use vello::kurbo::{Affine, BezPath, Circle, Line, Point, Rect, RoundedRect, RoundedRectRadii};
use vello::peniko::Color;
use waterui_graphics::{
GpuContext, GpuFrame, GpuRuntime, GpuSurface, GpuView, OffscreenRenderConfig, OffscreenSize,
};
#[test]
fn material_ripple_diameter_spans_diagonal_with_minimum() {
let wide = Rect::new(0.0, 0.0, 100.0, 40.0);
assert!((ripple_diameter(wide) - 100.0_f64.hypot(40.0)).abs() < 1e-6);
let tiny = Rect::new(0.0, 0.0, 20.0, 20.0);
assert_eq!(ripple_diameter(tiny), RIPPLE_MINIMUM_DIAMETER);
}
fn pressed_state(waves: &[PressWave]) -> WidgetInteractionState {
let mut press_waves = PressWaves::EMPTY;
for wave in waves {
press_waves.push(*wave);
}
WidgetInteractionState {
pressed: true,
press_waves,
..WidgetInteractionState::NONE
}
}
#[test]
fn material_ripple_animates_from_press_point_to_full_coverage() {
let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
let origin = Point::new(10.0, 12.0);
let mut recorder = RecordingDrawContext::default();
state_layer::draw_bounded(
&mut recorder,
bounds,
8.0.into(),
Color::new([1.0, 1.0, 1.0, 1.0]),
pressed_state(&[PressWave {
origin: Some(origin),
progress: 0.5,
opacity: 0.12,
}]),
);
let circle = recorder
.single_circle()
.expect("mid-press ripple must fill a solid circle");
assert_eq!(
circle.center,
Point::new(30.0, 16.0),
"center drifts halfway from the press point to the bounds center"
);
let full_radius = ripple_diameter(bounds) * 0.5;
assert!(
(circle.radius - full_radius * 0.7).abs() < 1e-6,
"radius is halfway between the 0.4 initial fraction and full size"
);
}
#[test]
fn material_ripple_press_layer_is_a_solid_circle() {
let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
let mut recorder = RecordingDrawContext::default();
state_layer::draw_bounded(
&mut recorder,
bounds,
8.0.into(),
Color::new([1.0, 1.0, 1.0, 1.0]),
pressed_state(&[PressWave {
origin: Some(Point::new(10.0, 12.0)),
progress: 1.0,
opacity: 0.12,
}]),
);
let circle = recorder
.single_circle()
.expect("press ripple must fill a solid circle");
assert_eq!(circle.center, Point::new(50.0, 20.0), "circle is centered");
assert!(
(circle.radius - ripple_diameter(bounds) * 0.5).abs() < 1e-6,
"circle radius is half the ripple diameter"
);
assert!(
matches!(circle.brush, RecordedBrush::Solid(alpha) if (alpha - 0.12).abs() < 1e-6),
"ripple is a solid color at the press opacity, not a gradient"
);
}
#[test]
fn unbounded_circle_ripple_converges_to_the_requested_radius() {
let center = Point::new(50.0, 30.0);
let radius = 20.0;
let mut recorder = RecordingDrawContext::default();
state_layer::draw_unbounded_circle(
&mut recorder,
center,
radius,
Color::new([1.0, 1.0, 1.0, 1.0]),
pressed_state(&[PressWave {
origin: None,
progress: 1.0,
opacity: 0.12,
}]),
);
let circle = recorder
.single_circle()
.expect("press ripple must fill a solid circle");
assert_eq!(circle.center, center, "wave converges on the halo center");
assert!(
(circle.radius - radius).abs() < 1e-6,
"wave radius {} must converge to the halo radius {}",
circle.radius,
radius
);
}
#[test]
fn overlapping_press_waves_draw_oldest_to_newest() {
let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
let mut recorder = RecordingDrawContext::default();
state_layer::draw_bounded(
&mut recorder,
bounds,
8.0.into(),
Color::new([1.0, 1.0, 1.0, 1.0]),
pressed_state(&[
PressWave {
origin: Some(Point::new(10.0, 12.0)),
progress: 1.0,
opacity: 0.06,
},
PressWave {
origin: Some(Point::new(80.0, 30.0)),
progress: 0.0,
opacity: 0.12,
},
]),
);
let circles = &recorder.filled_circles;
assert_eq!(circles.len(), 2, "both waves must be filled");
assert_eq!(
circles[0].center,
Point::new(50.0, 20.0),
"the older wave is centered at full coverage"
);
assert!(
matches!(circles[0].brush, RecordedBrush::Solid(alpha) if (alpha - 0.06).abs() < 1e-6),
"the older wave fades at its own sampled opacity"
);
assert_eq!(
circles[1].center,
Point::new(80.0, 30.0),
"the fresh wave starts over its own press point"
);
let full_radius = ripple_diameter(bounds) * 0.5;
assert!(
(circles[1].radius - full_radius * 0.4).abs() < 1e-6,
"the fresh wave starts at the initial scale fraction"
);
}
#[test]
fn static_pressed_state_synthesizes_a_full_coverage_wave() {
let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
let mut recorder = RecordingDrawContext::default();
state_layer::draw_bounded(
&mut recorder,
bounds,
8.0.into(),
Color::new([1.0, 1.0, 1.0, 1.0]),
WidgetInteractionState {
pressed: true,
..WidgetInteractionState::NONE
},
);
let circle = recorder
.single_circle()
.expect("static pressed state must fill a press layer");
assert_eq!(circle.center, Point::new(50.0, 20.0));
assert!(
matches!(
circle.brush,
RecordedBrush::Solid(alpha)
if (alpha - PRESSED_STATE_LAYER_OPACITY).abs() < 1e-6
),
"synthesized wave uses StateTokens.PressedStateLayerOpacity"
);
}
#[derive(Debug, Clone, Copy)]
enum RecordedBrush {
Solid(f32),
Gradient,
}
#[derive(Debug, Clone, Copy)]
struct RecordedCircle {
center: Point,
radius: f64,
brush: RecordedBrush,
}
#[derive(Default)]
struct RecordingDrawContext {
filled_circles: Vec<RecordedCircle>,
}
impl RecordingDrawContext {
fn record_brush(brush: &Brush) -> RecordedBrush {
match brush {
Brush::Solid(color) => RecordedBrush::Solid(color.components[3]),
Brush::Gradient(_) => RecordedBrush::Gradient,
}
}
fn single_circle(&self) -> Option<RecordedCircle> {
assert!(
self.filled_circles.len() <= 1,
"expected at most one filled circle, recorded {}",
self.filled_circles.len()
);
self.filled_circles.first().copied()
}
}
impl DrawContext for RecordingDrawContext {
fn fill_rect(&mut self, _rect: Rect, _brush: &Brush) {}
fn fill_rounded_rect(&mut self, _rect: Rect, _radii: RoundedRectRadii, _brush: &Brush) {}
fn stroke_rect(&mut self, _rect: Rect, _brush: &Brush, _width: f64) {}
fn stroke_rounded_rect(
&mut self,
_rect: Rect,
_radii: RoundedRectRadii,
_brush: &Brush,
_width: f64,
) {
}
fn stroke_line(&mut self, _from: Point, _to: Point, _brush: &Brush, _width: f64) {}
fn stroke_circle(&mut self, _center: Point, _radius: f64, _brush: &Brush, _width: f64) {}
fn fill_circle(&mut self, center: Point, radius: f64, brush: &Brush) {
self.filled_circles.push(RecordedCircle {
center,
radius,
brush: Self::record_brush(brush),
});
}
fn fill_path(&mut self, _path: &BezPath, _brush: &Brush) {}
fn stroke_path(&mut self, _path: &BezPath, _brush: &Brush, _width: f64) {}
fn draw_shadow(
&mut self,
_rect: Rect,
_radii: RoundedRectRadii,
_offset: vello::kurbo::Vec2,
_blur: f64,
_color: Color,
) {
}
fn push_layer(&mut self, _alpha: f32, _clip: Option<&Rect>) {}
fn push_rounded_layer(&mut self, _alpha: f32, _clip: Rect, _radii: RoundedRectRadii) {}
fn pop_layer(&mut self) {}
fn push_transform(&mut self, _affine: Affine) {}
fn pop_transform(&mut self) {}
}
struct VelloTestDrawContext<'a> {
scene: &'a mut vello::Scene,
}
impl VelloTestDrawContext<'_> {
fn fill_shape(&mut self, shape: &impl vello::kurbo::Shape, brush: &Brush) {
match brush {
Brush::Solid(color) => self.scene.fill(
vello::peniko::Fill::NonZero,
Affine::IDENTITY,
color,
None,
shape,
),
Brush::Gradient(gradient) => self.scene.fill(
vello::peniko::Fill::NonZero,
Affine::IDENTITY,
gradient,
None,
shape,
),
}
}
fn stroke_shape(&mut self, shape: &impl vello::kurbo::Shape, brush: &Brush, width: f64) {
let stroke = vello::kurbo::Stroke::new(width);
match brush {
Brush::Solid(color) => {
self.scene
.stroke(&stroke, Affine::IDENTITY, color, None, shape);
}
Brush::Gradient(gradient) => {
self.scene
.stroke(&stroke, Affine::IDENTITY, gradient, None, shape);
}
}
}
}
impl DrawContext for VelloTestDrawContext<'_> {
fn fill_rect(&mut self, rect: Rect, brush: &Brush) {
self.fill_shape(&rect, brush);
}
fn fill_rounded_rect(&mut self, rect: Rect, radii: RoundedRectRadii, brush: &Brush) {
self.fill_shape(&RoundedRect::from_rect(rect, radii), brush);
}
fn stroke_rect(&mut self, rect: Rect, brush: &Brush, width: f64) {
self.stroke_shape(&rect, brush, width);
}
fn stroke_rounded_rect(
&mut self,
rect: Rect,
radii: RoundedRectRadii,
brush: &Brush,
width: f64,
) {
self.stroke_shape(&RoundedRect::from_rect(rect, radii), brush, width);
}
fn stroke_line(&mut self, from: Point, to: Point, brush: &Brush, width: f64) {
self.stroke_shape(&Line::new(from, to), brush, width);
}
fn stroke_circle(&mut self, center: Point, radius: f64, brush: &Brush, width: f64) {
self.stroke_shape(&Circle::new(center, radius), brush, width);
}
fn fill_circle(&mut self, center: Point, radius: f64, brush: &Brush) {
self.fill_shape(&Circle::new(center, radius), brush);
}
fn fill_path(&mut self, path: &BezPath, brush: &Brush) {
self.fill_shape(path, brush);
}
fn stroke_path(&mut self, path: &BezPath, brush: &Brush, width: f64) {
self.stroke_shape(path, brush, width);
}
fn draw_shadow(
&mut self,
rect: Rect,
radii: RoundedRectRadii,
offset: vello::kurbo::Vec2,
blur: f64,
color: Color,
) {
let radius = radii
.as_single_radius()
.expect("vello blurred shadows require uniform corner radii");
self.scene.draw_blurred_rounded_rect(
Affine::IDENTITY,
rect + offset,
color,
radius,
blur.max(0.0),
);
}
fn push_layer(&mut self, alpha: f32, clip: Option<&Rect>) {
let clip = clip
.copied()
.unwrap_or(Rect::new(-1.0e9, -1.0e9, 1.0e9, 1.0e9));
self.scene.push_layer(
vello::peniko::Fill::NonZero,
vello::peniko::BlendMode::default(),
alpha,
Affine::IDENTITY,
&clip,
);
}
fn push_rounded_layer(&mut self, alpha: f32, clip: Rect, radii: RoundedRectRadii) {
let clip = RoundedRect::from_rect(clip, radii);
self.scene.push_layer(
vello::peniko::Fill::NonZero,
vello::peniko::BlendMode::default(),
alpha,
Affine::IDENTITY,
&clip,
);
}
fn pop_layer(&mut self) {
self.scene.pop_layer();
}
fn push_transform(&mut self, _affine: Affine) {
panic!("ripple visual test does not use transforms");
}
fn pop_transform(&mut self) {
panic!("ripple visual test does not use transforms");
}
}
struct RippleVisualRenderer {
renderer: Option<vello::Renderer>,
scene: vello::Scene,
}
impl RippleVisualRenderer {
fn new() -> Self {
Self {
renderer: None,
scene: vello::Scene::new(),
}
}
}
impl GpuView for RippleVisualRenderer {
#[expect(
clippy::future_not_send,
reason = "GpuView runs on the render thread; this future borrows the non-Send `GpuContext`/`Environment`"
)]
async fn setup(&mut self, ctx: &GpuContext<'_>, _env: &mut waterui_core::Environment) {
self.renderer = Some(
vello::Renderer::new(
ctx.device,
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport::area_only(),
num_init_threads: std::num::NonZeroUsize::new(1),
pipeline_cache: None,
},
)
.expect("failed to create ripple visual vello renderer"),
);
}
fn render(&mut self, frame: &mut GpuFrame) {
self.scene.reset();
let bounds = Rect::new(24.0, 24.0, 216.0, 96.0);
let mut draw = VelloTestDrawContext {
scene: &mut self.scene,
};
draw.fill_rounded_rect(
bounds,
20.0.into(),
&Brush::from(Color::new([0.40, 0.31, 0.64, 1.0])),
);
let mut press_waves = PressWaves::EMPTY;
press_waves.push(PressWave {
origin: Some(Point::new(56.0, 48.0)),
progress: 0.72,
opacity: 0.30,
});
state_layer::draw_bounded(
&mut draw,
bounds,
20.0.into(),
Color::new([1.0, 1.0, 1.0, 1.0]),
WidgetInteractionState {
pressed: true,
press_waves,
..WidgetInteractionState::NONE
},
);
self.renderer
.as_mut()
.expect("ripple visual renderer was not set up")
.render_to_texture(
frame.device,
frame.queue,
&self.scene,
&frame.view,
&vello::RenderParams {
base_color: Color::WHITE,
width: frame.width,
height: frame.height,
antialiasing_method: vello::AaConfig::Area,
},
)
.expect("ripple visual vello render failed");
}
}
#[test]
#[ignore = "writes a visual acceptance PNG for direct image review"]
fn material_ripple_visual_snapshot() {
let mut env = waterui_core::Environment::new();
let runtime = pollster::block_on(GpuRuntime::new())
.expect("ripple visual test requires a high-performance GPU");
let output = pollster::block_on(
GpuSurface::new(RippleVisualRenderer::new()).render_offscreen(
&runtime,
OffscreenRenderConfig::new(
OffscreenSize::try_from_pixels(240, 120).expect("static size must be valid"),
)
.format(vello::wgpu::TextureFormat::Rgba8Unorm),
&mut env,
),
)
.expect("ripple visual offscreen render failed");
let output_path = Path::new("target/hydrolysis-m3-visual/material-ripple-solid.png");
std::fs::create_dir_all(
output_path
.parent()
.expect("ripple visual output path must have parent"),
)
.expect("failed to create ripple visual output directory");
output
.save_png(output_path)
.expect("failed to save ripple visual output");
}
}