tessera_ui_basic_components/
side_bar.rs1use std::{
7 sync::Arc,
8 time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::RwLock;
13use tessera_ui::{Color, DimensionValue, Dp, Px, PxPosition, tessera, winit};
14
15use crate::{
16 animation,
17 fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
18 shape_def::{RoundedCorner, Shape},
19 surface::{SurfaceArgsBuilder, surface},
20};
21
22const ANIM_TIME: Duration = Duration::from_millis(300);
23
24#[derive(Default, Clone, Copy)]
28pub enum SideBarStyle {
29 Glass,
32 #[default]
34 Material,
35}
36
37#[derive(Builder)]
39pub struct SideBarProviderArgs {
40 pub on_close_request: Arc<dyn Fn() + Send + Sync>,
45 #[builder(default)]
47 pub style: SideBarStyle,
48}
49
50#[derive(Default)]
51struct SideBarProviderStateInner {
52 is_open: bool,
53 timer: Option<Instant>,
54}
55
56#[derive(Clone, Default)]
75pub struct SideBarProviderState {
76 inner: Arc<RwLock<SideBarProviderStateInner>>,
77}
78
79impl SideBarProviderState {
80 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn open(&self) {
91 let mut inner = self.inner.write();
92 if !inner.is_open {
93 inner.is_open = true;
94 let mut timer = Instant::now();
95 if let Some(old_timer) = inner.timer {
96 let elapsed = old_timer.elapsed();
97 if elapsed < ANIM_TIME {
98 timer += ANIM_TIME - elapsed;
99 }
100 }
101 inner.timer = Some(timer);
102 }
103 }
104
105 pub fn close(&self) {
111 let mut inner = self.inner.write();
112 if inner.is_open {
113 inner.is_open = false;
114 let mut timer = Instant::now();
115 if let Some(old_timer) = inner.timer {
116 let elapsed = old_timer.elapsed();
117 if elapsed < ANIM_TIME {
118 timer += ANIM_TIME - elapsed;
119 }
120 }
121 inner.timer = Some(timer);
122 }
123 }
124
125 pub fn is_open(&self) -> bool {
127 self.inner.read().is_open
128 }
129
130 pub fn is_animating(&self) -> bool {
132 self.inner
133 .read()
134 .timer
135 .is_some_and(|t| t.elapsed() < ANIM_TIME)
136 }
137
138 fn snapshot(&self) -> (bool, Option<Instant>) {
139 let inner = self.inner.read();
140 (inner.is_open, inner.timer)
141 }
142}
143
144fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
146 let raw = match timer {
147 None => 1.0,
148 Some(t) => {
149 let elapsed = t.elapsed();
150 if elapsed >= ANIM_TIME {
151 1.0
152 } else {
153 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
154 }
155 }
156 };
157 animation::easing(raw)
158}
159
160fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
162 if is_open {
163 progress * max_blur_radius
164 } else {
165 max_blur_radius * (1.0 - progress)
166 }
167}
168
169fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
171 if is_open {
172 progress * 0.5
173 } else {
174 0.5 * (1.0 - progress)
175 }
176}
177
178fn compute_side_bar_x(child_width: Px, progress: f32, is_open: bool) -> i32 {
180 let child = child_width.0 as f32;
181 let x = if is_open {
182 -child * (1.0 - progress)
183 } else {
184 -child * progress
185 };
186 x as i32
187}
188
189fn render_glass_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
190 let max_blur_radius = 5.0;
192 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
193 fluid_glass(
194 FluidGlassArgsBuilder::default()
195 .on_click(args.on_close_request.clone())
196 .tint_color(Color::TRANSPARENT)
197 .width(DimensionValue::Fill {
198 min: None,
199 max: None,
200 })
201 .height(DimensionValue::Fill {
202 min: None,
203 max: None,
204 })
205 .dispersion_height(Dp(0.0))
206 .refraction_height(Dp(0.0))
207 .block_input(true)
208 .blur_radius(Dp(blur_radius as f64))
209 .border(None)
210 .shape(Shape::RoundedRectangle {
211 top_left: RoundedCorner::manual(Dp(0.0), 3.0),
212 top_right: RoundedCorner::manual(Dp(0.0), 3.0),
213 bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
214 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
215 })
216 .noise_amount(0.0)
217 .build()
218 .expect("builder construction failed"),
219 None,
220 || {},
221 );
222}
223
224fn render_material_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
225 let scrim_alpha = scrim_alpha_for(progress, is_open);
227 surface(
228 SurfaceArgsBuilder::default()
229 .style(Color::BLACK.with_alpha(scrim_alpha).into())
230 .on_click(args.on_close_request.clone())
231 .width(DimensionValue::Fill {
232 min: None,
233 max: None,
234 })
235 .height(DimensionValue::Fill {
236 min: None,
237 max: None,
238 })
239 .block_input(true)
240 .build()
241 .expect("builder construction failed"),
242 None,
243 || {},
244 );
245}
246
247fn render_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
251 match args.style {
252 SideBarStyle::Glass => render_glass_scrim(args, progress, is_open),
253 SideBarStyle::Material => render_material_scrim(args, progress, is_open),
254 }
255}
256
257fn snapshot_state(state: &SideBarProviderState) -> (bool, Option<Instant>) {
259 state.snapshot()
260}
261
262fn make_keyboard_closure(
264 on_close: Arc<dyn Fn() + Send + Sync>,
265) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
266 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
267 for event in input.keyboard_events.drain(..) {
268 if event.state == winit::event::ElementState::Pressed
269 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
270 event.physical_key
271 {
272 (on_close)();
273 }
274 }
275 })
276}
277
278fn place_side_bar_if_present(
280 input: &tessera_ui::MeasureInput<'_>,
281 state_for_measure: &SideBarProviderState,
282 progress: f32,
283) {
284 if input.children_ids.len() <= 2 {
285 return;
286 }
287
288 let side_bar_id = input.children_ids[2];
289
290 let child_size = match input.measure_child(side_bar_id, input.parent_constraint) {
291 Ok(s) => s,
292 Err(_) => return,
293 };
294
295 let current_is_open = state_for_measure.is_open();
296 let x = compute_side_bar_x(child_size.width, progress, current_is_open);
297 input.place_child(side_bar_id, PxPosition::new(Px(x), Px(0)));
298}
299
300#[tessera]
330pub fn side_bar_provider(
331 args: SideBarProviderArgs,
332 state: SideBarProviderState,
333 main_content: impl FnOnce() + Send + Sync + 'static,
334 side_bar_content: impl FnOnce() + Send + Sync + 'static,
335) {
336 main_content();
338
339 let (is_open, timer_opt) = snapshot_state(&state);
341
342 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
344 return;
345 }
346
347 let on_close_for_keyboard = args.on_close_request.clone();
349 let progress = calc_progress_from_timer(timer_opt.as_ref());
350
351 render_scrim(&args, progress, is_open);
353
354 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
356 input_handler(keyboard_closure);
357
358 side_bar_content_wrapper(args.style, side_bar_content);
360
361 let state_for_measure = state.clone();
363 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
364 let main_content_id = input.children_ids[0];
366 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
367 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
368
369 if input.children_ids.len() > 1 {
371 let scrim_id = input.children_ids[1];
372 input.measure_child(scrim_id, input.parent_constraint)?;
373 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
374 }
375
376 place_side_bar_if_present(input, &state_for_measure, progress);
378
379 Ok(main_content_size)
381 });
382 measure(measure_closure);
383}
384
385#[tessera]
386fn side_bar_content_wrapper(style: SideBarStyle, content: impl FnOnce() + Send + Sync + 'static) {
387 match style {
388 SideBarStyle::Glass => {
389 fluid_glass(
390 FluidGlassArgsBuilder::default()
391 .shape(Shape::RoundedRectangle {
392 top_left: RoundedCorner::manual(Dp(0.0), 3.0),
393 top_right: RoundedCorner::manual(Dp(25.0), 3.0),
394 bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
395 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
396 })
397 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3))
398 .width(DimensionValue::from(Dp(250.0)))
399 .height(tessera_ui::DimensionValue::Fill {
400 min: None,
401 max: None,
402 })
403 .blur_radius(Dp(10.0))
404 .padding(Dp(16.0))
405 .block_input(true)
406 .build()
407 .expect("builder construction failed"),
408 None,
409 content,
410 );
411 }
412 SideBarStyle::Material => {
413 surface(
414 SurfaceArgsBuilder::default()
415 .style(Color::new(0.9, 0.9, 0.9, 1.0).into())
416 .width(DimensionValue::from(Dp(250.0)))
417 .height(tessera_ui::DimensionValue::Fill {
418 min: None,
419 max: None,
420 })
421 .padding(Dp(16.0))
422 .shape(Shape::RoundedRectangle {
423 top_left: RoundedCorner::manual(Dp(0.0), 3.0),
424 top_right: RoundedCorner::manual(Dp(25.0), 3.0),
425 bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
426 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
427 })
428 .block_input(true)
429 .build()
430 .expect("builder construction failed"),
431 None,
432 content,
433 );
434 }
435 }
436}