Skip to main content

gpui_component/sidebar/
mod.rs

1use crate::{
2    ActiveTheme, Collapsible, Icon, IconName, Side, Sizable, StyledExt,
3    button::{Button, ButtonVariants},
4    h_flex,
5    scroll::ScrollableElement,
6    v_flex,
7};
8use gpui::{
9    AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, ElementId,
10    InteractiveElement as _, IntoElement, Length, ListAlignment, ListState, ParentElement, Pixels,
11    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, list, prelude::FluentBuilder,
12    px,
13};
14use std::{rc::Rc, time::Duration};
15
16use crate::animation::{EffectTransition, ease_in_out_cubic};
17
18mod footer;
19mod group;
20mod header;
21mod menu;
22pub use footer::*;
23pub use group::*;
24pub use header::*;
25pub use menu::*;
26
27const DEFAULT_WIDTH: Pixels = px(255.);
28const COLLAPSED_WIDTH: Pixels = px(48.);
29const SIDEBAR_TRANSITION_DURATION: Duration = Duration::from_millis(200);
30
31/// The way a [`Sidebar`] behaves when it is collapsed.
32///
33/// This follows the shadcn/ui sidebar modes:
34/// - [`SidebarCollapsible::Icon`] collapses the sidebar to icon width.
35/// - [`SidebarCollapsible::Offcanvas`] slides the sidebar out and releases its layout width.
36/// - [`SidebarCollapsible::None`] keeps the sidebar expanded and ignores collapsed state.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum SidebarCollapsible {
39    /// Collapse the sidebar to icon width.
40    #[default]
41    Icon,
42    /// Collapse the sidebar completely out of the layout.
43    Offcanvas,
44    /// Disable sidebar collapse.
45    None,
46}
47
48impl From<bool> for SidebarCollapsible {
49    fn from(collapsible: bool) -> Self {
50        if collapsible { Self::Icon } else { Self::None }
51    }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55enum SidebarWrapperLayout {
56    None,
57    Static { width: Pixels },
58    Animated { target_width: Pixels },
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
62struct SidebarLayout {
63    icon_collapsed: bool,
64    offcanvas_collapsed: bool,
65    align_child_to_end: bool,
66    wrapper: SidebarWrapperLayout,
67}
68
69impl SidebarLayout {
70    fn new(
71        collapsible: SidebarCollapsible,
72        collapsed: bool,
73        expanded_width: Option<Pixels>,
74        side: Side,
75    ) -> Self {
76        let collapsed = collapsed && collapsible != SidebarCollapsible::None;
77        let wrapper = match collapsible {
78            SidebarCollapsible::None => SidebarWrapperLayout::None,
79            SidebarCollapsible::Icon => match expanded_width {
80                Some(expanded_width) => SidebarWrapperLayout::Animated {
81                    target_width: if collapsed {
82                        COLLAPSED_WIDTH
83                    } else {
84                        expanded_width
85                    },
86                },
87                None => SidebarWrapperLayout::None,
88            },
89            SidebarCollapsible::Offcanvas => match (expanded_width, collapsed) {
90                (Some(_), true) => SidebarWrapperLayout::Animated {
91                    target_width: px(0.),
92                },
93                (Some(expanded_width), false) => SidebarWrapperLayout::Animated {
94                    target_width: expanded_width,
95                },
96                (None, true) => SidebarWrapperLayout::Static { width: px(0.) },
97                (None, false) => SidebarWrapperLayout::None,
98            },
99        };
100        let align_child_to_end = match collapsible {
101            SidebarCollapsible::Offcanvas => side.is_left(),
102            _ => side.is_right(),
103        };
104
105        Self {
106            icon_collapsed: collapsed && collapsible == SidebarCollapsible::Icon,
107            offcanvas_collapsed: collapsed && collapsible == SidebarCollapsible::Offcanvas,
108            align_child_to_end,
109            wrapper,
110        }
111    }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq)]
115struct SidebarAnimationState {
116    from_width: Pixels,
117    target_width: Pixels,
118    render_child: bool,
119    hide_scheduled: bool,
120    hide_request: u64,
121}
122
123impl SidebarAnimationState {
124    fn new(target_width: Pixels, render_child: bool) -> Self {
125        Self {
126            from_width: target_width,
127            target_width,
128            render_child,
129            hide_scheduled: false,
130            hide_request: 0,
131        }
132    }
133
134    fn needs_update(&self, target_width: Pixels, offcanvas_collapsed: bool) -> bool {
135        let child_state_changed = if offcanvas_collapsed {
136            self.render_child && !self.hide_scheduled
137        } else {
138            !self.render_child || self.hide_scheduled
139        };
140
141        self.target_width != target_width || child_state_changed
142    }
143
144    fn update_target(&mut self, target_width: Pixels, offcanvas_collapsed: bool) -> Option<u64> {
145        if self.target_width != target_width {
146            self.from_width = self.target_width;
147            self.target_width = target_width;
148        }
149
150        if offcanvas_collapsed {
151            if self.render_child && !self.hide_scheduled {
152                self.hide_scheduled = true;
153                self.hide_request = self.hide_request.wrapping_add(1);
154                Some(self.hide_request)
155            } else {
156                None
157            }
158        } else {
159            self.render_child = true;
160            if self.hide_scheduled {
161                self.hide_request = self.hide_request.wrapping_add(1);
162            }
163            self.hide_scheduled = false;
164            None
165        }
166    }
167
168    fn finish_hide(&mut self, request: u64) -> bool {
169        if self.render_child
170            && self.hide_scheduled
171            && self.hide_request == request
172            && self.target_width == px(0.)
173        {
174            self.render_child = false;
175            self.hide_scheduled = false;
176            true
177        } else {
178            false
179        }
180    }
181}
182
183fn sidebar_wrapper(
184    id: impl Into<ElementId>,
185    align_child_to_end: bool,
186) -> impl ParentElement + IntoElement + Styled {
187    div()
188        .id(id)
189        .flex()
190        .h_full()
191        .flex_shrink_0()
192        .overflow_hidden()
193        .when(align_child_to_end, |this| this.justify_end())
194}
195
196fn sidebar_expanded_width(style: &StyleRefinement) -> Option<Pixels> {
197    match style.size.width {
198        Some(Length::Definite(DefiniteLength::Absolute(AbsoluteLength::Pixels(px)))) => Some(px),
199        Some(_) => None,
200        None => Some(DEFAULT_WIDTH),
201    }
202}
203
204fn sidebar_animation_id(id: &ElementId, from: Pixels, to: Pixels) -> ElementId {
205    ElementId::NamedInteger(
206        format!("{id}-anim-w").into(),
207        (from.as_f32().to_bits() as u64) << 32 | to.as_f32().to_bits() as u64,
208    )
209}
210
211pub trait SidebarItem: Collapsible + Clone {
212    fn render(
213        self,
214        id: impl Into<ElementId>,
215        window: &mut Window,
216        cx: &mut App,
217    ) -> impl IntoElement;
218}
219
220/// A Sidebar element that can contain collapsible child elements.
221#[derive(IntoElement)]
222pub struct Sidebar<E: SidebarItem + 'static> {
223    id: ElementId,
224    style: StyleRefinement,
225    content: Vec<E>,
226    /// header view
227    header: Option<AnyElement>,
228    /// footer view
229    footer: Option<AnyElement>,
230    /// The side of the sidebar
231    side: Side,
232    collapsible: SidebarCollapsible,
233    collapsed: bool,
234}
235
236impl<E: SidebarItem> Sidebar<E> {
237    /// Create a new Sidebar with the given ID.
238    pub fn new(id: impl Into<ElementId>) -> Self {
239        Self {
240            id: id.into(),
241            style: StyleRefinement::default(),
242            content: vec![],
243            header: None,
244            footer: None,
245            side: Side::Left,
246            collapsible: SidebarCollapsible::Icon,
247            collapsed: false,
248        }
249    }
250
251    /// Set the side of the sidebar.
252    ///
253    /// Default is `Side::Left`.
254    pub fn side(mut self, side: Side) -> Self {
255        self.side = side;
256        self
257    }
258
259    /// Set how the sidebar collapses.
260    ///
261    /// Passing `true` keeps the previous behavior and maps to
262    /// [`SidebarCollapsible::Icon`]. Passing `false` maps to
263    /// [`SidebarCollapsible::None`].
264    pub fn collapsible(mut self, collapsible: impl Into<SidebarCollapsible>) -> Self {
265        self.collapsible = collapsible.into();
266        self
267    }
268
269    /// Set the sidebar to be collapsed
270    pub fn collapsed(mut self, collapsed: bool) -> Self {
271        self.collapsed = collapsed;
272        self
273    }
274
275    /// Set the header of the sidebar.
276    pub fn header(mut self, header: impl IntoElement) -> Self {
277        self.header = Some(header.into_any_element());
278        self
279    }
280
281    /// Set the footer of the sidebar.
282    pub fn footer(mut self, footer: impl IntoElement) -> Self {
283        self.footer = Some(footer.into_any_element());
284        self
285    }
286
287    /// Add a child element to the sidebar, the child must implement `Collapsible`
288    pub fn child(mut self, child: E) -> Self {
289        self.content.push(child);
290        self
291    }
292
293    /// Add multiple children to the sidebar, the children must implement `Collapsible`
294    pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
295        self.content.extend(children);
296        self
297    }
298}
299
300/// Toggle button to collapse/expand the [`Sidebar`].
301#[derive(IntoElement)]
302pub struct SidebarToggleButton {
303    btn: Button,
304    collapsed: bool,
305    side: Side,
306    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
307}
308
309impl SidebarToggleButton {
310    /// Create a new SidebarToggleButton.
311    pub fn new() -> Self {
312        Self {
313            btn: Button::new("collapse").ghost().small(),
314            collapsed: false,
315            side: Side::Left,
316            on_click: None,
317        }
318    }
319
320    /// Set the side of the toggle button.
321    ///
322    /// Default is `Side::Left`.
323    pub fn side(mut self, side: Side) -> Self {
324        self.side = side;
325        self
326    }
327
328    /// Set the collapsed state of the toggle button.
329    pub fn collapsed(mut self, collapsed: bool) -> Self {
330        self.collapsed = collapsed;
331        self
332    }
333
334    /// Set the accessibility label of the toggle button.
335    ///
336    /// The default toggle is icon-only; without an explicit label it has no
337    /// accessible name.
338    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
339        self.btn = self.btn.accessibility_label(label);
340        self
341    }
342
343    /// Add a click handler to the toggle button.
344    pub fn on_click(
345        mut self,
346        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
347    ) -> Self {
348        self.on_click = Some(Rc::new(on_click));
349        self
350    }
351}
352
353impl RenderOnce for SidebarToggleButton {
354    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
355        let collapsed = self.collapsed;
356        let on_click = self.on_click.clone();
357
358        let icon = if collapsed {
359            if self.side.is_left() {
360                IconName::PanelLeftOpen
361            } else {
362                IconName::PanelRightOpen
363            }
364        } else {
365            if self.side.is_left() {
366                IconName::PanelLeftClose
367            } else {
368                IconName::PanelRightClose
369            }
370        };
371
372        self.btn
373            .when_some(on_click, |this, on_click| {
374                this.on_click(move |ev, window, cx| {
375                    on_click(ev, window, cx);
376                })
377            })
378            .icon(Icon::new(icon).size_4())
379    }
380}
381
382impl<E: SidebarItem> Styled for Sidebar<E> {
383    fn style(&mut self) -> &mut StyleRefinement {
384        &mut self.style
385    }
386}
387
388impl<E: SidebarItem> RenderOnce for Sidebar<E> {
389    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
390        self.style.padding = EdgesRefinement::default();
391
392        let id = self.id;
393        let content_len = self.content.len();
394        let overdraw = px(window.viewport_size().height.as_f32() * 0.3);
395        let list_state = window
396            .use_keyed_state(
397                SharedString::from(format!("{}-list-state", id)),
398                cx,
399                |_, _| ListState::new(content_len, ListAlignment::Top, overdraw),
400            )
401            .read(cx)
402            .clone();
403        if list_state.item_count() != content_len {
404            list_state.reset(content_len);
405        }
406
407        // Determine effective expanded width from user's custom style or default.
408        // Non-pixel widths still render correctly, but cannot use pixel width transitions.
409        let expanded_width = sidebar_expanded_width(&self.style);
410        let layout =
411            SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side);
412
413        // Sidebar content renders at its target width immediately. A wrapper
414        // div animates clip-width for smooth transitions without re-laying out
415        // sidebar content each animation frame.
416        let sidebar = v_flex()
417            .id(id.clone())
418            .flex_shrink_0()
419            .h_full()
420            .overflow_hidden()
421            .relative()
422            .bg(cx.theme().tokens.sidebar)
423            .text_color(cx.theme().sidebar_foreground)
424            .border_color(cx.theme().sidebar_border)
425            .map(|this| match self.side {
426                Side::Left => this.border_r_1(),
427                Side::Right => this.border_l_1(),
428            })
429            .when(self.style.size.width.is_none(), |this| {
430                this.w(DEFAULT_WIDTH)
431            })
432            .refine_style(&self.style)
433            .when(layout.icon_collapsed, |this| {
434                this.w(COLLAPSED_WIDTH).gap_2()
435            })
436            .when_some(self.header.take(), |this, header| {
437                this.child(
438                    h_flex()
439                        .id("header")
440                        .pt_3()
441                        .px_3()
442                        .gap_2()
443                        .when(layout.icon_collapsed, |this| this.pt_2().px_2())
444                        .child(header),
445                )
446            })
447            .child(
448                v_flex().id("content").flex_1().min_h_0().child(
449                    v_flex()
450                        .id("inner")
451                        .size_full()
452                        .px_3()
453                        .gap_y_3()
454                        .when(layout.icon_collapsed, |this| this.p_2())
455                        .child(gpui_base::ScrollBounce::new(
456                            "bounce",
457                            &list_state,
458                            list(list_state.clone(), {
459                                move |ix, window, cx| {
460                                    let group = self.content.get(ix).cloned();
461                                    let is_first = ix == 0;
462                                    let is_last =
463                                        content_len > 0 && ix == content_len.saturating_sub(1);
464                                    div()
465                                        .id(ix)
466                                        .when_some(group, |this, group| {
467                                            this.child(
468                                                group
469                                                    .collapsed(layout.icon_collapsed)
470                                                    .render(ix, window, cx)
471                                                    .into_any_element(),
472                                            )
473                                        })
474                                        .when(is_first, |this| this.pt_3())
475                                        .when(is_last, |this| this.pb_3())
476                                        .into_any_element()
477                                }
478                            })
479                            .size_full(),
480                        ))
481                        .vertical_scrollbar(&list_state),
482                ),
483            )
484            .when_some(self.footer.take(), |this, footer| {
485                this.child(
486                    h_flex()
487                        .id("footer")
488                        .pb_3()
489                        .px_3()
490                        .gap_2()
491                        .when(layout.icon_collapsed, |this| this.pt_2().px_2())
492                        .child(footer),
493                )
494            });
495
496        let target_width = match layout.wrapper {
497            SidebarWrapperLayout::None => return sidebar.into_any_element(),
498            SidebarWrapperLayout::Static { width } => {
499                return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
500                    .w(width)
501                    .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
502                    .into_any_element();
503            }
504            SidebarWrapperLayout::Animated { target_width } => target_width,
505        };
506
507        // Store animation state in keyed state so it remains stable across
508        // re-renders (GPUI re-renders the whole tree on each animation frame).
509        // The target width is derived from the current layout, so changes to
510        // collapsible mode or expanded width are handled even if `collapsed`
511        // itself does not change. Offcanvas keeps content mounted while the
512        // close transition runs, then unmounts it so hidden controls leave the
513        // tab order.
514        let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
515            SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
516        });
517
518        let hide_request = if animation_state
519            .read(cx)
520            .needs_update(target_width, layout.offcanvas_collapsed)
521        {
522            animation_state.update(cx, |state, _| {
523                state.update_target(target_width, layout.offcanvas_collapsed)
524            })
525        } else {
526            None
527        };
528        if let Some(hide_request) = hide_request {
529            cx.spawn({
530                let animation_state = animation_state.clone();
531                async move |cx| {
532                    cx.background_executor()
533                        .timer(SIDEBAR_TRANSITION_DURATION)
534                        .await;
535                    _ = animation_state.update(cx, |state, cx| {
536                        if state.finish_hide(hide_request) {
537                            cx.notify();
538                        }
539                    });
540                }
541            })
542            .detach();
543        }
544        let animation_state = *animation_state.read(cx);
545        let from_w = animation_state.from_width;
546        let to_w = animation_state.target_width;
547
548        let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
549            .when(animation_state.render_child, |this| this.child(sidebar));
550
551        EffectTransition::new(SIDEBAR_TRANSITION_DURATION)
552            .ease(ease_in_out_cubic)
553            .width(from_w, to_w)
554            .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
555            .into_any_element()
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn layout(
564        collapsible: SidebarCollapsible,
565        collapsed: bool,
566        expanded_width: Option<Pixels>,
567        side: Side,
568    ) -> SidebarLayout {
569        SidebarLayout::new(collapsible, collapsed, expanded_width, side)
570    }
571
572    #[test]
573    fn bool_collapsible_should_remain_backward_compatible() {
574        assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
575        assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
576    }
577
578    #[test]
579    fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
580        let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
581
582        assert!(layout.icon_collapsed);
583        assert!(!layout.offcanvas_collapsed);
584        assert!(!layout.align_child_to_end);
585        assert_eq!(
586            layout.wrapper,
587            SidebarWrapperLayout::Animated {
588                target_width: COLLAPSED_WIDTH,
589            }
590        );
591    }
592
593    #[test]
594    fn icon_expanded_should_use_expanded_width() {
595        let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
596
597        assert!(!layout.icon_collapsed);
598        assert!(!layout.offcanvas_collapsed);
599        assert_eq!(
600            layout.wrapper,
601            SidebarWrapperLayout::Animated {
602                target_width: px(240.),
603            }
604        );
605    }
606
607    #[test]
608    fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
609        let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
610
611        assert!(!layout.icon_collapsed);
612        assert!(!layout.offcanvas_collapsed);
613        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
614    }
615
616    #[test]
617    fn none_should_ignore_collapsed_state() {
618        let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
619
620        assert!(!layout.icon_collapsed);
621        assert!(!layout.offcanvas_collapsed);
622        assert!(layout.align_child_to_end);
623        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
624    }
625
626    #[test]
627    fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
628        let layout = layout(
629            SidebarCollapsible::Offcanvas,
630            true,
631            Some(px(240.)),
632            Side::Left,
633        );
634
635        assert!(!layout.icon_collapsed);
636        assert!(layout.offcanvas_collapsed);
637        assert!(layout.align_child_to_end);
638        assert_eq!(
639            layout.wrapper,
640            SidebarWrapperLayout::Animated {
641                target_width: px(0.),
642            }
643        );
644    }
645
646    #[test]
647    fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
648        let layout = layout(
649            SidebarCollapsible::Offcanvas,
650            false,
651            Some(px(240.)),
652            Side::Left,
653        );
654
655        assert!(!layout.icon_collapsed);
656        assert!(!layout.offcanvas_collapsed);
657        assert_eq!(
658            layout.wrapper,
659            SidebarWrapperLayout::Animated {
660                target_width: px(240.),
661            }
662        );
663    }
664
665    #[test]
666    fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
667        let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
668
669        assert!(!layout.icon_collapsed);
670        assert!(layout.offcanvas_collapsed);
671        assert_eq!(
672            layout.wrapper,
673            SidebarWrapperLayout::Static { width: px(0.) }
674        );
675    }
676
677    #[test]
678    fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
679        let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
680
681        assert!(!layout.icon_collapsed);
682        assert!(!layout.offcanvas_collapsed);
683        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
684    }
685
686    #[test]
687    fn offcanvas_should_anchor_child_toward_the_content_edge() {
688        let left = layout(
689            SidebarCollapsible::Offcanvas,
690            true,
691            Some(px(240.)),
692            Side::Left,
693        );
694        let right = layout(
695            SidebarCollapsible::Offcanvas,
696            true,
697            Some(px(240.)),
698            Side::Right,
699        );
700
701        assert!(left.align_child_to_end);
702        assert!(!right.align_child_to_end);
703    }
704
705    #[test]
706    fn animation_id_should_be_scoped_to_sidebar_id() {
707        let from = px(240.);
708        let to = COLLAPSED_WIDTH;
709
710        assert_ne!(
711            sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
712            sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
713        );
714    }
715
716    #[test]
717    fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
718        let mut state = SidebarAnimationState::new(px(240.), true);
719
720        let request = state.update_target(px(0.), true);
721
722        assert_eq!(request, Some(1));
723        assert_eq!(state.from_width, px(240.));
724        assert_eq!(state.target_width, px(0.));
725        assert!(state.render_child);
726
727        assert!(state.finish_hide(1));
728
729        assert!(!state.render_child);
730        assert!(!state.hide_scheduled);
731    }
732
733    #[test]
734    fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
735        let mut state = SidebarAnimationState::new(px(240.), true);
736
737        let request = state.update_target(px(0.), true);
738
739        assert_eq!(request, Some(1));
740        assert!(!state.needs_update(px(0.), true));
741        assert_eq!(state.update_target(px(0.), true), None);
742        assert_eq!(state.hide_request, 1);
743    }
744
745    #[test]
746    fn animation_state_should_cancel_pending_hide_when_reexpanded() {
747        let mut state = SidebarAnimationState::new(px(240.), true);
748
749        let request = state.update_target(px(0.), true).unwrap();
750        state.update_target(px(240.), false);
751
752        assert!(!state.finish_hide(request));
753        assert!(state.render_child);
754        assert!(!state.hide_scheduled);
755        assert_eq!(state.from_width, px(0.));
756        assert_eq!(state.target_width, px(240.));
757    }
758
759    #[test]
760    fn animation_state_should_ignore_stale_hide_request() {
761        let mut state = SidebarAnimationState::new(px(240.), true);
762
763        let request = state.update_target(px(0.), true).unwrap();
764        state.update_target(px(240.), false);
765        state.update_target(px(0.), true);
766
767        assert!(!state.finish_hide(request));
768        assert!(state.render_child);
769        assert!(state.hide_scheduled);
770    }
771
772    #[test]
773    fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
774        let state = SidebarAnimationState::new(px(0.), false);
775
776        assert!(!state.render_child);
777        assert_eq!(state.from_width, px(0.));
778        assert_eq!(state.target_width, px(0.));
779    }
780}