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(
456                            list(list_state.clone(), {
457                                move |ix, window, cx| {
458                                    let group = self.content.get(ix).cloned();
459                                    let is_first = ix == 0;
460                                    let is_last =
461                                        content_len > 0 && ix == content_len.saturating_sub(1);
462                                    div()
463                                        .id(ix)
464                                        .when_some(group, |this, group| {
465                                            this.child(
466                                                group
467                                                    .collapsed(layout.icon_collapsed)
468                                                    .render(ix, window, cx)
469                                                    .into_any_element(),
470                                            )
471                                        })
472                                        .when(is_first, |this| this.pt_3())
473                                        .when(is_last, |this| this.pb_3())
474                                        .into_any_element()
475                                }
476                            })
477                            .size_full(),
478                        )
479                        .vertical_scrollbar(&list_state),
480                ),
481            )
482            .when_some(self.footer.take(), |this, footer| {
483                this.child(
484                    h_flex()
485                        .id("footer")
486                        .pb_3()
487                        .px_3()
488                        .gap_2()
489                        .when(layout.icon_collapsed, |this| this.pt_2().px_2())
490                        .child(footer),
491                )
492            });
493
494        let target_width = match layout.wrapper {
495            SidebarWrapperLayout::None => return sidebar.into_any_element(),
496            SidebarWrapperLayout::Static { width } => {
497                return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
498                    .w(width)
499                    .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
500                    .into_any_element();
501            }
502            SidebarWrapperLayout::Animated { target_width } => target_width,
503        };
504
505        // Store animation state in keyed state so it remains stable across
506        // re-renders (GPUI re-renders the whole tree on each animation frame).
507        // The target width is derived from the current layout, so changes to
508        // collapsible mode or expanded width are handled even if `collapsed`
509        // itself does not change. Offcanvas keeps content mounted while the
510        // close transition runs, then unmounts it so hidden controls leave the
511        // tab order.
512        let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
513            SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
514        });
515
516        let hide_request = if animation_state
517            .read(cx)
518            .needs_update(target_width, layout.offcanvas_collapsed)
519        {
520            animation_state.update(cx, |state, _| {
521                state.update_target(target_width, layout.offcanvas_collapsed)
522            })
523        } else {
524            None
525        };
526        if let Some(hide_request) = hide_request {
527            cx.spawn({
528                let animation_state = animation_state.clone();
529                async move |cx| {
530                    cx.background_executor()
531                        .timer(SIDEBAR_TRANSITION_DURATION)
532                        .await;
533                    _ = animation_state.update(cx, |state, cx| {
534                        if state.finish_hide(hide_request) {
535                            cx.notify();
536                        }
537                    });
538                }
539            })
540            .detach();
541        }
542        let animation_state = *animation_state.read(cx);
543        let from_w = animation_state.from_width;
544        let to_w = animation_state.target_width;
545
546        let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
547            .when(animation_state.render_child, |this| this.child(sidebar));
548
549        EffectTransition::new(SIDEBAR_TRANSITION_DURATION)
550            .ease(ease_in_out_cubic)
551            .width(from_w, to_w)
552            .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
553            .into_any_element()
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    fn layout(
562        collapsible: SidebarCollapsible,
563        collapsed: bool,
564        expanded_width: Option<Pixels>,
565        side: Side,
566    ) -> SidebarLayout {
567        SidebarLayout::new(collapsible, collapsed, expanded_width, side)
568    }
569
570    #[test]
571    fn bool_collapsible_should_remain_backward_compatible() {
572        assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
573        assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
574    }
575
576    #[test]
577    fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
578        let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
579
580        assert!(layout.icon_collapsed);
581        assert!(!layout.offcanvas_collapsed);
582        assert!(!layout.align_child_to_end);
583        assert_eq!(
584            layout.wrapper,
585            SidebarWrapperLayout::Animated {
586                target_width: COLLAPSED_WIDTH,
587            }
588        );
589    }
590
591    #[test]
592    fn icon_expanded_should_use_expanded_width() {
593        let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
594
595        assert!(!layout.icon_collapsed);
596        assert!(!layout.offcanvas_collapsed);
597        assert_eq!(
598            layout.wrapper,
599            SidebarWrapperLayout::Animated {
600                target_width: px(240.),
601            }
602        );
603    }
604
605    #[test]
606    fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
607        let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
608
609        assert!(!layout.icon_collapsed);
610        assert!(!layout.offcanvas_collapsed);
611        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
612    }
613
614    #[test]
615    fn none_should_ignore_collapsed_state() {
616        let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
617
618        assert!(!layout.icon_collapsed);
619        assert!(!layout.offcanvas_collapsed);
620        assert!(layout.align_child_to_end);
621        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
622    }
623
624    #[test]
625    fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
626        let layout = layout(
627            SidebarCollapsible::Offcanvas,
628            true,
629            Some(px(240.)),
630            Side::Left,
631        );
632
633        assert!(!layout.icon_collapsed);
634        assert!(layout.offcanvas_collapsed);
635        assert!(layout.align_child_to_end);
636        assert_eq!(
637            layout.wrapper,
638            SidebarWrapperLayout::Animated {
639                target_width: px(0.),
640            }
641        );
642    }
643
644    #[test]
645    fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
646        let layout = layout(
647            SidebarCollapsible::Offcanvas,
648            false,
649            Some(px(240.)),
650            Side::Left,
651        );
652
653        assert!(!layout.icon_collapsed);
654        assert!(!layout.offcanvas_collapsed);
655        assert_eq!(
656            layout.wrapper,
657            SidebarWrapperLayout::Animated {
658                target_width: px(240.),
659            }
660        );
661    }
662
663    #[test]
664    fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
665        let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
666
667        assert!(!layout.icon_collapsed);
668        assert!(layout.offcanvas_collapsed);
669        assert_eq!(
670            layout.wrapper,
671            SidebarWrapperLayout::Static { width: px(0.) }
672        );
673    }
674
675    #[test]
676    fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
677        let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
678
679        assert!(!layout.icon_collapsed);
680        assert!(!layout.offcanvas_collapsed);
681        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
682    }
683
684    #[test]
685    fn offcanvas_should_anchor_child_toward_the_content_edge() {
686        let left = layout(
687            SidebarCollapsible::Offcanvas,
688            true,
689            Some(px(240.)),
690            Side::Left,
691        );
692        let right = layout(
693            SidebarCollapsible::Offcanvas,
694            true,
695            Some(px(240.)),
696            Side::Right,
697        );
698
699        assert!(left.align_child_to_end);
700        assert!(!right.align_child_to_end);
701    }
702
703    #[test]
704    fn animation_id_should_be_scoped_to_sidebar_id() {
705        let from = px(240.);
706        let to = COLLAPSED_WIDTH;
707
708        assert_ne!(
709            sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
710            sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
711        );
712    }
713
714    #[test]
715    fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
716        let mut state = SidebarAnimationState::new(px(240.), true);
717
718        let request = state.update_target(px(0.), true);
719
720        assert_eq!(request, Some(1));
721        assert_eq!(state.from_width, px(240.));
722        assert_eq!(state.target_width, px(0.));
723        assert!(state.render_child);
724
725        assert!(state.finish_hide(1));
726
727        assert!(!state.render_child);
728        assert!(!state.hide_scheduled);
729    }
730
731    #[test]
732    fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
733        let mut state = SidebarAnimationState::new(px(240.), true);
734
735        let request = state.update_target(px(0.), true);
736
737        assert_eq!(request, Some(1));
738        assert!(!state.needs_update(px(0.), true));
739        assert_eq!(state.update_target(px(0.), true), None);
740        assert_eq!(state.hide_request, 1);
741    }
742
743    #[test]
744    fn animation_state_should_cancel_pending_hide_when_reexpanded() {
745        let mut state = SidebarAnimationState::new(px(240.), true);
746
747        let request = state.update_target(px(0.), true).unwrap();
748        state.update_target(px(240.), false);
749
750        assert!(!state.finish_hide(request));
751        assert!(state.render_child);
752        assert!(!state.hide_scheduled);
753        assert_eq!(state.from_width, px(0.));
754        assert_eq!(state.target_width, px(240.));
755    }
756
757    #[test]
758    fn animation_state_should_ignore_stale_hide_request() {
759        let mut state = SidebarAnimationState::new(px(240.), true);
760
761        let request = state.update_target(px(0.), true).unwrap();
762        state.update_target(px(240.), false);
763        state.update_target(px(0.), true);
764
765        assert!(!state.finish_hide(request));
766        assert!(state.render_child);
767        assert!(state.hide_scheduled);
768    }
769
770    #[test]
771    fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
772        let state = SidebarAnimationState::new(px(0.), false);
773
774        assert!(!state.render_child);
775        assert_eq!(state.from_width, px(0.));
776        assert_eq!(state.target_width, px(0.));
777    }
778}