1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22#[non_exhaustive]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
24pub enum TabsVariant {
25 #[default]
27 Underline,
28 Segmented,
30}
31
32#[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 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 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 pub fn item(mut self, label: impl Into<String>) -> Self {
88 self.tabs.push((label.into(), None));
89 self
90 }
91
92 pub fn variant(mut self, variant: TabsVariant) -> Self {
94 self.variant = variant;
95 self
96 }
97
98 pub fn size(mut self, size: ButtonSize) -> Self {
100 self.size = size;
101 self
102 }
103
104 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 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 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
224const 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 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 let content = cx.keyed_child(selected as u64, content.as_ref());
286 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
308const SIDEBAR_RAIL: f32 = 60.0;
311const SIDEBAR_RAIL_STRIP: f32 = 8.0;
313const SIDEBAR_BRAND_INSET: f32 = 5.0;
315
316#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
318#[non_exhaustive]
319#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
320pub enum SidebarCollapse {
321 #[default]
323 Fixed,
324 Icons,
326 Hidden,
328}
329
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
332#[non_exhaustive]
333#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
334pub enum SidebarVariant {
335 #[default]
337 Standard,
338 Floating,
340 Inset,
342}
343
344#[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 pub fn new(open: bool) -> Self {
366 Self::controlled(State::new(open).binding())
367 }
368
369 pub fn controlled(open: Binding<bool>) -> Self {
371 Self { open, sheet: State::new(false), narrow: Rc::new(Cell::new(false)) }
372 }
373
374 pub fn is_open(&self) -> bool {
377 if self.narrow.get() { self.sheet.get() } else { self.open.get() }
378 }
379
380 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 pub fn toggle(&self) {
391 self.set_open(!self.is_open());
392 }
393
394 pub fn is_narrow(&self) -> bool {
397 self.narrow.get()
398 }
399}
400
401pub(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
412fn 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
462pub 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#[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 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 pub fn width(mut self, width: f32) -> Self {
549 self.width = width;
550 self
551 }
552
553 pub fn collapsible(mut self, collapse: SidebarCollapse) -> Self {
557 self.collapse = collapse;
558 self
559 }
560
561 pub fn variant(mut self, variant: SidebarVariant) -> Self {
563 self.variant = variant;
564 self
565 }
566
567 pub fn side(mut self, side: Side) -> Self {
571 self.side = side;
572 self
573 }
574
575 pub fn narrow_below(mut self, width: f32) -> Self {
578 self.narrow_below = Some(width);
579 self
580 }
581
582 pub fn shortcut(mut self, enabled: bool) -> Self {
585 self.shortcut = enabled;
586 self
587 }
588
589 pub fn rail(mut self, rail: bool) -> Self {
592 self.rail = rail;
593 self
594 }
595
596 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 pub fn state(self, state: SidebarState) -> Sidebar<C, SidebarParts> {
626 self.with_parts(|parts| parts.state = Some(state))
627 }
628
629 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 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
641struct 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 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
682struct SidebarColumn<'a, C> {
684 children: &'a C,
685 parts: Option<&'a SidebarParts>,
686 scope: Rc<SidebarScope>,
687 width: f32,
688 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 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 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 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
878struct 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 cx.semantics(node, Semantics::hidden());
912 node
913 }
914}
915
916#[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 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 #[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#[derive(Debug)]
987pub struct SidebarInset<V>(V);
988
989#[cfg_attr(feature = "live", ravenrs_live::expose)]
990impl<V: View> SidebarInset<V> {
991 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#[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 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#[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 pub fn new(title: impl Into<String>) -> Self {
1077 Self(title.into())
1078 }
1079}
1080
1081fn 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
1113pub 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 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 pub fn collapsible(mut self) -> Self {
1146 self.collapsible = true;
1147 self
1148 }
1149
1150 pub fn open(mut self, open: bool) -> Self {
1152 self.open_by_default = open;
1153 self
1154 }
1155
1156 pub fn open_binding(mut self, open: Binding<bool>) -> Self {
1158 self.open = Some(open);
1159 self.collapsible = true;
1160 self
1161 }
1162
1163 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 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 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#[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 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 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 pub fn selected(mut self, selected: bool) -> Self {
1342 self.selected = selected;
1343 self
1344 }
1345
1346 pub fn disabled(mut self, disabled: bool) -> Self {
1348 self.disabled = disabled;
1349 self
1350 }
1351
1352 pub fn count(mut self, count: impl Into<String>) -> Self {
1355 self.count = Some(count.into());
1356 self
1357 }
1358
1359 pub fn badge(mut self, badge: Badge) -> Self {
1362 self.badge = Some(badge);
1363 self
1364 }
1365
1366 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
1368 self.shortcut = Some(keys.into());
1369 self
1370 }
1371
1372 pub fn compact(mut self) -> Self {
1374 self.compact = true;
1375 self
1376 }
1377
1378 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
1380 self.on_click = Some(Rc::new(handler));
1381 self
1382 }
1383
1384 pub fn action(mut self, action: impl View + 'static) -> Self {
1388 self.actions.push(Box::new(action));
1389 self
1390 }
1391
1392 pub fn item(mut self, item: SidebarItem) -> Self {
1395 self.submenu.push(item);
1396 self
1397 }
1398
1399 pub fn expanded(mut self, expanded: bool) -> Self {
1402 self.expanded_by_default = Some(expanded);
1403 self
1404 }
1405
1406 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
1417struct SidebarButton<'a> {
1419 item: &'a SidebarItem,
1420 expanded: Option<Binding<bool>>,
1422 filled: bool,
1424}
1425
1426fn 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 (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 (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
1577struct 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#[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 pub fn new(rows: usize) -> Self {
1655 Self { rows, icon: true }
1656 }
1657
1658 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 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#[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 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 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 pub fn root_icon(mut self, icon: IconName) -> Self {
1745 self.root_icon = Some(icon);
1746 self
1747 }
1748}
1749
1750struct 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#[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 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 pub fn open(mut self, open: bool) -> Self {
1871 self.open_by_default = open;
1872 self
1873 }
1874
1875 pub fn expanded(mut self, expanded: Binding<bool>) -> Self {
1878 self.expanded = Some(expanded);
1879 self
1880 }
1881
1882 pub fn title_view(mut self, view: impl View + 'static) -> Self {
1885 self.title_view = Some(Box::new(view));
1886 self
1887 }
1888
1889 pub fn mark(mut self, mark: impl View + 'static) -> Self {
1892 self.mark = Some(Box::new(mark));
1893 self
1894 }
1895
1896 pub fn switch(mut self, value: Binding<bool>) -> Self {
1899 self.switch = Some(value);
1900 self
1901 }
1902
1903 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 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#[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 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}