Skip to main content

ravenrs_widgets/
nav.rs

1//! Navigation: tabs, sidebars, breadcrumbs and disclosures.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11
12use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics, WidgetId};
13use crate::floating::SheetLayer;
14use crate::overlay::Bubble;
15use crate::{
16    AnyView, Badge, ButtonSize, Divider, IconButton, IconName, Motion, ScrollView, Side, Skeleton, Toggle,
17    Tooltip, View, ViewSeq, motion, util,
18};
19
20/// How a [`Tabs`] bar looks.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22#[non_exhaustive]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
24pub enum TabsVariant {
25    /// Labels over a hairline, with a line that slides under the open one.
26    #[default]
27    Underline,
28    /// A rounded track with the open tab raised in it, for a few short choices.
29    Segmented,
30}
31
32/// A tab bar with one content view per tab; only the selected one is built.
33/// Arrow keys move between tabs and open them; Home and End jump to the ends.
34///
35/// With [`item`](Self::item) instead of [`tab`](Self::tab), it is only the bar, and
36/// what `selected` changes is up to the app.
37///
38/// ```
39/// use ravenrs_reactive::State;
40/// use ravenrs_widgets::{ButtonSize, Tabs, TabsVariant, Ui};
41///
42/// let view = State::new(0);
43/// let bar = Tabs::new(view.binding())
44///     .variant(TabsVariant::Segmented)
45///     .size(ButtonSize::Small)
46///     .item("Password")
47///     .item("Link by e-mail");
48/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
49/// ui.update();
50/// ```
51#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
52pub struct Tabs {
53    selected: Binding<usize>,
54    tabs: Vec<(String, Option<AnyView>)>,
55    variant: TabsVariant,
56    size: ButtonSize,
57    disabled: bool,
58}
59
60impl fmt::Debug for Tabs {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("Tabs").field("tabs", &self.tabs.iter().map(|t| &t.0).collect::<Vec<_>>()).finish()
63    }
64}
65
66#[cfg_attr(feature = "live", ravenrs_live::expose)]
67impl Tabs {
68    /// Empty tabs; `selected` holds the zero-based index of the open tab.
69    pub fn new(selected: Binding<usize>) -> Self {
70        Self {
71            selected,
72            tabs: Vec::new(),
73            variant: TabsVariant::Underline,
74            size: ButtonSize::Medium,
75            disabled: false,
76        }
77    }
78
79    /// Adds a tab with this label and content.
80    pub fn tab(mut self, label: impl Into<String>, content: impl View + 'static) -> Self {
81        self.tabs.push((label.into(), Some(Box::new(content))));
82        self
83    }
84
85    /// Adds a tab with this label and no content, for an app that shows what goes with
86    /// it elsewhere.
87    pub fn item(mut self, label: impl Into<String>) -> Self {
88        self.tabs.push((label.into(), None));
89        self
90    }
91
92    /// How the bar looks ([`TabsVariant::Underline`] by default).
93    pub fn variant(mut self, variant: TabsVariant) -> Self {
94        self.variant = variant;
95        self
96    }
97
98    /// The bar's size, on the scale of the buttons (`Medium` by default).
99    pub fn size(mut self, size: ButtonSize) -> Self {
100        self.size = size;
101        self
102    }
103
104    /// Keeps the open tab from changing, such as while waiting for an answer that
105    /// belongs to it. The bar fades and the tabs ignore input.
106    pub fn disabled(mut self, disabled: bool) -> Self {
107        self.disabled = disabled;
108        self
109    }
110}
111
112struct TabButton {
113    scope: WidgetId,
114    roving: util::Roving,
115    label: String,
116    selected: Binding<usize>,
117    variant: TabsVariant,
118    size: ButtonSize,
119    disabled: bool,
120}
121
122#[cfg_attr(feature = "live", ravenrs_live::expose)]
123impl TabButton {
124    fn ink(&self, cx: &BuildCx<'_>, on: bool) -> Color {
125        let theme = cx.theme();
126        if self.disabled {
127            util::faded(theme.color(if on { Semantic::Ink } else { Semantic::InkMuted }))
128        } else if on || cx.is_hovered() {
129            theme.color(Semantic::Ink)
130        } else {
131            theme.color(Semantic::InkMuted)
132        }
133    }
134
135    fn underline(&self, cx: &mut BuildCx<'_>, on: bool) -> NodeId {
136        let theme = *cx.theme();
137        let color = self.ink(cx, on);
138        let label = util::label(cx, &self.label, self.size.text_style(), Some(Weight::SEMIBOLD), color);
139        motion::control(cx, label);
140        let label = cx.container(
141            LayoutStyle { padding: Insets::symmetric(0.0, 2.0), ..LayoutStyle::stack(Axis::Horizontal) },
142            &[label],
143            Paint::None,
144        );
145        // The underline is one object that slides from tab to tab.
146        let marker = cx.container(
147            LayoutStyle { height: Some(2.0), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
148            &[],
149            if on {
150                let accent = theme.color(Semantic::Accent);
151                Paint::Quad(QuadPaint { corner_radius: 1.0, ..QuadPaint::new(accent) })
152            } else {
153                Paint::None
154            },
155        );
156        if on {
157            cx.motion(marker, Motion::new().shared_layout(self.scope, 0, theme.motion.snappy));
158        }
159        let (top, gap) = match self.size {
160            ButtonSize::Medium | ButtonSize::Large => (8.0, 10.0),
161            ButtonSize::Small | ButtonSize::Mini => (4.0, 6.0),
162        };
163        let style = LayoutStyle {
164            padding: Insets { top, ..Insets::ZERO },
165            spacing: gap,
166            ..LayoutStyle::stack(Axis::Vertical)
167        };
168        cx.container(style, &[label, marker], Paint::None)
169    }
170
171    fn segment(&self, cx: &mut BuildCx<'_>, on: bool) -> NodeId {
172        let theme = *cx.theme();
173        let color = self.ink(cx, on);
174        let weight = on.then_some(Weight::SEMIBOLD);
175        let label = util::label(cx, &self.label, self.size.text_style(), weight, color);
176        let paint = if on {
177            Paint::Quad(QuadPaint {
178                corner_radius: theme.radii.sm,
179                shadow: Some(theme.elevation.soft),
180                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
181                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
182            })
183        } else {
184            Paint::None
185        };
186        let node = cx.container(
187            LayoutStyle {
188                height: Some(self.size.height() - 2.0 * SEGMENT_TRACK_PADDING),
189                padding: Insets::symmetric(0.0, theme.spacing.s3),
190                align: Align::Center,
191                justify: Justify::Center,
192                ..LayoutStyle::stack(Axis::Horizontal)
193            },
194            &[label],
195            paint,
196        );
197        motion::control(cx, node);
198        node
199    }
200}
201
202impl View for TabButton {
203    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
204        let on = self.selected.get() == self.roving.index;
205        let node = match self.variant {
206            TabsVariant::Underline => self.underline(cx, on),
207            TabsVariant::Segmented => self.segment(cx, on),
208        };
209        cx.semantics(node, Semantics::new(Role::Tab).selected(on).disabled(self.disabled));
210        if self.disabled {
211            return node;
212        }
213        let (selected, index) = (self.selected.clone(), self.roving.index);
214        cx.on_click(node, Rc::new(move || selected.set(index)));
215        // Tabs activate as focus moves (WAI-ARIA tabs, automatic activation).
216        let selected = self.selected.clone();
217        self.roving.wire(cx, node, Some(Rc::new(move |i| selected.set(i))));
218        cx.focusable(node, 6.0);
219        cx.cursor(node, CursorIcon::Pointer);
220        node
221    }
222}
223
224/// What separates the track of segmented tabs from the piece inside it.
225const SEGMENT_TRACK_PADDING: f32 = 3.0;
226
227impl View for Tabs {
228    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
229        let theme = *cx.theme();
230        let border = theme.color(Semantic::Border);
231        let (count, focus_to) = (self.tabs.len(), util::roving_cell(cx, None));
232        let scope = cx.widget_id();
233        let buttons: Vec<NodeId> = self
234            .tabs
235            .iter()
236            .enumerate()
237            .map(|(index, (label, _))| {
238                let roving = util::Roving { index, count, focus_to: focus_to.clone() };
239                cx.child(&TabButton {
240                    scope,
241                    roving,
242                    label: label.clone(),
243                    selected: self.selected.clone(),
244                    variant: self.variant,
245                    size: self.size,
246                    disabled: self.disabled,
247                })
248            })
249            .collect();
250        let bar = match self.variant {
251            TabsVariant::Underline => cx.container(
252                LayoutStyle {
253                    spacing: 24.0,
254                    align_self: Some(Align::Stretch),
255                    // Tabs that do not fit go to another line instead of running over each other.
256                    wrap: true,
257                    ..LayoutStyle::stack(Axis::Horizontal)
258                },
259                &buttons,
260                Paint::Custom(Rc::new(move |f: Rect, scene| {
261                    scene.push_quad(Quad::new(Rect::new(f.x(), f.max_y() - 1.0, f.width(), 1.0), border));
262                })),
263            ),
264            TabsVariant::Segmented => cx.container(
265                LayoutStyle {
266                    padding: Insets::uniform(SEGMENT_TRACK_PADDING),
267                    spacing: 2.0,
268                    align: Align::Center,
269                    align_self: Some(Align::Start),
270                    wrap: true,
271                    ..LayoutStyle::stack(Axis::Horizontal)
272                },
273                &buttons,
274                Paint::Quad(QuadPaint {
275                    corner_radius: theme.radii.md,
276                    ..QuadPaint::new(theme.color(Semantic::Ink).with_alpha(0.06))
277                }),
278            ),
279        };
280        cx.semantics(bar, Semantics::new(Role::TabList));
281        let selected = self.selected.get().min(self.tabs.len().saturating_sub(1));
282        let mut children = vec![bar];
283        if let Some((label, Some(content))) = self.tabs.get(selected) {
284            // Keyed by tab so each tab keeps its own local state.
285            let content = cx.keyed_child(selected as u64, content.as_ref());
286            // A box that fills the space, so content that grows still does.
287            let panel = cx.container(
288                LayoutStyle {
289                    grow: 1.0,
290                    min_height: Some(0.0),
291                    align: Align::Stretch,
292                    ..LayoutStyle::stack(Axis::Vertical)
293                },
294                &[content],
295                Paint::None,
296            );
297            cx.semantics(panel, Semantics::new(Role::TabPanel).name(label.clone()));
298            children.push(panel);
299        }
300        cx.container(
301            LayoutStyle { spacing: 20.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
302            &children,
303            Paint::None,
304        )
305    }
306}
307
308/// Width of a sidebar reduced to icons, in logical pixels. It keeps each icon
309/// where it was when the sidebar is open: padding, row padding, icon, and back.
310const SIDEBAR_RAIL: f32 = 60.0;
311/// How wide the strip of a [`Sidebar::rail`] is.
312const SIDEBAR_RAIL_STRIP: f32 = 8.0;
313/// Puts a brand's 26px mark in line with the icons of a rail.
314const SIDEBAR_BRAND_INSET: f32 = 5.0;
315
316/// What a [`Sidebar`] does when it closes.
317#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
318#[non_exhaustive]
319#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
320pub enum SidebarCollapse {
321    /// It stays open; there is nothing to toggle.
322    #[default]
323    Fixed,
324    /// It narrows to a rail of icons, with the labels as tooltips.
325    Icons,
326    /// It leaves the screen.
327    Hidden,
328}
329
330/// How a [`Sidebar`] sits next to the page.
331#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
332#[non_exhaustive]
333#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
334pub enum SidebarVariant {
335    /// A full-height column with a hairline toward the page.
336    #[default]
337    Standard,
338    /// A rounded, raised panel away from the window's edges.
339    Floating,
340    /// No surface of its own; the page sits in a [`SidebarInset`] beside it.
341    Inset,
342}
343
344/// Whether a [`Sidebar`] is open, shared with whatever toggles it.
345///
346/// A sidebar narrower windows show as a sheet keeps that apart: closing the
347/// sheet does not change what the sidebar does when the window grows again.
348#[derive(Clone)]
349#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
350pub struct SidebarState {
351    open: Binding<bool>,
352    sheet: State<bool>,
353    narrow: Rc<Cell<bool>>,
354}
355
356impl fmt::Debug for SidebarState {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        f.debug_struct("SidebarState").field("narrow", &self.narrow.get()).finish_non_exhaustive()
359    }
360}
361
362#[cfg_attr(feature = "live", ravenrs_live::expose)]
363impl SidebarState {
364    /// A state that starts open or closed.
365    pub fn new(open: bool) -> Self {
366        Self::controlled(State::new(open).binding())
367    }
368
369    /// A state read from and written to `open`, for an app that saves or drives it.
370    pub fn controlled(open: Binding<bool>) -> Self {
371        Self { open, sheet: State::new(false), narrow: Rc::new(Cell::new(false)) }
372    }
373
374    /// Whether the sidebar is showing: its sheet in a narrow window, otherwise
375    /// the column at full width.
376    pub fn is_open(&self) -> bool {
377        if self.narrow.get() { self.sheet.get() } else { self.open.get() }
378    }
379
380    /// Opens or closes it.
381    pub fn set_open(&self, open: bool) {
382        if self.narrow.get() {
383            self.sheet.set(open);
384        } else {
385            self.open.set(open);
386        }
387    }
388
389    /// Opens it if closed, closes it if open.
390    pub fn toggle(&self) {
391        self.set_open(!self.is_open());
392    }
393
394    /// The window is narrow enough for this sidebar to be a sheet; see
395    /// [`Sidebar::narrow_below`]. Known once the sidebar has been built.
396    pub fn is_narrow(&self) -> bool {
397        self.narrow.get()
398    }
399}
400
401/// What the parts of a sidebar need to know about the sidebar around them.
402pub(crate) struct SidebarScope {
403    icons: bool,
404    in_sheet: bool,
405    side: Side,
406    state: SidebarState,
407    focus_to: util::FocusTarget,
408    next: Cell<usize>,
409    count: Rc<Cell<usize>>,
410}
411
412/// Up, Down, Home and End move between a sidebar's rows; Right and Left open
413/// and close a row that has content below it.
414fn wire_row_keys(
415    cx: &mut BuildCx<'_>,
416    node: NodeId,
417    scope: Option<&SidebarScope>,
418    open: Option<Binding<bool>>,
419) {
420    let place = scope.map(|s| {
421        let index = s.next.get();
422        s.next.set(index + 1);
423        if s.focus_to.take(index) {
424            cx.request_focus_node(node);
425        }
426        (index, s.focus_to.clone(), s.count.clone())
427    });
428    if place.is_none() && open.is_none() {
429        return;
430    }
431    cx.on_key(
432        node,
433        Rc::new(move |key, _| {
434            if let Some(open) = &open {
435                match key {
436                    Key::ArrowRight if !open.get() => {
437                        open.set(true);
438                        return true;
439                    }
440                    Key::ArrowLeft if open.get() => {
441                        open.set(false);
442                        return true;
443                    }
444                    _ => {}
445                }
446            }
447            let Some((index, focus_to, count)) = &place else { return false };
448            let (index, count) = (*index, count.get().max(1));
449            let target = match key {
450                Key::ArrowDown => (index + 1) % count,
451                Key::ArrowUp => (index + count - 1) % count,
452                Key::Home => 0,
453                Key::End => count - 1,
454                _ => return false,
455            };
456            focus_to.request(target);
457            true
458        }),
459    );
460}
461
462/// A navigation column: `Sidebar::new((SidebarSection::new("Workspace"), SidebarItem::new(...), ...))`.
463///
464/// Its children scroll between an optional header and footer. With
465/// [`Self::collapsible`] it closes to icons or off the screen, by Cmd/Ctrl+B or
466/// a [`SidebarTrigger`]. With [`Self::narrow_below`] small windows show it as a
467/// sheet over the page.
468///
469/// ```
470/// use ravenrs_widgets::*;
471///
472/// let state = SidebarState::new(true);
473/// let sidebar = Sidebar::new((
474///     SidebarGroup::new("Workspace", (
475///         SidebarItem::new(IconName::House, "Home").selected(true),
476///         SidebarItem::new(IconName::Users, "People").badge(Badge::new("3")),
477///     )),
478/// ))
479/// .collapsible(SidebarCollapse::Icons)
480/// .narrow_below(768.0)
481/// .state(state.clone());
482/// let trigger = SidebarTrigger::new(state);
483/// # let _ = (sidebar, trigger);
484/// ```
485pub struct Sidebar<C, P = ()> {
486    children: C,
487    width: f32,
488    collapse: SidebarCollapse,
489    variant: SidebarVariant,
490    side: Side,
491    narrow_below: Option<f32>,
492    shortcut: bool,
493    rail: bool,
494    label: Option<String>,
495    parts: P,
496}
497
498/// What [`Sidebar::state`], [`Sidebar::header`] and [`Sidebar::footer`] add to a
499/// sidebar. Its own type parameter, so a sidebar without them stays `Send`.
500#[derive(Default)]
501#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
502pub struct SidebarParts {
503    state: Option<SidebarState>,
504    header: Option<AnyView>,
505    footer: Option<AnyView>,
506}
507
508impl fmt::Debug for SidebarParts {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        f.debug_struct("SidebarParts").field("state", &self.state).finish_non_exhaustive()
511    }
512}
513
514impl From<()> for SidebarParts {
515    fn from((): ()) -> Self {
516        Self::default()
517    }
518}
519
520impl<C, P> fmt::Debug for Sidebar<C, P> {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        f.debug_struct("Sidebar").field("width", &self.width).finish_non_exhaustive()
523    }
524}
525
526#[cfg_attr(feature = "live", ravenrs_live::expose)]
527impl<C: ViewSeq> Sidebar<C> {
528    /// A sidebar of `children`, usually sections and items.
529    pub fn new(children: C) -> Self {
530        Self {
531            children,
532            width: 248.0,
533            collapse: SidebarCollapse::Fixed,
534            variant: SidebarVariant::Standard,
535            side: Side::Left,
536            narrow_below: None,
537            shortcut: true,
538            rail: false,
539            label: None,
540            parts: (),
541        }
542    }
543}
544
545#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1", with = "P = (); P = crate::SidebarParts"))]
546impl<C: ViewSeq, P> Sidebar<C, P> {
547    /// Width in logical pixels (default 248).
548    pub fn width(mut self, width: f32) -> Self {
549        self.width = width;
550        self
551    }
552
553    /// What it does when it closes ([`SidebarCollapse::Fixed`] by default).
554    /// Rows, groups and scroll position that are not bound to app state start
555    /// over after [`SidebarCollapse::Hidden`], since nothing is built meanwhile.
556    pub fn collapsible(mut self, collapse: SidebarCollapse) -> Self {
557        self.collapse = collapse;
558        self
559    }
560
561    /// How it sits next to the page ([`SidebarVariant::Standard`] by default).
562    pub fn variant(mut self, variant: SidebarVariant) -> Self {
563        self.variant = variant;
564        self
565    }
566
567    /// The window edge it belongs to ([`Side::Left`] by default). Place it on
568    /// that side of the page yourself; this sets its hairline, the side of its
569    /// tooltips and where its sheet comes from.
570    pub fn side(mut self, side: Side) -> Self {
571        self.side = side;
572        self
573    }
574
575    /// Below this window width, in logical pixels, it takes no room and opens
576    /// as a sheet over the page. It goes by the window, not by its container.
577    pub fn narrow_below(mut self, width: f32) -> Self {
578        self.narrow_below = Some(width);
579        self
580    }
581
582    /// Whether Cmd/Ctrl+B toggles it, if it can close (on by default). Turn it
583    /// off when a window has more than one sidebar, or the app uses that key.
584    pub fn shortcut(mut self, enabled: bool) -> Self {
585        self.shortcut = enabled;
586        self
587    }
588
589    /// A strip along its inner edge that opens and closes it with a click, for a
590    /// sidebar that can close.
591    pub fn rail(mut self, rail: bool) -> Self {
592        self.rail = rail;
593        self
594    }
595
596    /// The name screen readers announce for this landmark (the `navigation`
597    /// word of [`Labels`](crate::Labels) by default).
598    pub fn label(mut self, label: impl Into<String>) -> Self {
599        self.label = Some(label.into());
600        self
601    }
602}
603
604#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part2", with = "P = (); P = crate::SidebarParts"))]
605impl<C: ViewSeq, P: Into<SidebarParts>> Sidebar<C, P> {
606    fn with_parts(self, change: impl FnOnce(&mut SidebarParts)) -> Sidebar<C, SidebarParts> {
607        let mut parts = self.parts.into();
608        change(&mut parts);
609        Sidebar {
610            children: self.children,
611            width: self.width,
612            collapse: self.collapse,
613            variant: self.variant,
614            side: self.side,
615            narrow_below: self.narrow_below,
616            shortcut: self.shortcut,
617            rail: self.rail,
618            label: self.label,
619            parts,
620        }
621    }
622
623    /// Shares whether it is open with a [`SidebarTrigger`] or the app. Without
624    /// this it starts open and keeps the state itself.
625    pub fn state(self, state: SidebarState) -> Sidebar<C, SidebarParts> {
626        self.with_parts(|parts| parts.state = Some(state))
627    }
628
629    /// A view above the content that does not scroll. Reduced to icons, it
630    /// keeps its layout and shows what fits, so start it with an icon.
631    pub fn header(self, header: impl View + 'static) -> Sidebar<C, SidebarParts> {
632        self.with_parts(|parts| parts.header = Some(Box::new(header)))
633    }
634
635    /// A view below the content that does not scroll, treated like the header.
636    pub fn footer(self, footer: impl View + 'static) -> Sidebar<C, SidebarParts> {
637        self.with_parts(|parts| parts.footer = Some(Box::new(footer)))
638    }
639}
640
641/// A header or footer: laid out at full width, so closing only covers it, and
642/// on the rail what fits between the rail's margins shows.
643struct SidebarEnd<'a> {
644    content: &'a dyn View,
645    width: f32,
646}
647
648impl View for SidebarEnd<'_> {
649    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
650        let content = cx.child(self.content);
651        let inner = cx.container(
652            LayoutStyle {
653                width: Some((self.width - 24.0).max(0.0)),
654                align: Align::Stretch,
655                ..LayoutStyle::stack(Axis::Vertical)
656            },
657            &[content],
658            Paint::None,
659        );
660        let window = cx.container(
661            LayoutStyle {
662                grow: 1.0,
663                min_width: Some(0.0),
664                overflow_hidden: true,
665                ..LayoutStyle::stack(Axis::Horizontal)
666            },
667            &[inner],
668            Paint::None,
669        );
670        // Only on the rail: open, this edge would cut focus rings.
671        if cx.sidebar().is_some_and(|s| s.icons) {
672            cx.clip(window);
673        }
674        cx.container(
675            LayoutStyle { padding: Insets::uniform(12.0), ..LayoutStyle::stack(Axis::Horizontal) },
676            &[window],
677            Paint::None,
678        )
679    }
680}
681
682/// Header, scrolling content and footer, and the scope their parts read.
683struct SidebarColumn<'a, C> {
684    children: &'a C,
685    parts: Option<&'a SidebarParts>,
686    scope: Rc<SidebarScope>,
687    width: f32,
688    /// The width the header and footer lay out at.
689    full_width: f32,
690    height: Option<f32>,
691}
692
693impl<C: ViewSeq> View for SidebarColumn<'_, C> {
694    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
695        let (header, footer) = self.parts.map_or((None, None), |p| (p.header.as_ref(), p.footer.as_ref()));
696        let outer = cx.replace_sidebar(Some(self.scope.clone()));
697        let mut children = Vec::new();
698        if let Some(header) = header {
699            children.push(cx.child(&SidebarEnd { content: header.as_ref(), width: self.full_width }));
700            children.push(cx.child(&Divider::horizontal()));
701        }
702        children.push(
703            cx.child(&ScrollView::new(self.children).spacing(2.0).padding(Insets::symmetric(16.0, 12.0))),
704        );
705        if let Some(footer) = footer {
706            children.push(cx.child(&Divider::horizontal()));
707            children.push(cx.child(&SidebarEnd { content: footer.as_ref(), width: self.full_width }));
708        }
709        cx.replace_sidebar(outer);
710        self.scope.count.set(self.scope.next.get());
711        cx.container(
712            LayoutStyle {
713                width: Some(self.width),
714                height: self.height,
715                // However much it holds, its content scrolls, and the column takes what it is given.
716                min_height: Some(0.0),
717                align: Align::Stretch,
718                ..LayoutStyle::stack(Axis::Vertical)
719            },
720            &children,
721            Paint::None,
722        )
723    }
724}
725
726impl<C: ViewSeq> View for Sidebar<C> {
727    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
728        self.build_with(cx, None)
729    }
730}
731
732impl<C: ViewSeq> View for Sidebar<C, SidebarParts> {
733    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
734        self.build_with(cx, Some(&self.parts))
735    }
736}
737
738#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part3", with = "P = (); P = crate::SidebarParts"))]
739impl<C: ViewSeq, P> Sidebar<C, P> {
740    fn build_with(&self, cx: &mut BuildCx<'_>, parts: Option<&SidebarParts>) -> NodeId {
741        let theme = *cx.theme();
742        let own = cx.use_state(|| SidebarState::new(true));
743        let focus_to = util::roving_cell(cx, None);
744        let count = cx.use_state(|| Rc::new(Cell::new(0_usize))).get();
745        let state = parts.and_then(|p| p.state.clone()).unwrap_or_else(|| own.get());
746        let viewport = cx.viewport();
747        let narrow = self.narrow_below.is_some_and(|below| viewport.width < below);
748        state.narrow.set(narrow);
749        if !narrow && *state.sheet.peek() {
750            state.sheet.set(false);
751        }
752        if self.shortcut && (narrow || self.collapse != SidebarCollapse::Fixed) {
753            let toggle = state.clone();
754            cx.shortcut(Key::Character("b".into()), true, Rc::new(move || toggle.toggle()));
755        }
756        let margin = if self.variant == SidebarVariant::Floating { theme.spacing.s2 } else { 0.0 };
757        let open = state.open.get();
758        let target = match self.collapse {
759            _ if narrow => 0.0,
760            SidebarCollapse::Fixed => self.width,
761            _ if open => self.width,
762            SidebarCollapse::Icons => SIDEBAR_RAIL + 2.0 * margin,
763            SidebarCollapse::Hidden => 0.0,
764        };
765        // Reduced motion makes this instant. Labels stay until the column has
766        // narrowed, and the column's edge uncovers them on the way back.
767        // Asked for many times a day, so it ends quickly and at once, without a spring's tail.
768        let width = cx.animated(target, theme.motion.base);
769        let scope = Rc::new(SidebarScope {
770            icons: self.collapse == SidebarCollapse::Icons && !open && width <= target + 0.5,
771            in_sheet: narrow,
772            side: self.side,
773            state: state.clone(),
774            focus_to,
775            next: Cell::new(0),
776            count,
777        });
778        let label = self.label.clone().unwrap_or_else(|| cx.labels().navigation.clone());
779
780        if narrow {
781            let anchor = cx.empty();
782            if state.sheet.get() {
783                let panel = self.width.min(viewport.width);
784                let column = SidebarColumn {
785                    children: &self.children,
786                    parts,
787                    scope,
788                    width: panel,
789                    full_width: panel,
790                    height: Some(viewport.height),
791                };
792                let layer = SheetLayer {
793                    open: &state.sheet.binding(),
794                    title: &label,
795                    description: None,
796                    content: Some(&column),
797                    side: self.side,
798                    width: panel,
799                    bare: true,
800                };
801                let close = state.sheet.clone();
802                cx.overlay(anchor, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
803            }
804            return anchor;
805        }
806        if target == 0.0 && width <= 0.5 {
807            return cx.empty();
808        }
809
810        let inset = margin.min(width / 2.0);
811        let card_width = (width - 2.0 * inset).max(0.0);
812        let full_width = (self.width - 2.0 * margin).max(0.0);
813        let reduced = scope.icons;
814        let column = SidebarColumn {
815            children: &self.children,
816            parts,
817            scope,
818            // The column keeps its width while the edge covers and uncovers it, so that
819            // nothing inside changes on the way, as names squeezing letter by letter.
820            width: if reduced { card_width } else { full_width },
821            full_width,
822            height: None,
823        };
824        let column = cx.child(&column);
825        let (fill, border) = (theme.color(Semantic::Surface), theme.color(Semantic::Border));
826        let side = self.side;
827        let paint = match self.variant {
828            SidebarVariant::Inset => Paint::None,
829            SidebarVariant::Floating => Paint::Quad(QuadPaint {
830                corner_radius: theme.radii.lg,
831                border: Some(Border { width: 1.0, color: border }),
832                shadow: Some(theme.elevation.soft),
833                ..QuadPaint::new(fill)
834            }),
835            _ => Paint::Custom(Rc::new(move |f: Rect, scene| {
836                let x = if side == Side::Left { f.max_x() - 1.0 } else { f.x() };
837                scene.push_quad(Quad::new(f, fill));
838                scene.push_quad(Quad::new(Rect::new(x, f.y(), 1.0, f.height()), border));
839            })),
840        };
841        let mut card_parts = vec![column];
842        if self.rail && self.collapse != SidebarCollapse::Fixed {
843            let x = if side == Side::Left { (card_width - SIDEBAR_RAIL_STRIP).max(0.0) } else { 0.0 };
844            card_parts.push(cx.child(&SidebarRailStrip { state: state.clone(), x, height: viewport.height }));
845        }
846        let card = cx.container(
847            LayoutStyle {
848                width: Some(card_width),
849                min_height: Some(0.0),
850                align: Align::Stretch,
851                align_self: Some(Align::Stretch),
852                overflow_hidden: true,
853                ..LayoutStyle::stack(Axis::Horizontal)
854            },
855            &card_parts,
856            paint,
857        );
858        cx.clip(card);
859        cx.semantics(card, Semantics::new(Role::Navigation).name(label));
860        if margin == 0.0 {
861            return card;
862        }
863        cx.container(
864            LayoutStyle {
865                width: Some(width),
866                min_height: Some(0.0),
867                padding: Insets::uniform(inset),
868                align: Align::Stretch,
869                align_self: Some(Align::Stretch),
870                ..LayoutStyle::stack(Axis::Horizontal)
871            },
872            &[card],
873            Paint::None,
874        )
875    }
876}
877
878/// The strip of a [`Sidebar::rail`], which shows a line under the pointer.
879struct SidebarRailStrip {
880    state: SidebarState,
881    x: f32,
882    height: f32,
883}
884
885impl View for SidebarRailStrip {
886    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
887        let theme = *cx.theme();
888        let paint = if cx.is_hovered() {
889            let line = theme.color(Semantic::Border).lerp(theme.color(Semantic::Ink), 0.25);
890            Paint::Custom(Rc::new(move |f: Rect, scene| {
891                let x = f.x() + (f.width() - 2.0) / 2.0;
892                scene.push_quad(Quad::new(Rect::new(x, f.y(), 2.0, f.height()), line));
893            }))
894        } else {
895            Paint::None
896        };
897        let node = cx.container(
898            LayoutStyle {
899                absolute: Some((self.x, 0.0)),
900                width: Some(SIDEBAR_RAIL_STRIP),
901                height: Some(self.height),
902                ..LayoutStyle::leaf()
903            },
904            &[],
905            paint,
906        );
907        let state = self.state.clone();
908        cx.on_click(node, Rc::new(move || state.toggle()));
909        cx.cursor(node, CursorIcon::ColResize);
910        // SidebarTrigger is the way in for the keyboard and for screen readers.
911        cx.semantics(node, Semantics::hidden());
912        node
913    }
914}
915
916/// The name of an app at the top of a [`Sidebar`], with its mark. Reduced to icons,
917/// only the mark stays, in line with the icons.
918#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
919pub struct SidebarBrand {
920    mark: AnyView,
921    title: String,
922    subtitle: Option<String>,
923}
924
925impl fmt::Debug for SidebarBrand {
926    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
927        f.debug_struct("SidebarBrand").field("title", &self.title).finish_non_exhaustive()
928    }
929}
930
931#[cfg_attr(feature = "live", ravenrs_live::expose)]
932impl SidebarBrand {
933    /// The name `title` beside `mark`.
934    pub fn new(mark: impl View + 'static, title: impl Into<String>) -> Self {
935        Self { mark: Box::new(mark), title: title.into(), subtitle: None }
936    }
937
938    /// A second line, such as what the app is for.
939    #[must_use]
940    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
941        self.subtitle = Some(subtitle.into());
942        self
943    }
944}
945
946impl View for SidebarBrand {
947    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
948        let mark = cx.child(self.mark.as_ref());
949        let mut parts = vec![mark];
950        if !cx.sidebar().is_some_and(|s| s.icons) {
951            let title = cx.child(
952                &crate::Text::new(self.title.clone()).style(TextStyle::Body).font_weight(Weight::SEMIBOLD),
953            );
954            let mut lines = vec![title];
955            if let Some(subtitle) = &self.subtitle {
956                lines.push(
957                    cx.child(
958                        &crate::Text::new(subtitle.clone())
959                            .style(TextStyle::Callout)
960                            .foreground(Semantic::InkMuted)
961                            .truncate(),
962                    ),
963                );
964            }
965            parts.push(cx.container(
966                LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Vertical) },
967                &lines,
968                Paint::None,
969            ));
970        }
971        cx.container(
972            LayoutStyle {
973                padding: Insets::symmetric(2.0, SIDEBAR_BRAND_INSET),
974                spacing: 10.0,
975                align: Align::Center,
976                ..LayoutStyle::stack(Axis::Horizontal)
977            },
978            &parts,
979            Paint::None,
980        )
981    }
982}
983
984/// The page beside a [`SidebarVariant::Inset`] sidebar: a raised surface that
985/// takes the remaining room.
986#[derive(Debug)]
987pub struct SidebarInset<V>(V);
988
989#[cfg_attr(feature = "live", ravenrs_live::expose)]
990impl<V: View> SidebarInset<V> {
991    /// A page with this content.
992    pub fn new(content: V) -> Self {
993        Self(content)
994    }
995}
996
997impl<V: View> View for SidebarInset<V> {
998    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
999        let theme = *cx.theme();
1000        let content = cx.child(&self.0);
1001        let page = cx.container(
1002            LayoutStyle {
1003                grow: 1.0,
1004                min_width: Some(0.0),
1005                align: Align::Stretch,
1006                overflow_hidden: true,
1007                ..LayoutStyle::stack(Axis::Vertical)
1008            },
1009            &[content],
1010            Paint::Quad(QuadPaint {
1011                corner_radius: theme.radii.lg,
1012                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
1013                shadow: Some(theme.elevation.soft),
1014                ..QuadPaint::new(theme.color(Semantic::Surface))
1015            }),
1016        );
1017        cx.container(
1018            LayoutStyle {
1019                grow: 1.0,
1020                min_width: Some(0.0),
1021                padding: Insets::uniform(theme.spacing.s2),
1022                align: Align::Stretch,
1023                align_self: Some(Align::Stretch),
1024                ..LayoutStyle::stack(Axis::Horizontal)
1025            },
1026            &[page],
1027            Paint::None,
1028        )
1029    }
1030}
1031
1032/// A button that opens and closes a [`Sidebar`], sharing its [`SidebarState`].
1033#[derive(Clone, Debug)]
1034#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1035pub struct SidebarTrigger(SidebarState);
1036
1037#[cfg_attr(feature = "live", ravenrs_live::expose)]
1038impl SidebarTrigger {
1039    /// A trigger for the sidebar that uses `state`.
1040    pub fn new(state: SidebarState) -> Self {
1041        Self(state)
1042    }
1043}
1044
1045struct SidebarTriggerButton(SidebarState);
1046
1047impl View for SidebarTriggerButton {
1048    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1049        let state = self.0.clone();
1050        let button = IconButton::new(IconName::PanelLeft)
1051            .label(cx.labels().toggle_sidebar.clone())
1052            .size(ButtonSize::Small)
1053            .on_click(move || state.toggle());
1054        let node = button.build(cx);
1055        let open = self.0.is_open();
1056        cx.update_semantics(node, |s| s.expanded = Some(open));
1057        node
1058    }
1059}
1060
1061impl View for SidebarTrigger {
1062    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1063        let name = cx.labels().toggle_sidebar.clone();
1064        cx.child(&Tooltip::new(SidebarTriggerButton(self.0.clone()), name))
1065    }
1066}
1067
1068/// A small caption that groups sidebar items. Reduced to icons, it is a hairline.
1069#[derive(Clone, Debug)]
1070#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1071pub struct SidebarSection(String);
1072
1073#[cfg_attr(feature = "live", ravenrs_live::expose)]
1074impl SidebarSection {
1075    /// A section caption; shown in upper case.
1076    pub fn new(title: impl Into<String>) -> Self {
1077        Self(title.into())
1078    }
1079}
1080
1081/// What stands for a caption when only icons are left.
1082fn rail_separator(cx: &mut BuildCx<'_>) -> NodeId {
1083    let line = cx.child(&Divider::horizontal());
1084    cx.container(
1085        LayoutStyle {
1086            padding: Insets::symmetric(8.0, 6.0),
1087            align: Align::Stretch,
1088            ..LayoutStyle::stack(Axis::Vertical)
1089        },
1090        &[line],
1091        Paint::None,
1092    )
1093}
1094
1095impl View for SidebarSection {
1096    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1097        if let Some(scope) = cx.sidebar().filter(|s| s.icons) {
1098            return if scope.next.get() > 0 { rail_separator(cx) } else { cx.empty() };
1099        }
1100        let color = cx.theme().color(Semantic::InkMuted);
1101        let text = util::label(cx, &self.0.to_uppercase(), TextStyle::Caption, None, color);
1102        cx.container(
1103            LayoutStyle {
1104                padding: Insets { top: 16.0, bottom: 6.0, left: 10.0, right: 10.0 },
1105                ..LayoutStyle::stack(Axis::Horizontal)
1106            },
1107            &[text],
1108            Paint::None,
1109        )
1110    }
1111}
1112
1113/// A titled set of sidebar items that can fold away and carry actions.
1114pub struct SidebarGroup<C> {
1115    title: String,
1116    children: C,
1117    collapsible: bool,
1118    open_by_default: bool,
1119    open: Option<Binding<bool>>,
1120    actions: Vec<AnyView>,
1121}
1122
1123impl<C> fmt::Debug for SidebarGroup<C> {
1124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1125        f.debug_struct("SidebarGroup").field("title", &self.title).finish_non_exhaustive()
1126    }
1127}
1128
1129#[cfg_attr(feature = "live", ravenrs_live::expose)]
1130impl<C: ViewSeq> SidebarGroup<C> {
1131    /// A group of `children` under this caption.
1132    pub fn new(title: impl Into<String>, children: C) -> Self {
1133        Self {
1134            title: title.into(),
1135            children,
1136            collapsible: false,
1137            open_by_default: true,
1138            open: None,
1139            actions: Vec::new(),
1140        }
1141    }
1142
1143    /// Lets the caption fold the group. Reduced to icons, a group is always
1144    /// unfolded, because its caption is gone.
1145    pub fn collapsible(mut self) -> Self {
1146        self.collapsible = true;
1147        self
1148    }
1149
1150    /// Whether a collapsible group starts unfolded (true by default).
1151    pub fn open(mut self, open: bool) -> Self {
1152        self.open_by_default = open;
1153        self
1154    }
1155
1156    /// Reads and writes whether it is unfolded from the app; makes it collapsible.
1157    pub fn open_binding(mut self, open: Binding<bool>) -> Self {
1158        self.open = Some(open);
1159        self.collapsible = true;
1160        self
1161    }
1162
1163    /// Adds a control at the end of the caption, usually a small [`IconButton`]
1164    /// with a label, or a [`DropdownMenu`](crate::DropdownMenu). Hidden when
1165    /// reduced to icons.
1166    pub fn action(mut self, action: impl View + 'static) -> Self {
1167        self.actions.push(Box::new(action));
1168        self
1169    }
1170}
1171
1172struct SidebarGroupTitle<'a> {
1173    title: &'a str,
1174    open: Option<Binding<bool>>,
1175}
1176
1177impl View for SidebarGroupTitle<'_> {
1178    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1179        let theme = *cx.theme();
1180        let hovered = self.open.is_some() && cx.is_hovered();
1181        let color = theme.color(if hovered { Semantic::Ink } else { Semantic::InkMuted });
1182        let attrs = util::attrs(&theme, TextStyle::Caption, None);
1183        let text = cx.text_truncated(&self.title.to_uppercase(), &attrs, color, LayoutStyle::leaf());
1184        let text = cx.container(
1185            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
1186            &[text],
1187            Paint::None,
1188        );
1189        let mut children = vec![text];
1190        if let Some(open) = &self.open {
1191            let chevron = if open.get() { IconName::ChevronDown } else { IconName::ChevronRight };
1192            children.push(util::icon(cx, chevron, 14.0, color));
1193        }
1194        let node = cx.container(
1195            LayoutStyle {
1196                grow: 1.0,
1197                min_width: Some(0.0),
1198                padding: Insets::symmetric(6.0, 10.0),
1199                align: Align::Center,
1200                spacing: theme.spacing.s2,
1201                ..LayoutStyle::stack(Axis::Horizontal)
1202            },
1203            &children,
1204            Paint::Quad(QuadPaint {
1205                corner_radius: theme.radii.md,
1206                ..QuadPaint::new(if hovered {
1207                    theme.color(Semantic::Ink).with_alpha(0.05)
1208                } else {
1209                    Color::TRANSPARENT
1210                })
1211            }),
1212        );
1213        if let Some(open) = &self.open {
1214            motion::control(cx, node);
1215            cx.semantics(node, Semantics::new(Role::Button).name(self.title).expanded(open.get()));
1216            let toggle = open.clone();
1217            cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
1218            cx.focusable(node, theme.radii.md);
1219            cx.cursor(node, CursorIcon::Pointer);
1220            let scope = cx.sidebar();
1221            wire_row_keys(cx, node, scope.as_deref(), Some(open.clone()));
1222        } else {
1223            // The group around it already carries this name.
1224            cx.semantics(node, Semantics::hidden());
1225        }
1226        node
1227    }
1228}
1229
1230struct SidebarRows<'a>(&'a dyn ViewSeq);
1231
1232impl View for SidebarRows<'_> {
1233    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1234        let children = cx.children(self.0);
1235        cx.container(
1236            LayoutStyle { spacing: 2.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
1237            &children,
1238            Paint::None,
1239        )
1240    }
1241}
1242
1243impl<C: ViewSeq> View for SidebarGroup<C> {
1244    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1245        let default = self.open_by_default;
1246        let own = cx.use_state(move || default);
1247        let open = self.open.clone().unwrap_or_else(|| own.binding());
1248        let scope = cx.sidebar();
1249        let icons = scope.as_ref().is_some_and(|s| s.icons);
1250        let mut children = Vec::new();
1251        if icons {
1252            // Nothing to separate from above the first rows.
1253            if scope.is_some_and(|s| s.next.get() > 0) {
1254                children.push(rail_separator(cx));
1255            }
1256        } else {
1257            let title = cx.child(&SidebarGroupTitle {
1258                title: &self.title,
1259                open: self.collapsible.then(|| open.clone()),
1260            });
1261            let mut parts = vec![title];
1262            parts.extend(self.actions.iter().map(|action| cx.child(action.as_ref())));
1263            children.push(cx.container(
1264                LayoutStyle {
1265                    padding: Insets { top: 10.0, ..Insets::ZERO },
1266                    align: Align::Center,
1267                    spacing: 2.0,
1268                    ..LayoutStyle::stack(Axis::Horizontal)
1269                },
1270                &parts,
1271                Paint::None,
1272            ));
1273        }
1274        let unfolded = icons || !self.collapsible || open.get();
1275        children.push(motion::collapse(cx, unfolded, &SidebarRows(&self.children)));
1276        let node = cx.container(
1277            LayoutStyle { spacing: 2.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
1278            &children,
1279            Paint::None,
1280        );
1281        cx.semantics(node, Semantics::new(Role::Group).name(self.title.clone()));
1282        node
1283    }
1284}
1285
1286/// A sidebar row with an icon, a label, an optional count or badge, actions and
1287/// a submenu.
1288#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1289pub struct SidebarItem {
1290    icon: Option<IconName>,
1291    label: String,
1292    selected: bool,
1293    disabled: bool,
1294    count: Option<String>,
1295    badge: Option<Badge>,
1296    shortcut: Option<String>,
1297    compact: bool,
1298    on_click: Option<Rc<dyn Fn()>>,
1299    actions: Vec<AnyView>,
1300    submenu: Vec<SidebarItem>,
1301    expanded_by_default: Option<bool>,
1302    expanded: Option<Binding<bool>>,
1303}
1304
1305impl fmt::Debug for SidebarItem {
1306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1307        f.debug_struct("SidebarItem").field("label", &self.label).field("selected", &self.selected).finish()
1308    }
1309}
1310
1311#[cfg_attr(feature = "live", ravenrs_live::expose)]
1312impl SidebarItem {
1313    /// An item with this icon and label.
1314    pub fn new(icon: IconName, label: impl Into<String>) -> Self {
1315        let mut item = Self::text(label);
1316        item.icon = Some(icon);
1317        item
1318    }
1319
1320    /// An item with only a label, the usual entry of a submenu. Reduced to
1321    /// icons, its first letter stands in for the icon.
1322    pub fn text(label: impl Into<String>) -> Self {
1323        Self {
1324            icon: None,
1325            label: label.into(),
1326            selected: false,
1327            disabled: false,
1328            count: None,
1329            badge: None,
1330            shortcut: None,
1331            compact: false,
1332            on_click: None,
1333            actions: Vec::new(),
1334            submenu: Vec::new(),
1335            expanded_by_default: None,
1336            expanded: None,
1337        }
1338    }
1339
1340    /// Highlights the item as the current location.
1341    pub fn selected(mut self, selected: bool) -> Self {
1342        self.selected = selected;
1343        self
1344    }
1345
1346    /// Dims the item and takes it out of clicks and of keyboard focus.
1347    pub fn disabled(mut self, disabled: bool) -> Self {
1348        self.disabled = disabled;
1349        self
1350    }
1351
1352    /// A count badge at the end of the row (e.g. unread items). Reduced to
1353    /// icons, a dot on the icon stands for it, and screen readers read it.
1354    pub fn count(mut self, count: impl Into<String>) -> Self {
1355        self.count = Some(count.into());
1356        self
1357    }
1358
1359    /// A [`Badge`] at the end of the row, treated like [`Self::count`]: for a
1360    /// tone or a word such as "New".
1361    pub fn badge(mut self, badge: Badge) -> Self {
1362        self.badge = Some(badge);
1363        self
1364    }
1365
1366    /// Shows the keys that do the same, at the end of the row.
1367    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
1368        self.shortcut = Some(keys.into());
1369        self
1370    }
1371
1372    /// A denser row (32 px, `callout` text) for panels with many items.
1373    pub fn compact(mut self) -> Self {
1374        self.compact = true;
1375        self
1376    }
1377
1378    /// Runs `handler` when the item is clicked.
1379    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
1380        self.on_click = Some(Rc::new(handler));
1381        self
1382    }
1383
1384    /// Adds a control at the end of the row, beside its button: usually a small
1385    /// [`IconButton`] with a label, or a [`DropdownMenu`](crate::DropdownMenu).
1386    /// Hidden when reduced to icons.
1387    pub fn action(mut self, action: impl View + 'static) -> Self {
1388        self.actions.push(Box::new(action));
1389        self
1390    }
1391
1392    /// Adds a row to the submenu that unfolds under this one. Clicking this row
1393    /// folds and unfolds it; reduced to icons, clicking it opens the sidebar too.
1394    pub fn item(mut self, item: SidebarItem) -> Self {
1395        self.submenu.push(item);
1396        self
1397    }
1398
1399    /// Whether the submenu starts unfolded. By default, only if it holds the
1400    /// selected item.
1401    pub fn expanded(mut self, expanded: bool) -> Self {
1402        self.expanded_by_default = Some(expanded);
1403        self
1404    }
1405
1406    /// Reads and writes whether the submenu is unfolded from the app.
1407    pub fn expanded_binding(mut self, expanded: Binding<bool>) -> Self {
1408        self.expanded = Some(expanded);
1409        self
1410    }
1411
1412    fn holds_selection(&self) -> bool {
1413        self.submenu.iter().any(|item| item.selected || item.holds_selection())
1414    }
1415}
1416
1417/// The focusable part of a [`SidebarItem`].
1418struct SidebarButton<'a> {
1419    item: &'a SidebarItem,
1420    /// Whether the submenu is unfolded, for a row with one.
1421    expanded: Option<Binding<bool>>,
1422    /// Draws the row's fill itself: there is no action sharing it.
1423    filled: bool,
1424}
1425
1426/// A row's fill, shared by its button and the box around its actions.
1427fn row_paint(cx: &BuildCx<'_>, selected: bool, hovered: bool) -> Paint {
1428    let theme = cx.theme();
1429    let fill = if selected {
1430        theme.color(Semantic::Control)
1431    } else if hovered {
1432        theme.color(Semantic::Ink).with_alpha(0.05)
1433    } else {
1434        Color::TRANSPARENT
1435    };
1436    Paint::Quad(QuadPaint { corner_radius: theme.radii.md, ..QuadPaint::new(fill) })
1437}
1438
1439impl View for SidebarButton<'_> {
1440    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1441        let theme = *cx.theme();
1442        let item = self.item;
1443        let scope = cx.sidebar();
1444        let icons = scope.as_ref().is_some_and(|s| s.icons);
1445        let hovered = cx.is_hovered() && !item.disabled;
1446        let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
1447        let dim = |color: Color| if item.disabled { color.with_alpha(0.5) } else { color };
1448        let icon_color =
1449            dim(if item.selected { theme.color(Semantic::Accent).lerp(ink, 0.35) } else { muted });
1450        let text_style = if item.compact { TextStyle::Callout } else { TextStyle::Body };
1451        let mut children = Vec::new();
1452        if let Some(icon) = item.icon {
1453            children.push(util::icon(cx, icon, 16.0, icon_color));
1454        } else if icons {
1455            let letter = item.label.chars().next().map(String::from).unwrap_or_default();
1456            children.push(util::label(cx, &letter, TextStyle::Callout, Some(Weight::SEMIBOLD), icon_color));
1457        }
1458        let note = item.count.as_deref().or(item.badge.as_ref().map(Badge::text));
1459        if icons {
1460            if note.is_some() {
1461                children.push(cx.container(
1462                    LayoutStyle {
1463                        width: Some(6.0),
1464                        height: Some(6.0),
1465                        absolute: Some((24.0, 6.0)),
1466                        ..LayoutStyle::leaf()
1467                    },
1468                    &[],
1469                    Paint::Quad(QuadPaint {
1470                        corner_radius: 3.0,
1471                        ..QuadPaint::new(theme.color(Semantic::Accent))
1472                    }),
1473                ));
1474            }
1475        } else {
1476            let attrs = util::attrs(
1477                &theme,
1478                text_style,
1479                Some(if item.selected { Weight::SEMIBOLD } else { Weight::REGULAR }),
1480            );
1481            let color = dim(if item.selected || hovered { ink } else { muted });
1482            let label = cx.text_truncated(&item.label, &attrs, color, LayoutStyle::leaf());
1483            children.push(cx.container(
1484                LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
1485                &[label],
1486                Paint::None,
1487            ));
1488            if let Some(count) = &item.count {
1489                children.push(util::label(cx, count, TextStyle::Caption, None, dim(muted)));
1490            }
1491            if let Some(badge) = &item.badge {
1492                children.push(cx.child(badge));
1493            }
1494            if let Some(keys) = &item.shortcut {
1495                children.push(cx.child(&crate::Kbd::new(keys.clone())));
1496            }
1497            if let Some(expanded) = &self.expanded {
1498                let chevron = if expanded.get() { IconName::ChevronDown } else { IconName::ChevronRight };
1499                children.push(util::icon(cx, chevron, 14.0, dim(muted)));
1500            }
1501        }
1502        let style = LayoutStyle {
1503            padding: if icons {
1504                Insets::ZERO
1505            } else {
1506                Insets::symmetric(0.0, if item.compact { theme.spacing.s2 } else { 10.0 })
1507            },
1508            width: icons.then_some(SIDEBAR_RAIL - 24.0),
1509            height: Some(if item.compact { 32.0 } else { 36.0 }),
1510            spacing: if item.compact { theme.spacing.s2 } else { 10.0 },
1511            align: Align::Center,
1512            justify: if icons { Justify::Center } else { Justify::Start },
1513            grow: if self.filled { 0.0 } else { 1.0 },
1514            min_width: Some(0.0),
1515            ..LayoutStyle::stack(Axis::Horizontal)
1516        };
1517        let paint = if self.filled { row_paint(cx, item.selected, hovered) } else { Paint::None };
1518        let node = cx.container(style, &children, paint);
1519        if self.filled {
1520            motion::control(cx, node);
1521        }
1522        let mut semantics = Semantics::new(Role::Button)
1523            .name(item.label.clone())
1524            .selected(item.selected)
1525            .disabled(item.disabled);
1526        if let Some(note) = note {
1527            semantics = semantics.description(note);
1528        }
1529        if let Some(keys) = &item.shortcut {
1530            semantics = semantics.description(keys.clone());
1531        }
1532        if let Some(expanded) = &self.expanded {
1533            semantics = semantics.expanded(expanded.get());
1534        }
1535        cx.semantics(node, semantics);
1536        cx.cursor(node, if item.disabled { CursorIcon::Default } else { CursorIcon::Pointer });
1537        if !item.disabled {
1538            let handler = item.on_click.clone();
1539            let expanded = self.expanded.clone();
1540            let state = scope.as_ref().filter(|s| icons || s.in_sheet).map(|s| s.state.clone());
1541            if handler.is_some() || expanded.is_some() || state.is_some() {
1542                cx.on_click(
1543                    node,
1544                    Rc::new(move || {
1545                        match (&expanded, &state) {
1546                            // Reduced to icons there is no room to unfold into.
1547                            (Some(expanded), Some(state)) if icons => {
1548                                expanded.set(true);
1549                                state.set_open(true);
1550                            }
1551                            (Some(expanded), _) => expanded.update(|e| *e = !*e),
1552                            // A sheet is in the way of where the item leads.
1553                            (None, Some(state)) if !icons => state.set_open(false),
1554                            _ => {}
1555                        }
1556                        if let Some(handler) = &handler {
1557                            handler();
1558                        }
1559                    }),
1560                );
1561            }
1562            cx.focusable(node, theme.radii.md);
1563            let keys = self.expanded.clone().filter(|_| !icons);
1564            wire_row_keys(cx, node, scope.as_deref(), keys);
1565        }
1566        if let Some(scope) = scope.as_ref().filter(|s| s.icons)
1567            && (cx.is_hovered() || cx.is_focus_visible())
1568        {
1569            let placement =
1570                if scope.side == Side::Left { Placement::RightCenter } else { Placement::LeftCenter };
1571            cx.overlay(node, placement, &Bubble(&item.label), None);
1572        }
1573        node
1574    }
1575}
1576
1577/// The rows under a [`SidebarItem`], hung from a hairline below its icon.
1578struct SidebarSubmenu<'a>(&'a [SidebarItem]);
1579
1580impl View for SidebarSubmenu<'_> {
1581    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1582        let border = cx.theme().color(Semantic::Border);
1583        let children: Vec<NodeId> = self.0.iter().map(|item| cx.child(item)).collect();
1584        let node = cx.container(
1585            LayoutStyle {
1586                padding: Insets { top: 2.0, left: 26.0, ..Insets::ZERO },
1587                spacing: 2.0,
1588                align: Align::Stretch,
1589                ..LayoutStyle::stack(Axis::Vertical)
1590            },
1591            &children,
1592            Paint::Custom(Rc::new(move |f: Rect, scene| {
1593                let line = Rect::new(f.x() + 18.0, f.y() + 2.0, 1.0, (f.height() - 2.0).max(0.0));
1594                scene.push_quad(Quad::new(line, border));
1595            })),
1596        );
1597        cx.semantics(node, Semantics::new(Role::Group));
1598        node
1599    }
1600}
1601
1602impl View for SidebarItem {
1603    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1604        let default = self.expanded_by_default.unwrap_or_else(|| self.holds_selection());
1605        let own = cx.use_state(move || default);
1606        let expanded = self.expanded.clone().unwrap_or_else(|| own.binding());
1607        let icons = cx.sidebar().is_some_and(|s| s.icons);
1608        let has_submenu = !self.submenu.is_empty();
1609        let filled = icons || self.actions.is_empty();
1610        let button =
1611            cx.child(&SidebarButton { item: self, expanded: has_submenu.then(|| expanded.clone()), filled });
1612        let row = if filled {
1613            button
1614        } else {
1615            let mut parts = vec![button];
1616            parts.extend(self.actions.iter().map(|action| cx.child(action.as_ref())));
1617            let paint = row_paint(cx, self.selected, cx.is_hover_within() && !self.disabled);
1618            let row = cx.container(
1619                LayoutStyle {
1620                    padding: Insets { right: 2.0, ..Insets::ZERO },
1621                    align: Align::Center,
1622                    spacing: 2.0,
1623                    ..LayoutStyle::stack(Axis::Horizontal)
1624                },
1625                &parts,
1626                paint,
1627            );
1628            motion::control(cx, row);
1629            row
1630        };
1631        if !has_submenu || icons {
1632            return row;
1633        }
1634        let submenu = motion::collapse(cx, expanded.get(), &SidebarSubmenu(&self.submenu));
1635        cx.container(
1636            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
1637            &[row, submenu],
1638            Paint::None,
1639        )
1640    }
1641}
1642
1643/// Placeholder rows while a sidebar's items load.
1644#[derive(Clone, Copy, Debug)]
1645#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1646pub struct SidebarSkeleton {
1647    rows: usize,
1648    icon: bool,
1649}
1650
1651#[cfg_attr(feature = "live", ravenrs_live::expose)]
1652impl SidebarSkeleton {
1653    /// This many placeholder rows.
1654    pub fn new(rows: usize) -> Self {
1655        Self { rows, icon: true }
1656    }
1657
1658    /// Whether the rows have a place for an icon (true by default).
1659    pub fn icon(mut self, icon: bool) -> Self {
1660        self.icon = icon;
1661        self
1662    }
1663}
1664
1665impl View for SidebarSkeleton {
1666    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1667        // Uneven lengths read as text; the same ones on each build stay still.
1668        const LENGTHS: [f32; 4] = [128.0, 88.0, 152.0, 104.0];
1669        let icons = cx.sidebar().is_some_and(|s| s.icons);
1670        let mut rows = Vec::with_capacity(self.rows);
1671        for i in 0..self.rows {
1672            let mut parts = Vec::new();
1673            if self.icon || icons {
1674                parts.push(cx.child(&Skeleton::block(16.0, 16.0)));
1675            }
1676            if !icons {
1677                parts.push(cx.child(&Skeleton::block(LENGTHS[i % LENGTHS.len()], 12.0)));
1678            }
1679            rows.push(cx.container(
1680                LayoutStyle {
1681                    height: Some(36.0),
1682                    padding: Insets::symmetric(0.0, 10.0),
1683                    spacing: 10.0,
1684                    align: Align::Center,
1685                    overflow_hidden: true,
1686                    ..LayoutStyle::stack(Axis::Horizontal)
1687                },
1688                &parts,
1689                Paint::None,
1690            ));
1691        }
1692        let name = cx.labels().loading.clone();
1693        let node = cx.container(
1694            LayoutStyle { spacing: 2.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
1695            &rows,
1696            Paint::None,
1697        );
1698        cx.semantics(node, Semantics::new(Role::Group).name(name));
1699        node
1700    }
1701}
1702
1703/// A path of locations, the last one being the current page. With
1704/// [`on_navigate`](Self::on_navigate) the locations before it can be clicked.
1705///
1706/// ```
1707/// use ravenrs_widgets::{Breadcrumb, IconName, Ui};
1708///
1709/// let path = Breadcrumb::new(["Archive", "2026", "Weddings"])
1710///     .root_icon(IconName::Cloud)
1711///     .on_navigate(|index| println!("go to level {index}"));
1712/// let mut ui = Ui::new(path, ravenrs_style::Theme::raven_dark());
1713/// ui.update();
1714/// ```
1715#[derive(Clone)]
1716#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1717pub struct Breadcrumb {
1718    items: Vec<String>,
1719    root_icon: Option<IconName>,
1720    on_navigate: Option<Rc<dyn Fn(usize)>>,
1721}
1722
1723impl fmt::Debug for Breadcrumb {
1724    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1725        f.debug_tuple("Breadcrumb").field(&self.items).finish()
1726    }
1727}
1728
1729#[cfg_attr(feature = "live", ravenrs_live::expose)]
1730impl Breadcrumb {
1731    /// A breadcrumb of `items`, from the root to the current page.
1732    pub fn new(items: impl IntoIterator<Item = impl Into<String>>) -> Self {
1733        Self { items: items.into_iter().map(Into::into).collect(), root_icon: None, on_navigate: None }
1734    }
1735
1736    /// Makes every location but the current page a link: a click, Enter or Space runs
1737    /// `handler` with its position, 0 being the root.
1738    pub fn on_navigate(mut self, handler: impl Fn(usize) + 'static) -> Self {
1739        self.on_navigate = Some(Rc::new(handler));
1740        self
1741    }
1742
1743    /// Shows an icon before the first location.
1744    pub fn root_icon(mut self, icon: IconName) -> Self {
1745        self.root_icon = Some(icon);
1746        self
1747    }
1748}
1749
1750/// A location of a [`Breadcrumb`] that can be gone back to.
1751struct BreadcrumbLink<'a> {
1752    label: &'a str,
1753    icon: Option<IconName>,
1754    go: Rc<dyn Fn()>,
1755}
1756
1757impl View for BreadcrumbLink<'_> {
1758    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1759        let theme = *cx.theme();
1760        let lit = cx.is_hovered() || cx.is_focus_visible();
1761        let color = theme.color(if lit { Semantic::Ink } else { Semantic::InkMuted });
1762        let mut parts = Vec::new();
1763        if let Some(icon) = self.icon {
1764            parts.push(util::icon(cx, icon, 14.0, color));
1765        }
1766        parts.push(util::label(cx, self.label, TextStyle::Callout, None, color));
1767        let fill = if lit { theme.color(Semantic::Ink).with_alpha(0.06) } else { Color::TRANSPARENT };
1768        let node = cx.container(
1769            LayoutStyle {
1770                padding: Insets::symmetric(2.0, 4.0),
1771                spacing: 4.0,
1772                align: Align::Center,
1773                ..LayoutStyle::stack(Axis::Horizontal)
1774            },
1775            &parts,
1776            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
1777        );
1778        cx.semantics(node, Semantics::new(Role::Link).name(self.label.to_owned()));
1779        cx.on_click(node, self.go.clone());
1780        cx.focusable(node, theme.radii.sm);
1781        cx.cursor(node, CursorIcon::Pointer);
1782        node
1783    }
1784}
1785
1786impl View for Breadcrumb {
1787    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1788        let theme = *cx.theme();
1789        let mut children = Vec::new();
1790        for (i, item) in self.items.iter().enumerate() {
1791            let last = i + 1 == self.items.len();
1792            if i > 0 {
1793                children.push(util::icon(cx, IconName::ChevronRight, 14.0, theme.color(Semantic::InkMuted)));
1794            }
1795            let icon = self.root_icon.filter(|_| i == 0);
1796            if let Some(navigate) = self.on_navigate.as_ref().filter(|_| !last) {
1797                let navigate = navigate.clone();
1798                let link = BreadcrumbLink { label: item, icon, go: Rc::new(move || navigate(i)) };
1799                children.push(cx.keyed_child(i as u64, &link));
1800                continue;
1801            }
1802            let color = if last { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
1803            let weight = if last { Some(Weight::SEMIBOLD) } else { None };
1804            if let Some(icon) = icon {
1805                children.push(util::icon(cx, icon, 14.0, color));
1806            }
1807            children.push(util::label(cx, item, TextStyle::Callout, weight, color));
1808        }
1809        let node = cx.container(
1810            LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1811            &children,
1812            Paint::None,
1813        );
1814        cx.semantics(node, Semantics::new(Role::Group).name("Breadcrumb"));
1815        node
1816    }
1817}
1818
1819/// A header that shows or hides its content. Remembers whether it is open, or leaves
1820/// that to the app with [`expanded`](Self::expanded). The header can carry any view as
1821/// its title, a mark such as a [`Dot`](crate::Dot), and an on/off switch of its own.
1822///
1823/// ```
1824/// use ravenrs_reactive::State;
1825/// use ravenrs_widgets::{Disclosure, Dot, Text, Tone, Ui};
1826///
1827/// let (open, enabled) = (State::new(true), State::new(false));
1828/// let panel = Disclosure::new("Effects", Text::new("Vignette and grain"))
1829///     .expanded(open.binding())
1830///     .mark(Dot::new().tone(Tone::Warning).label("Changed"))
1831///     .switch(enabled.binding());
1832/// let mut ui = Ui::new(panel, ravenrs_style::Theme::raven_dark());
1833/// ui.update();
1834/// ```
1835#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1836pub struct Disclosure {
1837    title: String,
1838    title_view: Option<AnyView>,
1839    mark: Option<AnyView>,
1840    switch: Option<Binding<bool>>,
1841    content: AnyView,
1842    open_by_default: bool,
1843    expanded: Option<Binding<bool>>,
1844    key: Option<u64>,
1845}
1846
1847impl fmt::Debug for Disclosure {
1848    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1849        f.debug_struct("Disclosure").field("title", &self.title).finish_non_exhaustive()
1850    }
1851}
1852
1853#[cfg_attr(feature = "live", ravenrs_live::expose)]
1854impl Disclosure {
1855    /// A collapsed disclosure with this title and content.
1856    pub fn new(title: impl Into<String>, content: impl View + 'static) -> Self {
1857        Self {
1858            title: title.into(),
1859            title_view: None,
1860            mark: None,
1861            switch: None,
1862            content: Box::new(content),
1863            open_by_default: false,
1864            expanded: None,
1865            key: None,
1866        }
1867    }
1868
1869    /// Whether it starts open (closed by default).
1870    pub fn open(mut self, open: bool) -> Self {
1871        self.open_by_default = open;
1872        self
1873    }
1874
1875    /// Keeps whether it is open in this binding, so the app decides and can remember it.
1876    /// [`Self::open`] no longer matters.
1877    pub fn expanded(mut self, expanded: Binding<bool>) -> Self {
1878        self.expanded = Some(expanded);
1879        self
1880    }
1881
1882    /// Shows this view in the header instead of the title, which still names it for
1883    /// screen readers.
1884    pub fn title_view(mut self, view: impl View + 'static) -> Self {
1885        self.title_view = Some(Box::new(view));
1886        self
1887    }
1888
1889    /// Shows this view at the end of the header, so what is inside can be told while it
1890    /// is closed. A [`Dot`](crate::Dot) with a label says it to screen readers as well.
1891    pub fn mark(mut self, mark: impl View + 'static) -> Self {
1892        self.mark = Some(Box::new(mark));
1893        self
1894    }
1895
1896    /// Puts an on/off switch at the end of the header, named after the title. Using it
1897    /// does not open or close the disclosure.
1898    pub fn switch(mut self, value: Binding<bool>) -> Self {
1899        self.switch = Some(value);
1900        self
1901    }
1902
1903    /// What tells this disclosure apart inside an [`Accordion`], so it keeps its state
1904    /// when the list changes (its position by default).
1905    pub fn key(mut self, key: u64) -> Self {
1906        self.key = Some(key);
1907        self
1908    }
1909}
1910
1911struct DisclosureBody<'a>(&'a dyn View);
1912
1913impl View for DisclosureBody<'_> {
1914    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1915        let content = cx.child(self.0);
1916        cx.container(
1917            LayoutStyle {
1918                padding: Insets { top: 2.0, bottom: 10.0, left: 32.0, right: 8.0 },
1919                ..LayoutStyle::stack(Axis::Vertical)
1920            },
1921            &[content],
1922            Paint::None,
1923        )
1924    }
1925}
1926
1927impl View for Disclosure {
1928    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1929        let theme = *cx.theme();
1930        let default = self.open_by_default;
1931        let open = cx.use_state(move || default);
1932        let is_open = self.expanded.as_ref().map_or_else(|| open.get(), Binding::get);
1933        let hovered = cx.is_hovered();
1934        let chevron = if is_open { IconName::ChevronDown } else { IconName::ChevronRight };
1935        let icon = util::icon(cx, chevron, 16.0, theme.color(Semantic::InkMuted));
1936        let title = match &self.title_view {
1937            Some(view) => cx.keyed_child(1, view.as_ref()),
1938            None => util::label(
1939                cx,
1940                &self.title,
1941                TextStyle::Body,
1942                Some(Weight::SEMIBOLD),
1943                theme.color(Semantic::Ink),
1944            ),
1945        };
1946        let mut parts = vec![icon, title];
1947        if self.mark.is_some() || self.switch.is_some() {
1948            // What follows sits at the far end of the header.
1949            parts.push(cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None));
1950        }
1951        if let Some(mark) = &self.mark {
1952            parts.push(cx.keyed_child(2, mark.as_ref()));
1953        }
1954        if let Some(value) = &self.switch {
1955            let switch = Toggle::new(value.clone()).small();
1956            let node = cx.keyed_child(3, &switch);
1957            let name = self.title.clone();
1958            cx.update_semantics(node, |s| s.name = Some(name));
1959            parts.push(node);
1960        }
1961        let header_fill = if hovered {
1962            theme.color(Semantic::Ink).with_alpha(0.04)
1963        } else {
1964            ravenrs_core::Color::TRANSPARENT
1965        };
1966        let header = cx.container(
1967            LayoutStyle {
1968                padding: Insets::symmetric(10.0, 8.0),
1969                spacing: 8.0,
1970                align: Align::Center,
1971                ..LayoutStyle::stack(Axis::Horizontal)
1972            },
1973            &parts,
1974            Paint::Quad(QuadPaint { corner_radius: 8.0, ..QuadPaint::new(header_fill) }),
1975        );
1976        motion::control(cx, header);
1977        let mut semantics = Semantics::new(Role::Button).expanded(is_open);
1978        if self.title_view.is_some() || self.mark.is_some() || self.switch.is_some() {
1979            semantics = semantics.name(self.title.clone());
1980        }
1981        cx.semantics(header, semantics);
1982        let toggle: Rc<dyn Fn()> = match &self.expanded {
1983            Some(expanded) => {
1984                let expanded = expanded.clone();
1985                Rc::new(move || expanded.set(!expanded.get()))
1986            }
1987            None => {
1988                let open = open.clone();
1989                Rc::new(move || open.update(|o| *o = !*o))
1990            }
1991        };
1992        cx.on_click(header, toggle);
1993        cx.focusable(header, 8.0);
1994        cx.cursor(header, CursorIcon::Pointer);
1995        let body = motion::collapse(cx, is_open, &DisclosureBody(self.content.as_ref()));
1996        cx.container(
1997            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
1998            &[header, body],
1999            Paint::None,
2000        )
2001    }
2002}
2003
2004/// Disclosures separated by hairlines.
2005#[derive(Debug)]
2006#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
2007pub struct Accordion(Vec<Disclosure>);
2008
2009#[cfg_attr(feature = "live", ravenrs_live::expose)]
2010impl Accordion {
2011    /// An accordion of these disclosures.
2012    pub fn new(items: Vec<Disclosure>) -> Self {
2013        Self(items)
2014    }
2015}
2016
2017impl View for Accordion {
2018    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
2019        let mut children = Vec::new();
2020        for (i, item) in self.0.iter().enumerate() {
2021            if i > 0 {
2022                children.push(cx.child(&crate::Divider::horizontal()));
2023            }
2024            children.push(cx.keyed_child(item.key.unwrap_or(i as u64), item));
2025        }
2026        cx.container(
2027            LayoutStyle { spacing: 2.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
2028            &children,
2029            Paint::None,
2030        )
2031    }
2032}