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::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)]
38pub struct SideBarProviderArgs {
39 pub on_close_request: Arc<dyn Fn() + Send + Sync>,
44 #[builder(default)]
46 pub style: SideBarStyle,
47}
48
49#[derive(Default)]
55struct SideBarProviderStateInner {
56 is_open: bool,
57 timer: Option<Instant>,
58}
59
60#[derive(Clone, Default)]
61pub struct SideBarProviderState {
62 inner: Arc<RwLock<SideBarProviderStateInner>>,
63}
64
65impl SideBarProviderState {
66 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn open(&self) {
77 let mut inner = self.inner.write();
78 if !inner.is_open {
79 inner.is_open = true;
80 let mut timer = Instant::now();
81 if let Some(old_timer) = inner.timer {
82 let elapsed = old_timer.elapsed();
83 if elapsed < ANIM_TIME {
84 timer += ANIM_TIME - elapsed;
85 }
86 }
87 inner.timer = Some(timer);
88 }
89 }
90
91 pub fn close(&self) {
97 let mut inner = self.inner.write();
98 if inner.is_open {
99 inner.is_open = false;
100 let mut timer = Instant::now();
101 if let Some(old_timer) = inner.timer {
102 let elapsed = old_timer.elapsed();
103 if elapsed < ANIM_TIME {
104 timer += ANIM_TIME - elapsed;
105 }
106 }
107 inner.timer = Some(timer);
108 }
109 }
110
111 pub fn is_open(&self) -> bool {
113 self.inner.read().is_open
114 }
115
116 pub fn is_animating(&self) -> bool {
118 self.inner
119 .read()
120 .timer
121 .is_some_and(|t| t.elapsed() < ANIM_TIME)
122 }
123
124 fn snapshot(&self) -> (bool, Option<Instant>) {
125 let inner = self.inner.read();
126 (inner.is_open, inner.timer)
127 }
128}
129
130fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
132 let raw = match timer {
133 None => 1.0,
134 Some(t) => {
135 let elapsed = t.elapsed();
136 if elapsed >= ANIM_TIME {
137 1.0
138 } else {
139 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
140 }
141 }
142 };
143 animation::easing(raw)
144}
145
146fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
148 if is_open {
149 progress * max_blur_radius
150 } else {
151 max_blur_radius * (1.0 - progress)
152 }
153}
154
155fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
157 if is_open {
158 progress * 0.5
159 } else {
160 0.5 * (1.0 - progress)
161 }
162}
163
164fn compute_side_bar_x(child_width: Px, progress: f32, is_open: bool) -> i32 {
166 let child = child_width.0 as f32;
167 let x = if is_open {
168 -child * (1.0 - progress)
169 } else {
170 -child * progress
171 };
172 x as i32
173}
174
175fn render_glass_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
176 let max_blur_radius = 5.0;
178 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
179 fluid_glass(
180 FluidGlassArgsBuilder::default()
181 .on_click(args.on_close_request.clone())
182 .tint_color(Color::TRANSPARENT)
183 .width(DimensionValue::Fill {
184 min: None,
185 max: None,
186 })
187 .height(DimensionValue::Fill {
188 min: None,
189 max: None,
190 })
191 .dispersion_height(Dp(0.0))
192 .refraction_height(Dp(0.0))
193 .block_input(true)
194 .blur_radius(Dp(blur_radius as f64))
195 .border(None)
196 .shape(Shape::RoundedRectangle {
197 top_left: Dp(0.0),
198 top_right: Dp(0.0),
199 bottom_right: Dp(0.0),
200 bottom_left: Dp(0.0),
201 g2_k_value: 3.0,
202 })
203 .noise_amount(0.0)
204 .build()
205 .unwrap(),
206 None,
207 || {},
208 );
209}
210
211fn render_material_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
212 let scrim_alpha = scrim_alpha_for(progress, is_open);
214 surface(
215 SurfaceArgsBuilder::default()
216 .style(Color::BLACK.with_alpha(scrim_alpha).into())
217 .on_click(args.on_close_request.clone())
218 .width(DimensionValue::Fill {
219 min: None,
220 max: None,
221 })
222 .height(DimensionValue::Fill {
223 min: None,
224 max: None,
225 })
226 .block_input(true)
227 .build()
228 .unwrap(),
229 None,
230 || {},
231 );
232}
233
234fn render_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
238 match args.style {
239 SideBarStyle::Glass => render_glass_scrim(args, progress, is_open),
240 SideBarStyle::Material => render_material_scrim(args, progress, is_open),
241 }
242}
243
244fn snapshot_state(state: &SideBarProviderState) -> (bool, Option<Instant>) {
246 state.snapshot()
247}
248
249fn make_keyboard_closure(
251 on_close: Arc<dyn Fn() + Send + Sync>,
252) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
253 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
254 for event in input.keyboard_events.drain(..) {
255 if event.state == winit::event::ElementState::Pressed
256 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
257 event.physical_key
258 {
259 (on_close)();
260 }
261 }
262 })
263}
264
265fn place_side_bar_if_present(
267 input: &tessera_ui::MeasureInput<'_>,
268 state_for_measure: &SideBarProviderState,
269 progress: f32,
270) {
271 if input.children_ids.len() <= 2 {
272 return;
273 }
274
275 let side_bar_id = input.children_ids[2];
276
277 let child_size = match input.measure_child(side_bar_id, input.parent_constraint) {
278 Ok(s) => s,
279 Err(_) => return,
280 };
281
282 let current_is_open = state_for_measure.is_open();
283 let x = compute_side_bar_x(child_size.width, progress, current_is_open);
284 input.place_child(side_bar_id, PxPosition::new(Px(x), Px(0)));
285}
286
287#[tessera]
317pub fn side_bar_provider(
318 args: SideBarProviderArgs,
319 state: SideBarProviderState,
320 main_content: impl FnOnce() + Send + Sync + 'static,
321 side_bar_content: impl FnOnce() + Send + Sync + 'static,
322) {
323 main_content();
325
326 let (is_open, timer_opt) = snapshot_state(&state);
328
329 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
331 return;
332 }
333
334 let on_close_for_keyboard = args.on_close_request.clone();
336 let progress = calc_progress_from_timer(timer_opt.as_ref());
337
338 render_scrim(&args, progress, is_open);
340
341 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
343 input_handler(keyboard_closure);
344
345 side_bar_content_wrapper(args.style, side_bar_content);
347
348 let state_for_measure = state.clone();
350 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
351 let main_content_id = input.children_ids[0];
353 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
354 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
355
356 if input.children_ids.len() > 1 {
358 let scrim_id = input.children_ids[1];
359 input.measure_child(scrim_id, input.parent_constraint)?;
360 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
361 }
362
363 place_side_bar_if_present(input, &state_for_measure, progress);
365
366 Ok(main_content_size)
368 });
369 measure(measure_closure);
370}
371
372#[tessera]
373fn side_bar_content_wrapper(style: SideBarStyle, content: impl FnOnce() + Send + Sync + 'static) {
374 match style {
375 SideBarStyle::Glass => {
376 fluid_glass(
377 FluidGlassArgsBuilder::default()
378 .shape(Shape::RoundedRectangle {
379 top_left: Dp(0.0),
380 top_right: Dp(25.0),
381 bottom_right: Dp(25.0),
382 bottom_left: Dp(0.0),
383 g2_k_value: 3.0,
384 })
385 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3))
386 .width(DimensionValue::from(Dp(250.0)))
387 .height(tessera_ui::DimensionValue::Fill {
388 min: None,
389 max: None,
390 })
391 .blur_radius(Dp(10.0))
392 .padding(Dp(16.0))
393 .block_input(true)
394 .build()
395 .unwrap(),
396 None,
397 content,
398 );
399 }
400 SideBarStyle::Material => {
401 surface(
402 SurfaceArgsBuilder::default()
403 .style(Color::new(0.9, 0.9, 0.9, 1.0).into())
404 .width(DimensionValue::from(Dp(250.0)))
405 .height(tessera_ui::DimensionValue::Fill {
406 min: None,
407 max: None,
408 })
409 .padding(Dp(16.0))
410 .shape(Shape::RoundedRectangle {
411 top_left: Dp(0.0),
412 top_right: Dp(25.0),
413 bottom_right: Dp(25.0),
414 bottom_left: Dp(0.0),
415 g2_k_value: 3.0,
416 })
417 .block_input(true)
418 .build()
419 .unwrap(),
420 None,
421 content,
422 );
423 }
424 }
425}