tessera_ui_basic_components/surface.rs
1//! Provides a flexible, customizable surface container component for UI elements.
2//!
3//! This module defines the [`surface`] component and its configuration via [`SurfaceArgs`].
4//! The surface acts as a visual and interactive container, supporting background color,
5//! shape, shadow, border, padding, and optional ripple effects for user interaction.
6//!
7//! Typical use cases include wrapping content to visually separate it from the background,
8//! providing elevation or emphasis, and enabling interactive feedback (e.g., ripple on click).
9//! It is commonly used as the foundational layer for buttons, dialogs, editors, and other
10//! interactive or visually distinct UI elements.
11//!
12//! The surface can be configured for both static and interactive scenarios, with support for
13//! hover and click callbacks, making it suitable for a wide range of UI composition needs.
14
15use std::sync::Arc;
16
17use derive_builder::Builder;
18use tessera_ui::{
19 Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType, Px,
20 PxPosition, winit::window::CursorIcon,
21};
22use tessera_ui_macros::tessera;
23
24use crate::{
25 padding_utils::remove_padding_from_dimension,
26 pipelines::{RippleProps, ShadowProps, ShapeCommand},
27 pos_misc::is_position_in_component,
28 ripple_state::RippleState,
29 shape_def::Shape,
30};
31
32///
33/// Arguments for the [`surface`] component.
34///
35/// This struct defines the configurable properties for the [`surface`] container,
36/// which provides a background, optional shadow, border, shape, and interactive
37/// ripple effect. The surface is commonly used to wrap content and visually
38/// separate it from the background or other UI elements.
39///
40/// # Fields
41///
42/// - `color`: The fill color of the surface (RGBA). Defaults to a blue-gray.
43/// - `hover_color`: The color displayed when the surface is hovered. If `None`, no hover effect is applied.
44/// - `shape`: The geometric shape of the surface (e.g., rounded rectangle, ellipse).
45/// - `shadow`: Optional shadow properties for elevation effects.
46/// - `padding`: Padding inside the surface, applied to all sides.
47/// - `width`: Optional explicit width constraint. If `None`, wraps content.
48/// - `height`: Optional explicit height constraint. If `None`, wraps content.
49/// - `border_width`: Width of the border. If greater than 0, an outline is drawn.
50/// - `border_color`: Optional color for the border. If `None` and `border_width > 0`, uses `color`.
51/// - `on_click`: Optional callback for click events. If set, the surface becomes interactive and shows a ripple effect.
52/// - `ripple_color`: The color of the ripple effect for interactive surfaces.
53///
54/// # Example
55///
56/// ```
57/// use std::sync::Arc;
58/// use tessera_ui::{Color, Dp};
59/// use tessera_ui_basic_components::{
60/// pipelines::ShadowProps,
61/// ripple_state::RippleState,
62/// surface::{surface, SurfaceArgs},
63/// };
64///
65/// let ripple_state = Arc::new(RippleState::new());
66/// surface(
67/// SurfaceArgs {
68/// color: Color::from_rgb(0.95, 0.95, 1.0),
69/// shadow: Some(ShadowProps::default()),
70/// padding: Dp(16.0),
71/// border_width: 1.0,
72/// border_color: Some(Color::from_rgb(0.7, 0.7, 0.9)),
73/// ..Default::default()
74/// },
75/// Some(ripple_state.clone()),
76/// || {},
77/// );
78/// ```
79#[derive(Builder, Clone)]
80#[builder(pattern = "owned")]
81pub struct SurfaceArgs {
82 /// The fill color of the surface (RGBA).
83 #[builder(default = "Color::new(0.4745, 0.5255, 0.7961, 1.0)")]
84 pub color: Color,
85 /// The hover color of the surface (RGBA). If None, no hover effect is applied.
86 #[builder(default)]
87 pub hover_color: Option<Color>,
88 /// The shape of the surface (e.g., rounded rectangle, ellipse).
89 #[builder(default)]
90 pub shape: Shape,
91 /// The shadow properties of the surface.
92 #[builder(default)]
93 pub shadow: Option<ShadowProps>,
94 /// The padding inside the surface.
95 #[builder(default = "Dp(0.0)")]
96 pub padding: Dp,
97 /// Optional explicit width behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
98 #[builder(default, setter(strip_option))]
99 pub width: Option<DimensionValue>,
100 /// Optional explicit height behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
101 #[builder(default, setter(strip_option))]
102 pub height: Option<DimensionValue>,
103 /// Width of the border. If > 0, an outline will be drawn.
104 #[builder(default = "0.0")]
105 pub border_width: f32,
106 /// Optional color for the border (RGBA). If None and border_width > 0, `color` will be used.
107 #[builder(default)]
108 pub border_color: Option<Color>,
109 /// Optional click callback function. If provided, surface becomes interactive with ripple effect.
110 #[builder(default)]
111 pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
112 /// The ripple color (RGB) for interactive surfaces.
113 #[builder(default = "Color::from_rgb(1.0, 1.0, 1.0)")]
114 pub ripple_color: Color,
115 /// Whether the surface should block all input events.
116 #[builder(default = "false")]
117 pub block_input: bool,
118}
119
120// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
121impl Default for SurfaceArgs {
122 fn default() -> Self {
123 SurfaceArgsBuilder::default().build().unwrap()
124 }
125}
126
127///
128/// A basic container component that provides a customizable background, optional shadow,
129/// border, shape, and interactive ripple effect. The surface is typically used to wrap
130/// content and visually separate it from the background or other UI elements.
131///
132/// If `args.on_click` is set, the surface becomes interactive and displays a ripple
133/// animation on click. In this case, a [`RippleState`] must be provided to manage
134/// the ripple effect and hover state.
135///
136/// # Parameters
137///
138/// - `args`: [`SurfaceArgs`] struct specifying appearance, layout, and interaction.
139/// - `ripple_state`: Optional [`RippleState`] for interactive surfaces. Required if `on_click` is set.
140/// - `child`: Closure that builds the child content inside the surface.
141///
142/// # Example
143///
144/// ```
145/// use std::sync::Arc;
146/// use tessera_ui::{Color, Dp};
147/// use tessera_ui_basic_components::{
148/// pipelines::ShadowProps,
149/// surface::{surface, SurfaceArgs},
150/// text::text,
151/// };
152///
153/// surface(
154/// SurfaceArgs {
155/// color: Color::from_rgb(1.0, 1.0, 1.0),
156/// shadow: Some(ShadowProps::default()),
157/// padding: Dp(12.0),
158/// ..Default::default()
159/// },
160/// None,
161/// || {
162/// text("Content in a surface".to_string());
163/// },
164/// );
165/// ```
166///
167#[tessera]
168pub fn surface(args: SurfaceArgs, ripple_state: Option<Arc<RippleState>>, child: impl FnOnce()) {
169 (child)();
170 let ripple_state_for_measure = ripple_state.clone();
171 let args_measure_clone = args.clone();
172 let args_for_handler = args.clone();
173
174 measure(Box::new(move |input| {
175 // Determine surface's intrinsic constraint based on args
176 let surface_intrinsic_width = args_measure_clone.width.unwrap_or(DimensionValue::Wrap {
177 min: None,
178 max: None,
179 });
180 let surface_intrinsic_height = args_measure_clone.height.unwrap_or(DimensionValue::Wrap {
181 min: None,
182 max: None,
183 });
184 let surface_intrinsic_constraint =
185 Constraint::new(surface_intrinsic_width, surface_intrinsic_height);
186 // Merge with parent_constraint to get effective_surface_constraint
187 let effective_surface_constraint =
188 surface_intrinsic_constraint.merge(input.parent_constraint);
189 // Determine constraint for the child
190 let child_constraint = Constraint::new(
191 remove_padding_from_dimension(
192 effective_surface_constraint.width,
193 args_measure_clone.padding.into(),
194 ),
195 remove_padding_from_dimension(
196 effective_surface_constraint.height,
197 args_measure_clone.padding.into(),
198 ),
199 );
200 // Measure the child with the computed constraint
201 let child_measurement = if !input.children_ids.is_empty() {
202 let child_measurement =
203 input.measure_child(input.children_ids[0], &child_constraint)?;
204 // place the child
205 input.place_child(
206 input.children_ids[0],
207 PxPosition {
208 x: args.padding.into(),
209 y: args.padding.into(),
210 },
211 );
212 child_measurement
213 } else {
214 ComputedData {
215 width: Px(0),
216 height: Px(0),
217 }
218 };
219 // Add drawable for the surface
220 let is_hovered = ripple_state_for_measure
221 .as_ref()
222 .map(|state| state.is_hovered())
223 .unwrap_or(false);
224
225 let effective_color = if is_hovered && args_measure_clone.hover_color.is_some() {
226 args_measure_clone.hover_color.unwrap()
227 } else {
228 args_measure_clone.color
229 };
230
231 let drawable = if args_measure_clone.on_click.is_some() {
232 // Interactive surface with ripple effect
233 let ripple_props = if let Some(ref state) = ripple_state_for_measure {
234 if let Some((progress, click_pos)) = state.get_animation_progress() {
235 let radius = progress; // Expand from 0 to 1
236 let alpha = (1.0 - progress) * 0.3; // Fade out
237
238 RippleProps {
239 center: click_pos,
240 radius,
241 alpha,
242 color: args_measure_clone.ripple_color,
243 }
244 } else {
245 RippleProps::default()
246 }
247 } else {
248 RippleProps::default()
249 };
250
251 match args_measure_clone.shape {
252 Shape::RoundedRectangle {
253 corner_radius,
254 g2_k_value,
255 } => {
256 if args_measure_clone.border_width > 0.0 {
257 ShapeCommand::RippleOutlinedRect {
258 color: args_measure_clone.border_color.unwrap_or(effective_color),
259 corner_radius,
260 g2_k_value,
261 shadow: args_measure_clone.shadow,
262 border_width: args_measure_clone.border_width,
263 ripple: ripple_props,
264 }
265 } else {
266 ShapeCommand::RippleRect {
267 color: effective_color,
268 corner_radius,
269 g2_k_value,
270 shadow: args_measure_clone.shadow,
271 ripple: ripple_props,
272 }
273 }
274 }
275 Shape::Ellipse => {
276 if args_measure_clone.border_width > 0.0 {
277 ShapeCommand::RippleOutlinedRect {
278 color: args_measure_clone.border_color.unwrap_or(effective_color),
279 corner_radius: -1.0, // Use negative radius to signify ellipse
280 g2_k_value: 0.0, // Just for compatibility, not used in ellipse
281 shadow: args_measure_clone.shadow,
282 border_width: args_measure_clone.border_width,
283 ripple: ripple_props,
284 }
285 } else {
286 ShapeCommand::RippleRect {
287 color: effective_color,
288 corner_radius: -1.0, // Use negative radius to signify ellipse
289 g2_k_value: 0.0, // Just for compatibility, not used in ellipse
290 shadow: args_measure_clone.shadow,
291 ripple: ripple_props,
292 }
293 }
294 }
295 }
296 } else {
297 // Non-interactive surface
298 match args_measure_clone.shape {
299 Shape::RoundedRectangle {
300 corner_radius,
301 g2_k_value,
302 } => {
303 if args_measure_clone.border_width > 0.0 {
304 ShapeCommand::OutlinedRect {
305 color: args_measure_clone.border_color.unwrap_or(effective_color),
306 corner_radius,
307 g2_k_value,
308 shadow: args_measure_clone.shadow,
309 border_width: args_measure_clone.border_width,
310 }
311 } else {
312 ShapeCommand::Rect {
313 color: effective_color,
314 corner_radius,
315 g2_k_value,
316 shadow: args_measure_clone.shadow,
317 }
318 }
319 }
320 Shape::Ellipse => {
321 if args_measure_clone.border_width > 0.0 {
322 ShapeCommand::OutlinedEllipse {
323 color: args_measure_clone.border_color.unwrap_or(effective_color),
324 shadow: args_measure_clone.shadow,
325 border_width: args_measure_clone.border_width,
326 }
327 } else {
328 ShapeCommand::Ellipse {
329 color: effective_color,
330 shadow: args_measure_clone.shadow,
331 }
332 }
333 }
334 }
335 };
336
337 input.metadata_mut().push_draw_command(drawable);
338
339 // Calculate the final size of the surface
340 let padding_px: Px = args_measure_clone.padding.into();
341 let min_width = child_measurement.width + padding_px * 2;
342 let min_height = child_measurement.height + padding_px * 2;
343 let width = match effective_surface_constraint.width {
344 DimensionValue::Fixed(value) => value,
345 DimensionValue::Wrap { min, max } => min
346 .unwrap_or(Px(0))
347 .max(min_width)
348 .min(max.unwrap_or(Px::MAX)),
349 DimensionValue::Fill { min, max } => max
350 .expect("Seems that you are trying to fill an infinite width, which is not allowed")
351 .max(min_width)
352 .max(min.unwrap_or(Px(0))),
353 };
354 let height = match effective_surface_constraint.height {
355 DimensionValue::Fixed(value) => value,
356 DimensionValue::Wrap { min, max } => min
357 .unwrap_or(Px(0))
358 .max(min_height)
359 .min(max.unwrap_or(Px::MAX)),
360 DimensionValue::Fill { min, max } => max
361 .expect(
362 "Seems that you are trying to fill an infinite height, which is not allowed",
363 )
364 .max(min_height)
365 .max(min.unwrap_or(Px(0))),
366 };
367 Ok(ComputedData { width, height })
368 }));
369
370 // Event handling for interactive surfaces
371 if args.on_click.is_some() {
372 let args_for_handler = args.clone();
373 let state_for_handler = ripple_state;
374 state_handler(Box::new(move |mut input| {
375 let size = input.computed_data;
376 let cursor_pos_option = input.cursor_position_rel;
377 let is_cursor_in_surface = cursor_pos_option
378 .map(|pos| is_position_in_component(size, pos))
379 .unwrap_or(false);
380
381 // Update hover state
382 if let Some(ref state) = state_for_handler {
383 state.set_hovered(is_cursor_in_surface);
384 }
385
386 // Set cursor to pointer if hovered and clickable
387 if is_cursor_in_surface && args_for_handler.on_click.is_some() {
388 input.requests.cursor_icon = CursorIcon::Pointer;
389 }
390
391 // Handle mouse events
392 if is_cursor_in_surface {
393 // Check for mouse press events to start ripple
394 let press_events: Vec<_> = input
395 .cursor_events
396 .iter()
397 .filter(|event| {
398 matches!(
399 event.content,
400 CursorEventContent::Pressed(PressKeyEventType::Left)
401 )
402 })
403 .collect();
404
405 // Check for mouse release events (click)
406 let release_events: Vec<_> = input
407 .cursor_events
408 .iter()
409 .filter(|event| {
410 matches!(
411 event.content,
412 CursorEventContent::Released(PressKeyEventType::Left)
413 )
414 })
415 .collect();
416
417 if !press_events.is_empty()
418 && let (Some(cursor_pos), Some(state)) =
419 (cursor_pos_option, state_for_handler.as_ref())
420 {
421 // Convert cursor position to normalized coordinates [-0.5, 0.5]
422 let normalized_x = (cursor_pos.x.to_f32() / size.width.to_f32()) - 0.5;
423 let normalized_y = (cursor_pos.y.to_f32() / size.height.to_f32()) - 0.5;
424
425 // Start ripple animation
426 state.start_animation([normalized_x, normalized_y]);
427 }
428
429 if !release_events.is_empty() {
430 // Trigger click callback
431 if let Some(ref on_click) = args_for_handler.on_click {
432 on_click();
433 }
434 }
435
436 // Block all events to prevent propagation
437 if args_for_handler.block_input {
438 input.block_all();
439 }
440 }
441 }));
442 } else {
443 // Non-interactive surface, still block all cursor events inside the surface
444 state_handler(Box::new(move |mut input| {
445 let size = input.computed_data;
446 let cursor_pos_option = input.cursor_position_rel;
447 let is_cursor_in_surface = cursor_pos_option
448 .map(|pos| is_position_in_component(size, pos))
449 .unwrap_or(false);
450 if args_for_handler.block_input && is_cursor_in_surface {
451 input.block_all();
452 }
453 }));
454 }
455}