tessera_ui_basic_components/side_bar.rs
1//! A component that displays content sliding in from the left side of the screen.
2//!
3//! The `side_bar_provider` manages the presentation and dismissal of a "side bar" — a common UI
4//! pattern for showing navigation or contextual controls.
5//!
6//! # Key Components
7//!
8//! * **[`side_bar_provider`]**: The main component function that orchestrates the main content,
9//! the scrim (background overlay), and the side bar content itself.
10//! * **[`SideBarProviderState`]**: A state object that applications create and manipulate to
11//! control the side bar visibility. Call its [`open()`] and [`close()`] methods to change state.
12//! * **[`SideBarProviderArgs`]**: Configuration for the provider, including the visual [`style`]
13//! and the mandatory `on_close_request` callback.
14//! * **[`SideBarStyle`]**: Visual variants for scrim and container (Material or Glass).
15//!
16//! # Behavior
17//!
18//! - The side bar animates smoothly in and out from the left edge.
19//! - A scrim blocks interaction with the main content while the side bar is visible.
20//! - Clicking the scrim or pressing the `Escape` key will invoke the provided `on_close_request`
21//! callback.
22//! # Example
23//!
24//! ```
25//! use std::sync::Arc;
26//! use parking_lot::RwLock;
27//! use tessera_ui::{tessera, Renderer};
28//! use tessera_ui_basic_components::{
29//! side_bar::{side_bar_provider, SideBarProviderArgsBuilder, SideBarProviderState, SideBarStyle},
30//! button::{button, ButtonArgsBuilder},
31//! ripple_state::RippleState,
32//! text::{text, TextArgsBuilder},
33//! };
34//!
35//! // 1. Define an application state to hold the side bar's state.
36//! #[derive(Default)]
37//! struct AppState {
38//! bar_state: Arc<RwLock<SideBarProviderState>>,
39//! ripple_state: Arc<RippleState>,
40//! }
41//!
42//! #[tessera]
43//! fn app(state: Arc<RwLock<AppState>>) {
44//! let bar_state = state.read().bar_state.clone();
45//! // 2. Use the side_bar_provider.
46//! side_bar_provider(
47//! SideBarProviderArgsBuilder::default()
48//! // 3. Provide a callback to handle close requests.
49//! .on_close_request(Arc::new({
50//! let bar_state = bar_state.clone();
51//! move || bar_state.write().close()
52//! }))
53//! .build()
54//! .unwrap(),
55//! bar_state.clone(),
56//! // 4. Define the main content that is always visible.
57//! move || {
58//! button(
59//! ButtonArgsBuilder::default()
60//! .on_click(Arc::new({
61//! let bar_state = bar_state.clone();
62//! move || bar_state.write().open()
63//! }))
64//! .build()
65//! .unwrap(),
66//! state.read().ripple_state.clone(),
67//! || text(TextArgsBuilder::default().text("Open Side Bar".to_string()).build().unwrap())
68//! );
69//! },
70//! // 5. Define the content of the side bar itself.
71//! || {
72//! text(TextArgsBuilder::default().text("This is the side bar!").build().unwrap());
73//! }
74//! );
75//! }
76//! ```
77use std::{
78 sync::Arc,
79 time::{Duration, Instant},
80};
81
82use derive_builder::Builder;
83use parking_lot::RwLock;
84use tessera_ui::{Color, DimensionValue, Dp, Px, PxPosition, tessera, winit};
85
86use crate::{
87 animation,
88 fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
89 shape_def::Shape,
90 surface::{SurfaceArgsBuilder, surface},
91};
92
93const ANIM_TIME: Duration = Duration::from_millis(300);
94
95/// Defines the visual style of the side bar and its scrim.
96///
97/// The scrim is the overlay that appears behind the side bar, covering the main content.
98#[derive(Default, Clone, Copy)]
99pub enum SideBarStyle {
100 /// A translucent glass effect that blurs the content behind it.
101 /// This style may be more costly and is suitable when a blurred backdrop is desired.
102 Glass,
103 /// A simple, semi-transparent dark overlay. This is the default style.
104 #[default]
105 Material,
106}
107
108#[derive(Builder)]
109pub struct SideBarProviderArgs {
110 /// A callback that is invoked when the user requests to close the side bar.
111 ///
112 /// This can be triggered by clicking the scrim or pressing the `Escape` key.
113 /// The callback is expected to call [`SideBarProviderState::close()`].
114 pub on_close_request: Arc<dyn Fn() + Send + Sync>,
115 /// The visual style used by the provider. See [`SideBarStyle`].
116 #[builder(default)]
117 pub style: SideBarStyle,
118}
119
120/// Manages the open/closed state of a [`side_bar_provider`].
121///
122/// This state object must be created by the application and passed to the
123/// [`side_bar_provider`]. It is used to control the visibility of the side bar
124/// programmatically. Wrap in `Arc<RwLock<...>>` for shared access from multiple UI parts.
125#[derive(Default)]
126pub struct SideBarProviderState {
127 is_open: bool,
128 timer: Option<Instant>,
129}
130
131impl SideBarProviderState {
132 /// Initiates the animation to open the side bar.
133 ///
134 /// If the side bar is already open this has no effect. If it is currently
135 /// closing, the animation will reverse direction and start opening from the
136 /// current animated position.
137 pub fn open(&mut self) {
138 if !self.is_open {
139 self.is_open = true;
140 let mut timer = Instant::now();
141 if let Some(old_timer) = self.timer {
142 let elapsed = old_timer.elapsed();
143 if elapsed < ANIM_TIME {
144 timer += ANIM_TIME - elapsed;
145 }
146 }
147 self.timer = Some(timer);
148 }
149 }
150
151 /// Initiates the animation to close the side bar.
152 ///
153 /// If the side bar is already closed this has no effect. If it is currently
154 /// opening, the animation will reverse direction and start closing from the
155 /// current animated position.
156 pub fn close(&mut self) {
157 if self.is_open {
158 self.is_open = false;
159 let mut timer = Instant::now();
160 if let Some(old_timer) = self.timer {
161 let elapsed = old_timer.elapsed();
162 if elapsed < ANIM_TIME {
163 timer += ANIM_TIME - elapsed;
164 }
165 }
166 self.timer = Some(timer);
167 }
168 }
169}
170
171/// Compute eased progress from an optional timer reference.
172fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
173 let raw = match timer {
174 None => 1.0,
175 Some(t) => {
176 let elapsed = t.elapsed();
177 if elapsed >= ANIM_TIME {
178 1.0
179 } else {
180 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
181 }
182 }
183 };
184 animation::easing(raw)
185}
186
187/// Compute blur radius for glass style.
188fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
189 if is_open {
190 progress * max_blur_radius
191 } else {
192 max_blur_radius * (1.0 - progress)
193 }
194}
195
196/// Compute scrim alpha for material style.
197fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
198 if is_open {
199 progress * 0.5
200 } else {
201 0.5 * (1.0 - progress)
202 }
203}
204
205/// Compute X position for side bar placement.
206fn compute_side_bar_x(child_width: Px, progress: f32, is_open: bool) -> i32 {
207 let child = child_width.0 as f32;
208 let x = if is_open {
209 -child * (1.0 - progress)
210 } else {
211 -child * progress
212 };
213 x as i32
214}
215
216fn render_glass_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
217 // Glass scrim: compute blur radius and render using fluid_glass.
218 let max_blur_radius = 5.0;
219 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
220 fluid_glass(
221 FluidGlassArgsBuilder::default()
222 .on_click(args.on_close_request.clone())
223 .tint_color(Color::TRANSPARENT)
224 .width(DimensionValue::Fill {
225 min: None,
226 max: None,
227 })
228 .height(DimensionValue::Fill {
229 min: None,
230 max: None,
231 })
232 .dispersion_height(0.0)
233 .refraction_height(0.0)
234 .block_input(true)
235 .blur_radius(blur_radius)
236 .border(None)
237 .shape(Shape::RoundedRectangle {
238 top_left: Dp(0.0),
239 top_right: Dp(0.0),
240 bottom_right: Dp(0.0),
241 bottom_left: Dp(0.0),
242 g2_k_value: 3.0,
243 })
244 .noise_amount(0.0)
245 .build()
246 .unwrap(),
247 None,
248 || {},
249 );
250}
251
252fn render_material_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
253 // Material scrim: compute alpha and render a simple dark surface.
254 let scrim_alpha = scrim_alpha_for(progress, is_open);
255 surface(
256 SurfaceArgsBuilder::default()
257 .style(Color::BLACK.with_alpha(scrim_alpha).into())
258 .on_click(args.on_close_request.clone())
259 .width(DimensionValue::Fill {
260 min: None,
261 max: None,
262 })
263 .height(DimensionValue::Fill {
264 min: None,
265 max: None,
266 })
267 .block_input(true)
268 .build()
269 .unwrap(),
270 None,
271 || {},
272 );
273}
274
275/// Render scrim according to configured style.
276/// Delegates actual rendering to small, focused helpers to keep the
277/// main API surface concise and improve readability.
278fn render_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
279 match args.style {
280 SideBarStyle::Glass => render_glass_scrim(args, progress, is_open),
281 SideBarStyle::Material => render_material_scrim(args, progress, is_open),
282 }
283}
284
285/// Snapshot provider state to reduce lock duration and centralize access.
286fn snapshot_state(state: &Arc<RwLock<SideBarProviderState>>) -> (bool, Option<Instant>) {
287 let s = state.read();
288 (s.is_open, s.timer)
289}
290
291/// Create the keyboard handler closure used to close the sheet on Escape.
292fn make_keyboard_closure(
293 on_close: Arc<dyn Fn() + Send + Sync>,
294) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
295 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
296 for event in input.keyboard_events.drain(..) {
297 if event.state == winit::event::ElementState::Pressed
298 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
299 event.physical_key
300 {
301 (on_close)();
302 }
303 }
304 })
305}
306
307/// Place side bar if present. Extracted to reduce complexity of the parent function.
308fn place_side_bar_if_present(
309 input: &tessera_ui::MeasureInput<'_>,
310 state_for_measure: &Arc<RwLock<SideBarProviderState>>,
311 progress: f32,
312) {
313 if input.children_ids.len() <= 2 {
314 return;
315 }
316
317 let side_bar_id = input.children_ids[2];
318
319 let child_size = match input.measure_child(side_bar_id, input.parent_constraint) {
320 Ok(s) => s,
321 Err(_) => return,
322 };
323
324 let current_is_open = state_for_measure.read().is_open;
325 let x = compute_side_bar_x(child_size.width, progress, current_is_open);
326 input.place_child(side_bar_id, PxPosition::new(Px(x), Px(0)));
327}
328
329/// Renders a side bar UI group, managing its animation, scrim, keyboard handling and content.
330///
331/// This is the primary function to create a side bar. Call it inside a component that owns or
332/// has access to the application's shared state. The provider renders the main content first,
333/// then the scrim (overlay) and the side bar when the `state` indicates visibility.
334///
335/// # Arguments
336///
337/// - `args`: Configuration options including the `on_close_request` callback and visual `style`.
338/// See [`SideBarProviderArgs`].
339/// - `state`: Shared state controlling open/closed animation timing. See [`SideBarProviderState`].
340/// - `main_content`: Closure rendering the always-visible main UI (background for the side bar).
341/// - `side_bar_content`: Closure rendering the side bar's content.
342///
343/// The provider registers a keyboard handler that invokes the provided `on_close_request` when
344/// the `Escape` key is pressed. Clicking the scrim also triggers the same callback.
345#[tessera]
346pub fn side_bar_provider(
347 args: SideBarProviderArgs,
348 state: Arc<RwLock<SideBarProviderState>>,
349 main_content: impl FnOnce() + Send + Sync + 'static,
350 side_bar_content: impl FnOnce() + Send + Sync + 'static,
351) {
352 // Render main content first.
353 main_content();
354
355 // Snapshot state once to minimize locking overhead.
356 let (is_open, timer_opt) = snapshot_state(&state);
357
358 // Fast exit when nothing to render.
359 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
360 return;
361 }
362
363 // Prepare values used by rendering and placement.
364 let on_close_for_keyboard = args.on_close_request.clone();
365 let progress = calc_progress_from_timer(timer_opt.as_ref());
366
367 // Render the configured scrim.
368 render_scrim(&args, progress, is_open);
369
370 // Register keyboard handler (close on Escape).
371 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
372 input_handler(keyboard_closure);
373
374 // Render side bar content with computed alpha.
375 side_bar_content_wrapper(args.style, side_bar_content);
376
377 // Measurement: place main content, scrim and side bar.
378 let state_for_measure = state.clone();
379 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
380 // Place main content at origin.
381 let main_content_id = input.children_ids[0];
382 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
383 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
384
385 // Place scrim (if present) covering the whole parent.
386 if input.children_ids.len() > 1 {
387 let scrim_id = input.children_ids[1];
388 input.measure_child(scrim_id, input.parent_constraint)?;
389 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
390 }
391
392 // Place side bar (if present) using extracted helper.
393 place_side_bar_if_present(input, &state_for_measure, progress);
394
395 // Return the main content size (best-effort; unwrap used above to satisfy closure type).
396 Ok(main_content_size)
397 });
398 measure(measure_closure);
399}
400
401#[tessera]
402fn side_bar_content_wrapper(style: SideBarStyle, content: impl FnOnce() + Send + Sync + 'static) {
403 match style {
404 SideBarStyle::Glass => {
405 fluid_glass(
406 FluidGlassArgsBuilder::default()
407 .shape(Shape::RoundedRectangle {
408 top_left: Dp(0.0),
409 top_right: Dp(25.0),
410 bottom_right: Dp(25.0),
411 bottom_left: Dp(0.0),
412 g2_k_value: 3.0,
413 })
414 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3))
415 .width(DimensionValue::from(Dp(250.0)))
416 .height(tessera_ui::DimensionValue::Fill {
417 min: None,
418 max: None,
419 })
420 .blur_radius(10.0)
421 .padding(Dp(16.0))
422 .block_input(true)
423 .build()
424 .unwrap(),
425 None,
426 content,
427 );
428 }
429 SideBarStyle::Material => {
430 surface(
431 SurfaceArgsBuilder::default()
432 .style(Color::new(0.9, 0.9, 0.9, 1.0).into())
433 .width(DimensionValue::from(Dp(250.0)))
434 .height(tessera_ui::DimensionValue::Fill {
435 min: None,
436 max: None,
437 })
438 .padding(Dp(16.0))
439 .shape(Shape::RoundedRectangle {
440 top_left: Dp(0.0),
441 top_right: Dp(25.0),
442 bottom_right: Dp(25.0),
443 bottom_left: Dp(0.0),
444 g2_k_value: 3.0,
445 })
446 .block_input(true)
447 .build()
448 .unwrap(),
449 None,
450 content,
451 );
452 }
453 }
454}