Skip to main content

ravenrs_widgets/
stack.rs

1use std::cell::Cell;
2use std::rc::Rc;
3
4use ravenrs_core::{CursorIcon, Insets, LinearGradient, Point, Quad, Rect, Role, Scene, Size};
5use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
6use ravenrs_reactive::State;
7use ravenrs_style::{Semantic, TextStyle, Weight};
8
9use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
10use crate::overlay::{Entry, MenuList};
11use crate::{
12    AnyView, Button, ButtonSize, ButtonStyle, Divider, IconButton, IconName, Text, Tooltip, View, ViewSeq,
13    util,
14};
15
16macro_rules! stack {
17    ($(#[$doc:meta])* $name:ident, $axis:expr) => {
18        $(#[$doc])*
19        pub struct $name<C> {
20            children: C,
21            style: LayoutStyle,
22        }
23
24        #[cfg_attr(feature = "live", ravenrs_live::expose)]
25        impl<C: ViewSeq> $name<C> {
26            /// Children are a tuple of views (up to 12) or a `Vec`.
27            pub fn new(children: C) -> Self {
28                Self { children, style: LayoutStyle { spacing: 16.0, ..LayoutStyle::stack($axis) } }
29            }
30
31            /// Gap between children, in logical pixels (default 16, `space-4`).
32            pub fn spacing(mut self, spacing: f32) -> Self {
33                self.style.spacing = spacing;
34                self
35            }
36
37            /// Inner padding in logical pixels.
38            pub fn padding(mut self, padding: impl Into<Insets>) -> Self {
39                self.style.padding = padding.into();
40                self
41            }
42
43            /// Cross-axis alignment of the children.
44            pub fn align(mut self, align: Align) -> Self {
45                self.style.align = align;
46                self
47            }
48
49            /// Main-axis distribution of the children.
50            pub fn justify(mut self, justify: Justify) -> Self {
51                self.style.justify = justify;
52                self
53            }
54
55            /// Fixed width in logical pixels.
56            pub fn width(mut self, width: f32) -> Self {
57                self.style.width = Some(width);
58                self
59            }
60
61            /// Fixed height in logical pixels.
62            pub fn height(mut self, height: f32) -> Self {
63                self.style.height = Some(height);
64                self
65            }
66
67            /// Maximum width in logical pixels.
68            pub fn max_width(mut self, width: f32) -> Self {
69                self.style.max_width = Some(width);
70                self
71            }
72
73            /// Takes a share of the free space in the parent stack. A growing
74            /// stack may also shrink below its content (CSS `min-height: 0`), so
75            /// scroll views inside it get a bounded height.
76            pub fn grow(mut self, grow: f32) -> Self {
77                self.style.grow = grow;
78                self.style.min_width.get_or_insert(0.0);
79                self.style.min_height.get_or_insert(0.0);
80                self
81            }
82
83            /// Moves children that do not fit onto new lines (rows wrap down,
84            /// columns wrap right).
85            pub fn wrap(mut self) -> Self {
86                self.style.wrap = true;
87                self
88            }
89        }
90
91        impl<C> std::fmt::Debug for $name<C> {
92            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93                f.debug_struct(stringify!($name)).field("style", &self.style).finish_non_exhaustive()
94            }
95        }
96
97        impl<C: ViewSeq> View for $name<C> {
98            fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
99                let children = cx.children(&self.children);
100                cx.container(self.style, &children, Paint::None)
101            }
102        }
103    };
104}
105
106stack!(
107    /// Arranges children top to bottom.
108    VStack,
109    Axis::Vertical
110);
111stack!(
112    /// Arranges children left to right.
113    HStack,
114    Axis::Horizontal
115);
116
117/// Views on top of each other, the later ones above, in a box as large as the largest.
118///
119/// Its height comes from the last layout, so the first frame settles in a second
120/// layout of the same update. A layer keeps its own height: to fill the stack, give it
121/// a height. [`align`](Self::align) places the smaller ones.
122///
123/// ```
124/// use ravenrs_widgets::{Text, ZStack, VStack};
125/// use ravenrs_layout::Align;
126///
127/// let same_place = ZStack::new((
128///     VStack::new((Text::new("Taller"), Text::new("content"))),
129///     Text::new("Over it"),
130/// ))
131/// .align(Align::Center);
132/// ```
133#[derive(Debug)]
134pub struct ZStack<C> {
135    children: C,
136    align: Align,
137}
138
139#[cfg_attr(feature = "live", ravenrs_live::expose)]
140impl<C: ViewSeq> ZStack<C> {
141    /// Children are a tuple of views (up to 12) or a `Vec`, the first one at the back.
142    pub fn new(children: C) -> Self {
143        Self { children, align: Align::Stretch }
144    }
145
146    /// Where the smaller layers go, along both axes. `Stretch`, the default, gives them the
147    /// whole width and puts them at the top.
148    pub fn align(mut self, align: Align) -> Self {
149        self.align = align;
150        self
151    }
152}
153
154/// One layer of a [`ZStack`]: a box of no height, so that the layers overlap, around
155/// a box that reports the height the content really takes.
156struct ZLayer<'a> {
157    content: NodeId,
158    height: &'a Cell<f32>,
159}
160
161impl View for ZLayer<'_> {
162    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
163        let inner = cx.container(
164            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
165            &[self.content],
166            Paint::None,
167        );
168        cx.report_frame(inner);
169        self.height.set(cx.scroll_viewport().map_or(0.0, |size| size.height));
170        cx.container(
171            LayoutStyle { height: Some(0.0), align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
172            &[inner],
173            Paint::None,
174        )
175    }
176}
177
178impl<C: ViewSeq> View for ZStack<C> {
179    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
180        let content = cx.children(&self.children);
181        let heights = vec![Cell::new(0.0_f32); content.len()];
182        let layers: Vec<NodeId> = content
183            .iter()
184            .zip(&heights)
185            .map(|(&content, height)| cx.child(&ZLayer { content, height }))
186            .collect();
187        let tallest = heights.iter().map(Cell::get).fold(0.0, f32::max);
188        let share = match self.align {
189            Align::Center => 0.5,
190            Align::End => 1.0,
191            _ => 0.0,
192        };
193        for (layer, height) in layers.iter().zip(&heights) {
194            cx.translate(*layer, Point::new(0.0, (tallest - height.get()) * share));
195        }
196        // Across, a column already takes the widest of its children; only the height needs the
197        // layers to report theirs.
198        cx.container(
199            LayoutStyle {
200                height: Some(tallest),
201                align: self.align,
202                align_self: Some(Align::Stretch),
203                ..LayoutStyle::stack(Axis::Vertical)
204            },
205            &layers,
206            Paint::None,
207        )
208    }
209}
210
211/// A label on the left and its value on the right, for lists of properties.
212///
213/// A value that does not fit is cut with an ellipsis, and an empty one shows a dash.
214///
215/// ```
216/// use ravenrs_widgets::{Badge, PropertyRow, VStack};
217///
218/// let details = VStack::new((
219///     PropertyRow::new("Camera", "NIKON D3100"),
220///     PropertyRow::new("Lens", ""),
221///     PropertyRow::with("Status", Badge::new("Edited")),
222/// ));
223/// ```
224#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
225pub struct PropertyRow {
226    label: String,
227    value: PropertyValue,
228}
229
230enum PropertyValue {
231    Text(String),
232    View(AnyView),
233}
234
235impl std::fmt::Debug for PropertyRow {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.debug_struct("PropertyRow").field("label", &self.label).finish_non_exhaustive()
238    }
239}
240
241#[cfg_attr(feature = "live", ravenrs_live::expose)]
242impl PropertyRow {
243    /// A row showing `value` as text.
244    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
245        Self { label: label.into(), value: PropertyValue::Text(value.into()) }
246    }
247
248    /// A row with a view as its value, such as a badge or a switch.
249    pub fn with(label: impl Into<String>, value: impl View + 'static) -> Self {
250        Self { label: label.into(), value: PropertyValue::View(Box::new(value)) }
251    }
252}
253
254impl View for PropertyRow {
255    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
256        let theme = *cx.theme();
257        let attrs = util::attrs(&theme, TextStyle::Callout, None);
258        // The label keeps its size: it is the value that gives way.
259        let min_width = Some(cx.measure_text(&self.label, &attrs).width.ceil());
260        let label = cx.text(
261            &self.label,
262            &attrs,
263            theme.color(Semantic::InkMuted),
264            LayoutStyle { min_width, ..LayoutStyle::leaf() },
265        );
266        let (value, spoken) = match &self.value {
267            PropertyValue::Text(text) => {
268                let shown = if text.trim().is_empty() { "—" } else { text };
269                let node = cx.text_truncated(shown, &attrs, theme.color(Semantic::Ink), LayoutStyle::leaf());
270                cx.semantics(label, Semantics::hidden());
271                cx.semantics(node, Semantics::hidden());
272                (node, Some(shown.to_owned()))
273            }
274            PropertyValue::View(view) => (cx.child(view.as_ref()), None),
275        };
276        let node = cx.container(
277            LayoutStyle {
278                spacing: theme.spacing.s2,
279                justify: Justify::SpaceBetween,
280                align: Align::Center,
281                align_self: Some(Align::Stretch),
282                ..LayoutStyle::stack(Axis::Horizontal)
283            },
284            &[label, value],
285            Paint::None,
286        );
287        if let Some(spoken) = spoken {
288            cx.semantics(node, Semantics::new(Role::Label).name(self.label.clone()).value(spoken));
289        }
290        node
291    }
292}
293
294/// The top of a page: its title, a line about it, badges beside the title and actions at the end.
295///
296/// ```
297/// use ravenrs_widgets::{Badge, Button, PageHeader};
298///
299/// let header = PageHeader::new("Sessions")
300///     .description("Every shoot, newest first.")
301///     .badge(Badge::new("12"))
302///     .action(Button::new("New session"));
303/// ```
304#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
305pub struct PageHeader {
306    title: String,
307    description: Option<String>,
308    badges: Vec<AnyView>,
309    actions: Vec<AnyView>,
310    compact: bool,
311}
312
313impl std::fmt::Debug for PageHeader {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("PageHeader").field("title", &self.title).finish_non_exhaustive()
316    }
317}
318
319#[cfg_attr(feature = "live", ravenrs_live::expose)]
320impl PageHeader {
321    /// A header with this title.
322    pub fn new(title: impl Into<String>) -> Self {
323        Self {
324            title: title.into(),
325            description: None,
326            badges: Vec::new(),
327            actions: Vec::new(),
328            compact: false,
329        }
330    }
331
332    /// The line about the page. A long one is cut with an ellipsis.
333    pub fn description(mut self, text: impl Into<String>) -> Self {
334        self.description = Some(text.into());
335        self
336    }
337
338    /// Adds a view beside the title, usually a badge.
339    pub fn badge(mut self, badge: impl View + 'static) -> Self {
340        self.badges.push(Box::new(badge));
341        self
342    }
343
344    /// Adds a view at the end of the header, usually a button.
345    pub fn action(mut self, action: impl View + 'static) -> Self {
346        self.actions.push(Box::new(action));
347        self
348    }
349
350    /// A single line, with a smaller title and the description beside it, for tool screens.
351    pub fn compact(mut self) -> Self {
352        self.compact = true;
353        self
354    }
355}
356
357impl View for PageHeader {
358    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
359        let theme = *cx.theme();
360        let ink = theme.color(Semantic::Ink);
361        let (style, weight, level) = if self.compact {
362            (TextStyle::Headline, Some(Weight::SEMIBOLD), 2)
363        } else {
364            (TextStyle::Title, None, 1)
365        };
366        let title = util::label(cx, &self.title, style, weight, ink);
367        cx.semantics(title, Semantics::new(Role::Heading).level(level));
368        let description = self.description.as_ref().map(|text| {
369            let attrs = util::attrs(&theme, TextStyle::Callout, None);
370            cx.text_truncated(text, &attrs, theme.color(Semantic::InkMuted), LayoutStyle::leaf())
371        });
372        let mut top = vec![title];
373        if self.compact {
374            top.extend(description);
375        }
376        top.extend(self.badges.iter().map(|badge| cx.child(badge.as_ref())));
377        let top = cx.container(
378            LayoutStyle {
379                spacing: theme.spacing.s2,
380                align: Align::Center,
381                min_width: Some(0.0),
382                ..LayoutStyle::stack(Axis::Horizontal)
383            },
384            &top,
385            Paint::None,
386        );
387        let mut text = vec![top];
388        if !self.compact {
389            text.extend(description);
390        }
391        let text = cx.container(
392            LayoutStyle {
393                spacing: theme.spacing.s1,
394                grow: 1.0,
395                min_width: Some(0.0),
396                ..LayoutStyle::stack(Axis::Vertical)
397            },
398            &text,
399            Paint::None,
400        );
401        let mut children = vec![text];
402        if !self.actions.is_empty() {
403            let actions: Vec<NodeId> = self.actions.iter().map(|action| cx.child(action.as_ref())).collect();
404            children.push(cx.container(
405                LayoutStyle {
406                    spacing: theme.spacing.s2,
407                    align: Align::Center,
408                    ..LayoutStyle::stack(Axis::Horizontal)
409                },
410                &actions,
411                Paint::None,
412            ));
413        }
414        cx.container(
415            LayoutStyle {
416                spacing: theme.spacing.s4,
417                align: Align::Center,
418                align_self: Some(Align::Stretch),
419                ..LayoutStyle::stack(Axis::Horizontal)
420            },
421            &children,
422            Paint::None,
423        )
424    }
425}
426
427/// Flexible empty space that pushes its siblings apart inside a stack.
428#[derive(Clone, Copy, Debug, Default)]
429#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
430pub struct Spacer {
431    min: f32,
432}
433
434#[cfg_attr(feature = "live", ravenrs_live::expose)]
435impl Spacer {
436    /// A spacer with no minimum size.
437    pub fn new() -> Self {
438        Self::default()
439    }
440
441    /// The smallest size the spacer keeps along the stack's axis.
442    pub fn min(mut self, min: f32) -> Self {
443        self.min = min;
444        self
445    }
446}
447
448impl View for Spacer {
449    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
450        let style = LayoutStyle {
451            grow: 1.0,
452            min_width: Some(self.min),
453            min_height: Some(self.min),
454            ..LayoutStyle::leaf()
455        };
456        cx.container(style, &[], Paint::None)
457    }
458}
459
460/// A vertically scrolling stack. It fills the space its parent gives it (or
461/// `height`) and scrolls its children with the wheel or trackpad, showing a
462/// thin scrollbar while they move.
463pub struct ScrollView<C> {
464    children: C,
465    style: LayoutStyle,
466    horizontal: bool,
467    scrollbar_outside: f32,
468}
469
470impl<C> std::fmt::Debug for ScrollView<C> {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.debug_struct("ScrollView").field("style", &self.style).finish_non_exhaustive()
473    }
474}
475
476#[cfg_attr(feature = "live", ravenrs_live::expose)]
477impl<C: ViewSeq> ScrollView<C> {
478    /// A scroll view of `children` (a tuple of up to 12 views or a `Vec`).
479    pub fn new(children: C) -> Self {
480        Self {
481            children,
482            style: LayoutStyle {
483                spacing: 16.0,
484                grow: 1.0,
485                align: Align::Stretch,
486                overflow_hidden: true,
487                min_height: Some(0.0),
488                ..LayoutStyle::stack(Axis::Vertical)
489            },
490            horizontal: false,
491            scrollbar_outside: 0.0,
492        }
493    }
494
495    /// Puts the scrollbar this far past the view's right edge, for a view inside a
496    /// padded surface such as a dialog, so that it never passes over what it scrolls.
497    #[must_use]
498    pub fn scrollbar_outside(mut self, by: f32) -> Self {
499        self.scrollbar_outside = by;
500        self
501    }
502
503    /// Also scrolls sideways, for content wider than the view (a zoomed page,
504    /// a wide canvas). Children keep their own width instead of stretching.
505    pub fn horizontal(mut self) -> Self {
506        self.horizontal = true;
507        self.style.align = Align::Start;
508        self.style.min_width = Some(0.0);
509        self
510    }
511
512    /// Gap between children, in logical pixels (default 16).
513    pub fn spacing(mut self, spacing: f32) -> Self {
514        self.style.spacing = spacing;
515        self
516    }
517
518    /// Inner padding in logical pixels.
519    pub fn padding(mut self, padding: impl Into<Insets>) -> Self {
520        self.style.padding = padding.into();
521        self
522    }
523
524    /// A fixed height instead of filling the parent.
525    pub fn height(mut self, height: f32) -> Self {
526        self.style.height = Some(height);
527        self.style.grow = 0.0;
528        self
529    }
530
531    /// Cross-axis alignment of the children (`Stretch` by default).
532    pub fn align(mut self, align: Align) -> Self {
533        self.style.align = align;
534        self
535    }
536}
537
538impl<C: ViewSeq> View for ScrollView<C> {
539    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
540        let children = cx.children(&self.children);
541        let node = cx.container(self.style, &children, Paint::None);
542        cx.scrollable(node);
543        if self.scrollbar_outside != 0.0 {
544            cx.scrollbar_outside(node, self.scrollbar_outside);
545        }
546        if self.horizontal {
547            cx.scroll_horizontally(node);
548        }
549        node
550    }
551}
552
553/// How long the fade of a [`ScrollEdgeArrow`] is, in logical pixels.
554const EDGE_ARROW_LENGTH: f32 = 40.0;
555/// The least an arrow of a [`ScrollEdgeArrow`] scrolls, in logical pixels.
556const EDGE_ARROW_LEAST_STEP: f32 = 200.0;
557
558/// A strip that scrolls, with an arrow over a fade at each end that has more to show. The
559/// arrow scrolls most of the strip at a time, and leaves when that end is reached.
560///
561/// The strip is horizontal unless [`ScrollEdgeArrow::vertical`]. Children keep their size
562/// when they have one, so give them a width: without it they share the room first, as in any
563/// row, down to what their content needs. Every scroll builds the strip again to know
564/// whether an arrow still applies, so what goes in should be cheap to build.
565///
566/// ```
567/// use ravenrs_widgets::{Button, ScrollEdgeArrow, Ui};
568///
569/// let photos: Vec<Button> = (1..=30).map(|n| Button::new(format!("Photo {n}"))).collect();
570/// let strip = ScrollEdgeArrow::new(photos).spacing(8.0);
571/// let mut ui = Ui::new(strip, ravenrs_style::Theme::raven_dark());
572/// ui.update();
573/// ```
574pub struct ScrollEdgeArrow<C> {
575    children: C,
576    vertical: bool,
577    spacing: f32,
578    padding: Insets,
579    height: Option<f32>,
580    fade: Semantic,
581}
582
583impl<C> std::fmt::Debug for ScrollEdgeArrow<C> {
584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        f.debug_struct("ScrollEdgeArrow").field("vertical", &self.vertical).finish_non_exhaustive()
586    }
587}
588
589#[cfg_attr(feature = "live", ravenrs_live::expose)]
590impl<C: ViewSeq> ScrollEdgeArrow<C> {
591    /// A horizontal strip of `children` (a tuple of up to 12 views or a `Vec`).
592    pub fn new(children: C) -> Self {
593        Self {
594            children,
595            vertical: false,
596            spacing: 8.0,
597            padding: Insets::default(),
598            height: None,
599            fade: Semantic::Canvas,
600        }
601    }
602
603    /// Scrolls up and down, taking the height its parent gives it.
604    pub fn vertical(mut self) -> Self {
605        self.vertical = true;
606        self
607    }
608
609    /// Gap between children, in logical pixels (default 8).
610    pub fn spacing(mut self, spacing: f32) -> Self {
611        self.spacing = spacing;
612        self
613    }
614
615    /// Inner padding in logical pixels.
616    pub fn padding(mut self, padding: impl Into<Insets>) -> Self {
617        self.padding = padding.into();
618        self
619    }
620
621    /// A fixed height. A horizontal strip takes the height of its children by default.
622    pub fn height(mut self, height: f32) -> Self {
623        self.height = Some(height);
624        self
625    }
626
627    /// The color the arrows fade from, which should be what is behind the strip
628    /// ([`Semantic::Canvas`] by default).
629    pub fn fade(mut self, fade: Semantic) -> Self {
630        self.fade = fade;
631        self
632    }
633}
634
635/// One arrow of a [`ScrollEdgeArrow`], which any view that scrolls by itself can also use.
636pub(crate) struct EdgeArrow {
637    /// At the end of the strip rather than at its start.
638    pub forward: bool,
639    pub vertical: bool,
640    /// Its frame inside its parent.
641    pub frame: Rect,
642    pub fade: Semantic,
643    pub label: String,
644    pub on_click: Rc<dyn Fn()>,
645}
646
647impl View for EdgeArrow {
648    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
649        let theme = *cx.theme();
650        let ink = theme.color(Semantic::Ink);
651        let color = if cx.is_hovered() { ink } else { ink.with_alpha(ink.a * 0.7) };
652        let icon_name = match (self.vertical, self.forward) {
653            (false, false) => IconName::ChevronLeft,
654            (false, true) => IconName::ChevronRight,
655            (true, false) => IconName::ChevronUp,
656            (true, true) => IconName::ChevronDown,
657        };
658        let icon = util::icon(cx, icon_name, 20.0, color);
659        let solid = if self.forward { 1.0 } else { 0.0 };
660        let (start, end) = if self.vertical {
661            (Point::new(0.5, solid), Point::new(0.5, 1.0 - solid))
662        } else {
663            (Point::new(solid, 0.5), Point::new(1.0 - solid, 0.5))
664        };
665        let from = theme.color(self.fade);
666        let node = cx.container(
667            LayoutStyle {
668                absolute: Some((self.frame.x(), self.frame.y())),
669                width: Some(self.frame.width()),
670                height: Some(self.frame.height()),
671                align: Align::Center,
672                justify: Justify::Center,
673                ..LayoutStyle::stack(Axis::Horizontal)
674            },
675            &[icon],
676            Paint::Quad(QuadPaint::new(LinearGradient { start, end, from, to: from.with_alpha(0.0) })),
677        );
678        cx.on_click(node, self.on_click.clone());
679        cx.semantics(node, Semantics::new(Role::Button).name(self.label.clone()));
680        cx.focusable(node, theme.radii.sm);
681        cx.cursor(node, CursorIcon::Pointer);
682        node
683    }
684}
685
686impl<C: ViewSeq> View for ScrollEdgeArrow<C> {
687    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
688        let labels = cx.labels();
689        let vertical = self.vertical;
690        let along = move |size: Size| if vertical { size.height } else { size.width };
691        let view = cx.scroll_viewport().unwrap_or(Size::ZERO);
692        let content = cx.measured_content().unwrap_or(Size::ZERO);
693        let last = (along(content) - along(view)).max(0.0);
694        let offset = cx.scroll_offset();
695
696        // An arrow can only ask: a scroll happens during a build.
697        let wanted: State<Option<f32>> = cx.use_state(|| None);
698        let asked = wanted.get();
699        let here = asked.map_or(if vertical { offset.y } else { offset.x }, |to| to.clamp(0.0, last));
700        if asked.is_some() {
701            wanted.set(None);
702            cx.set_scroll_offset(if vertical {
703                Point::new(offset.x, here)
704            } else {
705                Point::new(here, offset.y)
706            });
707        }
708
709        let children = cx.children(&self.children);
710        let strip = cx.container(
711            LayoutStyle {
712                spacing: self.spacing,
713                padding: self.padding,
714                align: if vertical { Align::Stretch } else { Align::Center },
715                overflow_hidden: true,
716                grow: 1.0,
717                min_width: Some(0.0),
718                min_height: Some(0.0),
719                ..LayoutStyle::stack(if vertical { Axis::Vertical } else { Axis::Horizontal })
720            },
721            &children,
722            Paint::None,
723        );
724        // Rebuilt on every scroll, and told the size of what scrolls: that is what the arrows go by.
725        cx.scrollable_virtual(strip, !vertical);
726        cx.measure_content(strip);
727
728        let step = (along(view) * 0.8).max(EDGE_ARROW_LEAST_STEP);
729        let arrow = |forward: bool| {
730            let at_end = along(view) - EDGE_ARROW_LENGTH;
731            let frame = match (vertical, forward) {
732                (false, false) => Rect::new(0.0, 0.0, EDGE_ARROW_LENGTH, view.height),
733                (false, true) => Rect::new(at_end, 0.0, EDGE_ARROW_LENGTH, view.height),
734                (true, false) => Rect::new(0.0, 0.0, view.width, EDGE_ARROW_LENGTH),
735                (true, true) => Rect::new(0.0, at_end, view.width, EDGE_ARROW_LENGTH),
736            };
737            let label = if forward { &labels.scroll_forward } else { &labels.scroll_back };
738            let wanted = wanted.clone();
739            let target = if forward { here + step } else { here - step };
740            EdgeArrow {
741                forward,
742                vertical,
743                frame,
744                fade: self.fade,
745                label: label.clone(),
746                on_click: Rc::new(move || wanted.set(Some(target))),
747            }
748        };
749        let show_back = here > 0.5;
750        let show_forward = here < last - 0.5;
751        let mut nodes = vec![strip];
752        let back = show_back.then(|| cx.keyed_child(1, &arrow(false)));
753        let forward = show_forward.then(|| cx.keyed_child(2, &arrow(true)));
754        nodes.extend(back);
755        nodes.extend(forward);
756        // Going off the end takes away the arrow that had the keyboard: it moves to the other one.
757        if asked.is_some() {
758            match (back, forward) {
759                (Some(other), None) | (None, Some(other)) => cx.request_focus_node(other),
760                _ => {}
761            }
762        }
763        cx.container(
764            LayoutStyle {
765                height: self.height,
766                grow: if vertical && self.height.is_none() { 1.0 } else { 0.0 },
767                min_width: Some(0.0),
768                min_height: Some(0.0),
769                align: Align::Stretch,
770                align_self: Some(Align::Stretch),
771                ..LayoutStyle::stack(Axis::Vertical)
772            },
773            &nodes,
774            Paint::None,
775        )
776    }
777}
778
779/// Lays children out in equal-width columns, wrapping into rows.
780pub struct Grid<C> {
781    children: C,
782    style: LayoutStyle,
783}
784
785impl<C> std::fmt::Debug for Grid<C> {
786    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787        f.debug_struct("Grid").field("style", &self.style).finish_non_exhaustive()
788    }
789}
790
791#[cfg_attr(feature = "live", ravenrs_live::expose)]
792impl<C: ViewSeq> Grid<C> {
793    /// A grid of `columns` equal columns (at least 1).
794    pub fn new(columns: u16, children: C) -> Self {
795        Self {
796            children,
797            style: LayoutStyle {
798                spacing: 16.0,
799                grid_columns: Some(columns.max(1)),
800                align_self: Some(Align::Stretch),
801                ..LayoutStyle::stack(Axis::Vertical)
802            },
803        }
804    }
805
806    /// Gap between cells, in logical pixels (default 16).
807    pub fn spacing(mut self, spacing: f32) -> Self {
808        self.style.spacing = spacing;
809        self
810    }
811}
812
813impl<C: ViewSeq> View for Grid<C> {
814    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
815        let children = cx.children(&self.children);
816        cx.container(self.style, &children, Paint::None)
817    }
818}
819
820/// Window width below which a [`Footer`] stacks its areas.
821const FOOTER_BREAKPOINT: f32 = 640.0;
822
823/// The band that ends a page or an app: copyright, links, version, status.
824///
825/// It has a left, a center and a right area, each a tuple of views (up to 12)
826/// or a `Vec`, whose items move to a new line when they do not fit. The center
827/// stays centered however wide the sides are. Below 640 logical pixels of
828/// window width (see [`Self::breakpoint`]) the areas stack, centered. Screen
829/// readers find it as the page's `contentinfo` landmark.
830///
831/// ```
832/// use ravenrs_widgets::{Badge, Footer, Tone};
833///
834/// let footer = Footer::new()
835///     .left((Footer::text("© 2026 Raven Finance"),))
836///     .center((Footer::link("Privacy"), Footer::link("Terms")))
837///     .right((Footer::text("v27.1"), Badge::new("Online").tone(Tone::Success)));
838/// ```
839#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
840pub struct Footer {
841    left: Option<Box<dyn ViewSeq>>,
842    center: Option<Box<dyn ViewSeq>>,
843    right: Option<Box<dyn ViewSeq>>,
844    divider: bool,
845    breakpoint: f32,
846    stacked: bool,
847}
848
849impl std::fmt::Debug for Footer {
850    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
851        f.debug_struct("Footer")
852            .field("divider", &self.divider)
853            .field("breakpoint", &self.breakpoint)
854            .field("stacked", &self.stacked)
855            .finish_non_exhaustive()
856    }
857}
858
859impl Default for Footer {
860    fn default() -> Self {
861        Self {
862            left: None,
863            center: None,
864            right: None,
865            divider: true,
866            breakpoint: FOOTER_BREAKPOINT,
867            stacked: false,
868        }
869    }
870}
871
872#[cfg_attr(feature = "live", ravenrs_live::expose)]
873impl Footer {
874    /// An empty footer with a divider on top.
875    pub fn new() -> Self {
876        Self::default()
877    }
878
879    /// Text in a footer's usual look: small and muted.
880    pub fn text(text: impl Into<String>) -> Text {
881        Text::new(text).style(TextStyle::Caption).foreground(Semantic::InkMuted)
882    }
883
884    /// A link in a footer's usual look: a small ghost [`Button`], so it has a
885    /// focus ring and works with the keyboard. Add `on_click` to it.
886    pub fn link(label: impl Into<String>) -> Button {
887        Button::new(label).style(ButtonStyle::Ghost).size(ButtonSize::Small)
888    }
889
890    /// The views at the left end.
891    pub fn left(mut self, items: impl ViewSeq + 'static) -> Self {
892        self.left = Some(Box::new(items));
893        self
894    }
895
896    /// The views in the middle.
897    pub fn center(mut self, items: impl ViewSeq + 'static) -> Self {
898        self.center = Some(Box::new(items));
899        self
900    }
901
902    /// The views at the right end.
903    pub fn right(mut self, items: impl ViewSeq + 'static) -> Self {
904        self.right = Some(Box::new(items));
905        self
906    }
907
908    /// Shows the hairline along the top (on by default).
909    pub fn divider(mut self, divider: bool) -> Self {
910        self.divider = divider;
911        self
912    }
913
914    /// The window width, in logical pixels, below which the areas stack
915    /// (default 640).
916    pub fn breakpoint(mut self, width: f32) -> Self {
917        self.breakpoint = width;
918        self
919    }
920
921    /// Always stacks the areas, for a footer in a narrow panel of a wide window:
922    /// the breakpoint only knows the window.
923    pub fn stacked(mut self) -> Self {
924        self.stacked = true;
925        self
926    }
927
928    fn body(&self, cx: &mut BuildCx<'_>) -> NodeId {
929        let spacing = cx.theme().spacing;
930        let area = |items, justify| FooterArea { items, justify };
931        let (left, center, right) = (self.left.as_deref(), self.center.as_deref(), self.right.as_deref());
932        let center_only = left.is_none() && right.is_none();
933        if self.stacked || center_only || cx.viewport().width < self.breakpoint {
934            let areas: Vec<_> =
935                [left, center, right].into_iter().flatten().map(|a| area(Some(a), Justify::Center)).collect();
936            return cx.child(&VStack::new(areas).spacing(spacing.s2).align(Align::Stretch));
937        }
938        let (start, end) = (area(left, Justify::Start), area(right, Justify::End));
939        if center.is_some() {
940            // Equal columns are what keep the center centered between uneven sides.
941            cx.child(&Grid::new(3, (start, area(center, Justify::Center), end)).spacing(spacing.s4))
942        } else {
943            let sides = HStack::new((start, end))
944                .spacing(spacing.s4)
945                .align(Align::Center)
946                .justify(Justify::SpaceBetween);
947            cx.child(&sides.wrap())
948        }
949    }
950}
951
952impl View for Footer {
953    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
954        let spacing = cx.theme().spacing;
955        let body = self.body(cx);
956        let content = cx.container(
957            LayoutStyle {
958                padding: Insets::symmetric(spacing.s3, spacing.s4),
959                align: Align::Stretch,
960                ..LayoutStyle::stack(Axis::Vertical)
961            },
962            &[body],
963            Paint::None,
964        );
965        let divider = self.divider.then(|| cx.child(&Divider::horizontal()));
966        let children: Vec<NodeId> = divider.into_iter().chain([content]).collect();
967        let node = cx.container(
968            LayoutStyle {
969                align: Align::Stretch,
970                align_self: Some(Align::Stretch),
971                ..LayoutStyle::stack(Axis::Vertical)
972            },
973            &children,
974            Paint::None,
975        );
976        cx.semantics(node, Semantics::new(Role::ContentInfo));
977        node
978    }
979}
980
981/// One area of a [`Footer`]. It exists even when empty, to hold its grid column.
982struct FooterArea<'a> {
983    items: Option<&'a dyn ViewSeq>,
984    justify: Justify,
985}
986
987impl View for FooterArea<'_> {
988    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
989        let spacing = cx.theme().spacing.s3;
990        let children = self.items.map_or_else(Vec::new, |items| cx.children(items));
991        cx.container(
992            LayoutStyle {
993                spacing,
994                align: Align::Center,
995                justify: self.justify,
996                wrap: true,
997                // Lets a long area wrap inside its grid column instead of widening it.
998                min_width: Some(0.0),
999                ..LayoutStyle::stack(Axis::Horizontal)
1000            },
1001            &children,
1002            Paint::None,
1003        )
1004    }
1005}
1006
1007/// The height of a [`TitleBar`], in logical pixels.
1008const TITLE_BAR_HEIGHT: f32 = 38.0;
1009
1010/// A title bar drawn by the app: what comes first, the title, the actions and the buttons of
1011/// the window. A button shows only when it has a handler. A double click on the bar does the
1012/// same as the maximize button.
1013///
1014/// It draws and calls back, and that is all. What the handlers do to the window is up to the
1015/// app. **It cannot drag the window nor hide the frame the system draws**, because those belong
1016/// to the platform layer, which offers neither yet: for now the system's own bar stays above it.
1017///
1018/// ```
1019/// use ravenrs_widgets::{IconButton, IconName, TitleBar, Ui};
1020///
1021/// let bar = TitleBar::new("Report.pdf")
1022///     .action(IconButton::new(IconName::Search).label("Search"))
1023///     .on_minimize(|| {})
1024///     .on_maximize(|| {})
1025///     .on_close(|| {});
1026/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
1027/// ui.update();
1028/// ```
1029#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1030pub struct TitleBar {
1031    title: String,
1032    leading: Option<AnyView>,
1033    actions: Vec<AnyView>,
1034    on_minimize: Option<Rc<dyn Fn()>>,
1035    on_maximize: Option<Rc<dyn Fn()>>,
1036    on_close: Option<Rc<dyn Fn()>>,
1037    on_context_menu: Option<Rc<dyn Fn(Point)>>,
1038    maximized: bool,
1039    active: bool,
1040}
1041
1042impl std::fmt::Debug for TitleBar {
1043    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044        f.debug_struct("TitleBar").field("title", &self.title).finish_non_exhaustive()
1045    }
1046}
1047
1048#[cfg_attr(feature = "live", ravenrs_live::expose)]
1049impl TitleBar {
1050    /// A bar with this title and no buttons.
1051    pub fn new(title: impl Into<String>) -> Self {
1052        Self {
1053            title: title.into(),
1054            leading: None,
1055            actions: Vec::new(),
1056            on_minimize: None,
1057            on_maximize: None,
1058            on_close: None,
1059            on_context_menu: None,
1060            maximized: false,
1061            active: true,
1062        }
1063    }
1064
1065    /// Any view before the title, such as an icon or a menu.
1066    pub fn leading(mut self, view: impl View + 'static) -> Self {
1067        self.leading = Some(Box::new(view));
1068        self
1069    }
1070
1071    /// Appends a view to the area between the title and the window's buttons.
1072    pub fn action(mut self, view: impl View + 'static) -> Self {
1073        self.actions.push(Box::new(view));
1074        self
1075    }
1076
1077    /// Shows the minimize button, which runs `handler`.
1078    pub fn on_minimize(mut self, handler: impl Fn() + 'static) -> Self {
1079        self.on_minimize = Some(Rc::new(handler));
1080        self
1081    }
1082
1083    /// Shows the maximize button, which runs `handler`, as a double click on the bar does.
1084    pub fn on_maximize(mut self, handler: impl Fn() + 'static) -> Self {
1085        self.on_maximize = Some(Rc::new(handler));
1086        self
1087    }
1088
1089    /// Shows the close button, which runs `handler`.
1090    pub fn on_close(mut self, handler: impl Fn() + 'static) -> Self {
1091        self.on_close = Some(Rc::new(handler));
1092        self
1093    }
1094
1095    /// Called with the pointer's place on a right click on the bar, for a menu of the window.
1096    pub fn on_context_menu(mut self, handler: impl Fn(Point) + 'static) -> Self {
1097        self.on_context_menu = Some(Rc::new(handler));
1098        self
1099    }
1100
1101    /// The window is maximized: the maximize button shows and says that it restores.
1102    pub fn maximized(mut self, maximized: bool) -> Self {
1103        self.maximized = maximized;
1104        self
1105    }
1106
1107    /// Whether the window has the keyboard (it does by default). Without it the bar goes quiet,
1108    /// which is how to tell which of two windows side by side is listening.
1109    pub fn active(mut self, active: bool) -> Self {
1110        self.active = active;
1111        self
1112    }
1113}
1114
1115impl View for TitleBar {
1116    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1117        let theme = *cx.theme();
1118        let labels = cx.labels();
1119        let mut children = Vec::new();
1120        if let Some(leading) = &self.leading {
1121            children.push(cx.keyed_child(1, leading));
1122        }
1123        let ink = theme.color(if self.active { Semantic::Ink } else { Semantic::InkMuted });
1124        children.push(cx.text_truncated(
1125            &self.title,
1126            &util::attrs(&theme, TextStyle::Callout, Some(Weight::MEDIUM)),
1127            ink,
1128            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
1129        ));
1130        for (i, action) in self.actions.iter().enumerate() {
1131            children.push(cx.keyed_child(10 + i as u64, action));
1132        }
1133        let (middle_icon, middle_label) = if self.maximized {
1134            (IconName::Copy, &labels.window_restore)
1135        } else {
1136            (IconName::Square, &labels.window_maximize)
1137        };
1138        let buttons = [
1139            (&self.on_minimize, IconName::Minus, &labels.window_minimize),
1140            (&self.on_maximize, middle_icon, middle_label),
1141            (&self.on_close, IconName::X, &labels.close),
1142        ];
1143        for (i, (handler, icon, label)) in buttons.into_iter().enumerate() {
1144            if let Some(handler) = handler.clone() {
1145                let button = Tooltip::new(
1146                    IconButton::new(icon)
1147                        .label(label.clone())
1148                        .size(ButtonSize::Mini)
1149                        .on_click(move || handler()),
1150                    label.clone(),
1151                );
1152                children.push(cx.keyed_child(2 + i as u64, &button));
1153            }
1154        }
1155        let fill = theme.color(if self.active { Semantic::Surface } else { Semantic::Canvas });
1156        let line = theme.color(Semantic::Border);
1157        let node = cx.container(
1158            LayoutStyle {
1159                height: Some(TITLE_BAR_HEIGHT),
1160                padding: Insets::symmetric(0.0, theme.spacing.s3),
1161                spacing: theme.spacing.s2,
1162                align: Align::Center,
1163                align_self: Some(Align::Stretch),
1164                ..LayoutStyle::stack(Axis::Horizontal)
1165            },
1166            &children,
1167            Paint::Custom(Rc::new(move |f: Rect, scene: &mut Scene| {
1168                scene.push_quad(Quad::new(f, fill));
1169                scene.push_quad(Quad::new(Rect::new(f.x(), f.max_y() - 1.0, f.width(), 1.0), line));
1170            })),
1171        );
1172        if let Some(maximize) = &self.on_maximize {
1173            cx.on_double_click(node, maximize.clone());
1174        }
1175        if let Some(menu) = &self.on_context_menu {
1176            cx.on_context_menu(node, menu.clone());
1177        }
1178        cx.semantics(node, Semantics::new(Role::Group).name(self.title.clone()));
1179        node
1180    }
1181}
1182
1183/// The one line at the foot of an app window that is always there: the version,
1184/// what is going on in the background, a notice with its actions.
1185///
1186/// Where a [`Footer`] ends a page and wraps or stacks to show everything, a
1187/// status bar keeps one fixed line: its sides keep their width and the center
1188/// takes the rest, cutting what does not fit, so it never pushes the app around.
1189/// Tinting the center with [`Self::center_tone`] makes a notice stand out without
1190/// the whole bar shouting. The center is announced to screen readers as a status.
1191///
1192/// ```
1193/// use ravenrs_widgets::{StatusBar, Tone, ViewExt};
1194///
1195/// let bar = StatusBar::new()
1196///     .left((StatusBar::action("Version 1.2"),))
1197///     .center((StatusBar::text("Version 1.3 is available").truncate().grow(1.0), StatusBar::action("Update")))
1198///     .center_tone(Tone::Warning)
1199///     .right((StatusBar::text("12 items"),));
1200/// ```
1201#[derive(Default)]
1202#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1203pub struct StatusBar {
1204    left: Option<Box<dyn ViewSeq>>,
1205    center: Option<Box<dyn ViewSeq>>,
1206    right: Option<Box<dyn ViewSeq>>,
1207    center_tone: Option<crate::Tone>,
1208}
1209
1210impl std::fmt::Debug for StatusBar {
1211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1212        f.debug_struct("StatusBar").field("center_tone", &self.center_tone).finish_non_exhaustive()
1213    }
1214}
1215
1216#[cfg_attr(feature = "live", ravenrs_live::expose)]
1217impl StatusBar {
1218    /// Its height, in logical pixels, for what has to clear it.
1219    pub const HEIGHT: f32 = 28.0;
1220
1221    /// An empty status bar.
1222    pub fn new() -> Self {
1223        Self::default()
1224    }
1225
1226    /// Text in a status bar's usual look: small and muted.
1227    pub fn text(text: impl Into<String>) -> Text {
1228        Text::new(text).style(TextStyle::Caption).foreground(Semantic::InkMuted)
1229    }
1230
1231    /// An action that fits the line: a mini ghost [`Button`], with a focus ring
1232    /// and the keyboard. Add `on_click` to it.
1233    pub fn action(label: impl Into<String>) -> Button {
1234        Button::new(label).style(ButtonStyle::Ghost).size(ButtonSize::Mini)
1235    }
1236
1237    /// The views at the left end.
1238    pub fn left(mut self, items: impl ViewSeq + 'static) -> Self {
1239        self.left = Some(Box::new(items));
1240        self
1241    }
1242
1243    /// The views in the middle, which get the width the sides leave. Give the
1244    /// text `.truncate().grow(1.0)` so that it is what gets cut.
1245    pub fn center(mut self, items: impl ViewSeq + 'static) -> Self {
1246        self.center = Some(Box::new(items));
1247        self
1248    }
1249
1250    /// The views at the right end.
1251    pub fn right(mut self, items: impl ViewSeq + 'static) -> Self {
1252        self.right = Some(Box::new(items));
1253        self
1254    }
1255
1256    /// Tints the middle, and only the middle, to make a notice stand out.
1257    pub fn center_tone(mut self, tone: crate::Tone) -> Self {
1258        self.center_tone = Some(tone);
1259        self
1260    }
1261
1262    fn side(cx: &mut BuildCx<'_>, items: Option<&dyn ViewSeq>) -> NodeId {
1263        let spacing = cx.theme().spacing.s1;
1264        let children = items.map_or_else(Vec::new, |items| cx.children(items));
1265        cx.container(
1266            LayoutStyle { spacing, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1267            &children,
1268            Paint::None,
1269        )
1270    }
1271}
1272
1273impl View for StatusBar {
1274    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1275        let theme = *cx.theme();
1276        let left = Self::side(cx, self.left.as_deref());
1277        let right = Self::side(cx, self.right.as_deref());
1278        let items = self.center.as_deref().map_or_else(Vec::new, |items| cx.children(items));
1279        let has_center = !items.is_empty();
1280        let fill = self.center_tone.filter(|_| has_center).map(|tone| tone.color(&theme).faded(0.16));
1281        let center = cx.container(
1282            LayoutStyle {
1283                spacing: theme.spacing.s1,
1284                padding: Insets::symmetric(0.0, theme.spacing.s2),
1285                align: Align::Center,
1286                align_self: Some(Align::Stretch),
1287                grow: 1.0,
1288                min_width: Some(0.0),
1289                overflow_hidden: true,
1290                ..LayoutStyle::stack(Axis::Horizontal)
1291            },
1292            &items,
1293            fill.map_or(Paint::None, |fill| Paint::Quad(QuadPaint::new(fill))),
1294        );
1295        if has_center {
1296            cx.semantics(center, Semantics::new(Role::Status));
1297        }
1298        let line = cx.container(
1299            LayoutStyle {
1300                spacing: theme.spacing.s2,
1301                padding: Insets::symmetric(0.0, theme.spacing.s2),
1302                align: Align::Center,
1303                height: Some(Self::HEIGHT),
1304                align_self: Some(Align::Stretch),
1305                ..LayoutStyle::stack(Axis::Horizontal)
1306            },
1307            &[left, center, right],
1308            Paint::None,
1309        );
1310        let divider = cx.child(&Divider::horizontal());
1311        let node = cx.container(
1312            LayoutStyle {
1313                align: Align::Stretch,
1314                align_self: Some(Align::Stretch),
1315                ..LayoutStyle::stack(Axis::Vertical)
1316            },
1317            &[divider, line],
1318            Paint::None,
1319        );
1320        cx.semantics(node, Semantics::new(Role::ContentInfo));
1321        node
1322    }
1323}
1324
1325/// One place of a [`ToolRail`]: a tool, and the others that can take its place.
1326#[derive(Clone, Debug)]
1327#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1328pub struct ToolRailItem {
1329    tools: Vec<RailToolInfo>,
1330}
1331
1332#[derive(Clone, Debug)]
1333struct RailToolInfo {
1334    icon: crate::IconName,
1335    name: String,
1336    shortcut: Option<String>,
1337}
1338
1339#[cfg_attr(feature = "live", ravenrs_live::expose)]
1340impl ToolRailItem {
1341    /// A place holding this tool.
1342    pub fn new(icon: crate::IconName, name: impl Into<String>) -> Self {
1343        Self { tools: vec![RailToolInfo { icon, name: name.into(), shortcut: None }] }
1344    }
1345
1346    /// Shows this key beside the tool added last, such as `"M"` (display only).
1347    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
1348        if let Some(tool) = self.tools.last_mut() {
1349            tool.shortcut = Some(keys.into());
1350        }
1351        self
1352    }
1353
1354    /// Adds a tool that shares this place. The others open in a menu at its side, by
1355    /// holding the button down, by a right click or with the Right arrow, and the
1356    /// place shows the last one used.
1357    pub fn variant(mut self, icon: crate::IconName, name: impl Into<String>) -> Self {
1358        self.tools.push(RailToolInfo { icon, name: name.into(), shortcut: None });
1359        self
1360    }
1361}
1362
1363#[derive(Clone, Debug)]
1364enum RailEntry {
1365    Tool(ToolRailItem),
1366    Separator,
1367}
1368
1369/// How long a press on a tool with variants takes to open them, in seconds.
1370const RAIL_HOLD: f64 = 0.4;
1371/// The side of a tool's button when the rail shows only icons.
1372const RAIL_ICON_BUTTON: f32 = 36.0;
1373
1374/// A narrow bar of tools with their names under their icons, of which one is
1375/// open: what sits along the side of an editor, with the open tool's panel next to it.
1376///
1377/// `selected` counts every tool in order, the variants of a place included. It can
1378/// also show only icons, in one column or more, with lines between groups of tools.
1379///
1380/// ```
1381/// use ravenrs_reactive::State;
1382/// use ravenrs_widgets::{IconName, ToolRail, ToolRailItem};
1383///
1384/// let open = State::new(0);
1385/// let rail = ToolRail::new(open.binding(), [(IconName::Type, "Text"), (IconName::ZoomIn, "Zoom")]);
1386///
1387/// let tool = State::new(0);
1388/// let editor = ToolRail::new(tool.binding(), [(IconName::Pencil, "Brush")])
1389///     .separator()
1390///     .tool(
1391///         ToolRailItem::new(IconName::Square, "Rectangle")
1392///             .shortcut("M")
1393///             .variant(IconName::Circle, "Ellipse"),
1394///     )
1395///     .columns(2);
1396/// ```
1397#[derive(Debug)]
1398#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1399pub struct ToolRail {
1400    selected: ravenrs_reactive::Binding<usize>,
1401    entries: Vec<RailEntry>,
1402    width: Option<f32>,
1403    columns: usize,
1404    icons_only: bool,
1405}
1406
1407#[cfg_attr(feature = "live", ravenrs_live::expose)]
1408impl ToolRail {
1409    /// A rail of these tools; `selected` says which one is open.
1410    pub fn new<S: Into<String>>(
1411        selected: ravenrs_reactive::Binding<usize>,
1412        tools: impl IntoIterator<Item = (crate::IconName, S)>,
1413    ) -> Self {
1414        let entries =
1415            tools.into_iter().map(|(icon, name)| RailEntry::Tool(ToolRailItem::new(icon, name))).collect();
1416        Self { selected, entries, width: None, columns: 1, icons_only: false }
1417    }
1418
1419    /// Adds a place with a tool and its variants.
1420    pub fn tool(mut self, item: ToolRailItem) -> Self {
1421        self.entries.push(RailEntry::Tool(item));
1422        self
1423    }
1424
1425    /// Adds a line between two groups of tools.
1426    pub fn separator(mut self) -> Self {
1427        self.entries.push(RailEntry::Separator);
1428        self
1429    }
1430
1431    /// Its width, in logical pixels (76 by default, or what the columns take).
1432    pub fn width(mut self, width: f32) -> Self {
1433        self.width = Some(width);
1434        self
1435    }
1436
1437    /// Shows only icons, with the names as tooltips.
1438    pub fn icons_only(mut self, icons_only: bool) -> Self {
1439        self.icons_only = icons_only;
1440        self
1441    }
1442
1443    /// Lays the tools out in this many columns (1 by default). With more than one, it
1444    /// shows only icons.
1445    pub fn columns(mut self, columns: usize) -> Self {
1446        self.columns = columns.max(1);
1447        self
1448    }
1449}
1450
1451struct RailTool<'a> {
1452    tools: &'a [RailToolInfo],
1453    /// Where this place's tools start, counting every tool of the rail.
1454    first: usize,
1455    selected: ravenrs_reactive::Binding<usize>,
1456    icons_only: bool,
1457}
1458
1459#[cfg_attr(feature = "live", ravenrs_live::expose)]
1460impl RailTool<'_> {
1461    /// The menu of the tools that share this place.
1462    fn variants(&self, shown: usize, last_used: &ravenrs_reactive::State<usize>) -> Vec<Entry> {
1463        self.tools
1464            .iter()
1465            .enumerate()
1466            .map(|(i, tool)| {
1467                let (selected, last_used, tool_at) =
1468                    (self.selected.clone(), last_used.clone(), self.first + i);
1469                let mut item = crate::MenuItem::new(tool.name.clone())
1470                    .icon(tool.icon)
1471                    .checked(i == shown)
1472                    .on_select(move || {
1473                        last_used.set(i);
1474                        selected.set(tool_at);
1475                    });
1476                if let Some(keys) = &tool.shortcut {
1477                    item = item.shortcut(keys.clone());
1478                }
1479                Entry::Item(item)
1480            })
1481            .collect()
1482    }
1483}
1484
1485impl View for RailTool<'_> {
1486    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1487        let theme = *cx.theme();
1488        let hovered = cx.is_hovered();
1489        let last_used = cx.use_state(|| 0_usize);
1490        let current = self.selected.get();
1491        let ours = current.checked_sub(self.first).filter(|i| *i < self.tools.len());
1492        // A place shows the tool in use, or the last one used.
1493        let shown = ours.unwrap_or_else(|| (*last_used.peek()).min(self.tools.len() - 1));
1494        let tool = &self.tools[shown];
1495        let has_variants = self.tools.len() > 1;
1496        let is_selected = ours.is_some();
1497        let ink = theme.color(if is_selected { Semantic::Ink } else { Semantic::InkMuted });
1498        let mut parts = vec![crate::util::icon(cx, tool.icon, 18.0, ink)];
1499        if !self.icons_only {
1500            parts.push(crate::util::label(cx, &tool.name, TextStyle::Caption, None, ink));
1501        }
1502        if has_variants {
1503            // A small mark in the corner says there is more here.
1504            parts.push(cx.container(
1505                LayoutStyle {
1506                    width: Some(4.0),
1507                    height: Some(4.0),
1508                    absolute: Some((
1509                        if self.icons_only { RAIL_ICON_BUTTON - 8.0 } else { 6.0 },
1510                        if self.icons_only { RAIL_ICON_BUTTON - 8.0 } else { 6.0 },
1511                    )),
1512                    ..LayoutStyle::leaf()
1513                },
1514                &[],
1515                Paint::Quad(QuadPaint { corner_radius: 2.0, ..QuadPaint::new(ink) }),
1516            ));
1517        }
1518        let fill = if is_selected {
1519            theme.color(Semantic::Accent).faded(0.2)
1520        } else if hovered {
1521            theme.color(Semantic::Ink).faded(0.05)
1522        } else {
1523            ravenrs_core::Color::TRANSPARENT
1524        };
1525        let style = if self.icons_only {
1526            LayoutStyle {
1527                width: Some(RAIL_ICON_BUTTON),
1528                height: Some(RAIL_ICON_BUTTON),
1529                align: Align::Center,
1530                justify: Justify::Center,
1531                ..LayoutStyle::stack(Axis::Vertical)
1532            }
1533        } else {
1534            LayoutStyle {
1535                height: Some(56.0),
1536                spacing: theme.spacing.s1,
1537                align: Align::Center,
1538                justify: Justify::Center,
1539                align_self: Some(Align::Stretch),
1540                ..LayoutStyle::stack(Axis::Vertical)
1541            }
1542        };
1543        let node = cx.container(
1544            style,
1545            &parts,
1546            Paint::Quad(QuadPaint { corner_radius: theme.radii.md, ..QuadPaint::new(fill) }),
1547        );
1548        let open: ravenrs_reactive::State<bool> = cx.use_state(|| false);
1549        let is_open = has_variants && open.get();
1550        let mut semantics = Semantics::new(Role::Button).name(tool.name.clone()).selected(is_selected);
1551        if has_variants {
1552            semantics = semantics.expanded(is_open);
1553        }
1554        cx.semantics(node, semantics);
1555        // A press that opened the variants does not also choose when it is released.
1556        let held = cx.use_state(|| Rc::new(Cell::new(None::<f64>))).get();
1557        let opened_by_hold = cx.use_state(|| Rc::new(Cell::new(false))).get();
1558        if has_variants {
1559            if cx.is_pressed() && !is_open {
1560                let now = cx.now();
1561                let due = held.get().unwrap_or(now + RAIL_HOLD);
1562                held.set(Some(due));
1563                if now >= due {
1564                    opened_by_hold.set(true);
1565                    open.set(true);
1566                } else {
1567                    cx.wake_at(due);
1568                }
1569            } else {
1570                held.set(None);
1571            }
1572        }
1573        let (selected, tool_at, swallow) =
1574            (self.selected.clone(), self.first + shown, opened_by_hold.clone());
1575        cx.on_click(
1576            node,
1577            Rc::new(move || {
1578                if !swallow.replace(false) {
1579                    selected.set(tool_at);
1580                }
1581            }),
1582        );
1583        cx.focusable(node, theme.radii.md);
1584        cx.cursor(node, ravenrs_core::CursorIcon::Pointer);
1585        if !has_variants {
1586            return node;
1587        }
1588        let at_right = open.clone();
1589        cx.on_key(
1590            node,
1591            Rc::new(move |key, _| {
1592                let opens = *key == ravenrs_core::Key::ArrowRight;
1593                if opens {
1594                    at_right.set(true);
1595                }
1596                opens
1597            }),
1598        );
1599        let on_menu = open.clone();
1600        cx.on_context_menu(node, Rc::new(move |_| on_menu.set(true)));
1601        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
1602        if is_open {
1603            was_open.set(true);
1604            let close: Rc<dyn Fn()> = Rc::new(move || open.set(false));
1605            let entries = self.variants(shown, &last_used);
1606            cx.overlay(
1607                node,
1608                crate::Placement::Right,
1609                &MenuList { entries: &entries, close: close.clone(), on_side: None },
1610                Some(close),
1611            );
1612        } else if was_open.replace(false) {
1613            cx.request_focus_node(node);
1614        }
1615        node
1616    }
1617}
1618
1619impl View for ToolRail {
1620    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1621        let theme = *cx.theme();
1622        let icons_only = self.icons_only || self.columns > 1;
1623        let gap = 2.0;
1624        let padding = theme.spacing.s1;
1625        let columns = self.columns as f32;
1626        let width = self.width.unwrap_or(if icons_only {
1627            columns * RAIL_ICON_BUTTON + (columns - 1.0) * gap + padding * 2.0
1628        } else {
1629            76.0
1630        });
1631        let mut children = Vec::with_capacity(self.entries.len());
1632        let mut row = Vec::with_capacity(self.columns);
1633        let mut first = 0;
1634        let flush = |cx: &mut BuildCx<'_>, row: &mut Vec<NodeId>, children: &mut Vec<NodeId>| {
1635            if !row.is_empty() {
1636                children.push(cx.container(
1637                    LayoutStyle { spacing: gap, ..LayoutStyle::stack(Axis::Horizontal) },
1638                    row,
1639                    Paint::None,
1640                ));
1641                row.clear();
1642            }
1643        };
1644        for entry in &self.entries {
1645            match entry {
1646                RailEntry::Tool(item) => {
1647                    let place =
1648                        RailTool { tools: &item.tools, first, selected: self.selected.clone(), icons_only };
1649                    let name = item.tools.first().map_or("", |tool| tool.name.as_str());
1650                    let node = if icons_only {
1651                        cx.keyed_child(first as u64, &crate::Tooltip::new(place, name.to_owned()))
1652                    } else {
1653                        cx.keyed_child(first as u64, &place)
1654                    };
1655                    first += item.tools.len();
1656                    if self.columns > 1 {
1657                        row.push(node);
1658                        if row.len() == self.columns {
1659                            flush(cx, &mut row, &mut children);
1660                        }
1661                    } else {
1662                        children.push(node);
1663                    }
1664                }
1665                RailEntry::Separator => {
1666                    flush(cx, &mut row, &mut children);
1667                    children.push(cx.child(&Divider::horizontal()));
1668                }
1669            }
1670        }
1671        flush(cx, &mut row, &mut children);
1672        cx.container(
1673            LayoutStyle {
1674                width: Some(width),
1675                spacing: gap,
1676                padding: Insets::uniform(padding),
1677                align: Align::Stretch,
1678                ..LayoutStyle::stack(Axis::Vertical)
1679            },
1680            &children,
1681            Paint::None,
1682        )
1683    }
1684}