Skip to main content

ravenrs_widgets/
shortcuts.rs

1//! What a screen's keys do: a sheet with all of them and a line of hints.
2
3use ravenrs_core::{Insets, Role};
4use ravenrs_layout::{Align, Axis, LayoutStyle};
5use ravenrs_reactive::Binding;
6use ravenrs_style::{Semantic, TextStyle, Weight};
7
8use crate::context::{BuildCx, NodeId, Paint, Semantics};
9use crate::{Dialog, Kbd, View, util};
10
11/// Width of a group of a sheet, so that as many as fit stand side by side.
12const SHEET_GROUP_WIDTH: f32 = 280.0;
13/// How much of the window the list of a sheet may take before it scrolls.
14const SHEET_HEIGHT_SHARE: f32 = 0.7;
15/// The height of a line of a sheet and of a group's title, to tell when it may not fit.
16const SHEET_LINE: f32 = 30.0;
17
18/// One group of a sheet, with what it holds as (keys, what they do).
19#[derive(Clone, Debug)]
20struct ShortcutGroup {
21    title: String,
22    rows: Vec<(String, String)>,
23}
24
25/// A dialog listing the keys of a screen, in groups. Escape, the backdrop and its X close it.
26///
27/// It shows what it is told, so write the keys as they read on this system:
28/// [`Hotkey::label`](crate::Hotkey::label) does it for the keys an app has registered.
29///
30/// ```
31/// use ravenrs_reactive::State;
32/// use ravenrs_widgets::ShortcutSheet;
33///
34/// let open = State::new(true);
35/// let sheet = ShortcutSheet::new(open.binding())
36///     .group("Navigate", [("←", "Previous photo"), ("→", "Next photo")])
37///     .group("Rate", [("1–5", "Give a score"), ("0", "Take it away")]);
38/// ```
39#[derive(Debug)]
40#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
41pub struct ShortcutSheet {
42    open: Binding<bool>,
43    title: Option<String>,
44    groups: Vec<ShortcutGroup>,
45    width: f32,
46}
47
48#[cfg_attr(feature = "live", ravenrs_live::expose)]
49impl ShortcutSheet {
50    /// A sheet shown while `open` is true.
51    pub fn new(open: Binding<bool>) -> Self {
52        Self { open, title: None, groups: Vec::new(), width: 2.0 * SHEET_GROUP_WIDTH + 100.0 }
53    }
54
55    /// Sets the title, instead of the one in [`Labels`](crate::Labels).
56    pub fn title(mut self, title: impl Into<String>) -> Self {
57        self.title = Some(title.into());
58        self
59    }
60
61    /// Adds a group of `(keys, what they do)`.
62    pub fn group<K: Into<String>, D: Into<String>>(
63        mut self,
64        title: impl Into<String>,
65        rows: impl IntoIterator<Item = (K, D)>,
66    ) -> Self {
67        let rows = rows.into_iter().map(|(keys, does)| (keys.into(), does.into())).collect();
68        self.groups.push(ShortcutGroup { title: title.into(), rows });
69        self
70    }
71
72    /// Sets the width of the dialog in logical pixels; groups go side by side as they fit.
73    pub fn width(mut self, width: f32) -> Self {
74        self.width = width;
75        self
76    }
77}
78
79/// The groups inside the dialog.
80struct SheetBody {
81    groups: Vec<ShortcutGroup>,
82}
83
84#[cfg_attr(feature = "live", ravenrs_live::expose)]
85impl SheetBody {
86    fn row(cx: &mut BuildCx<'_>, keys: &str, does: &str) -> NodeId {
87        let theme = *cx.theme();
88        let cap = cx.child(&Kbd::new(keys));
89        // A column for the keys, so that what they do starts in the same place.
90        let cap = cx.container(
91            LayoutStyle { min_width: Some(64.0), ..LayoutStyle::stack(Axis::Horizontal) },
92            &[cap],
93            Paint::None,
94        );
95        let does = util::label(cx, does, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
96        cx.container(
97            LayoutStyle {
98                spacing: theme.spacing.s2,
99                align: Align::Center,
100                min_height: Some(SHEET_LINE - theme.spacing.s1),
101                ..LayoutStyle::stack(Axis::Horizontal)
102            },
103            &[cap, does],
104            Paint::None,
105        )
106    }
107}
108
109impl View for SheetBody {
110    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
111        let theme = *cx.theme();
112        let mut groups = Vec::with_capacity(self.groups.len());
113        for group in &self.groups {
114            let title = util::label(
115                cx,
116                &group.title,
117                TextStyle::Caption,
118                Some(Weight::SEMIBOLD),
119                theme.color(Semantic::InkMuted),
120            );
121            cx.semantics(title, Semantics::new(Role::Heading).level(3));
122            let mut column = vec![title];
123            for (keys, does) in &group.rows {
124                column.push(Self::row(cx, keys, does));
125            }
126            groups.push(cx.container(
127                LayoutStyle {
128                    width: Some(SHEET_GROUP_WIDTH),
129                    spacing: theme.spacing.s1,
130                    ..LayoutStyle::stack(Axis::Vertical)
131                },
132                &column,
133                Paint::None,
134            ));
135        }
136        let lines: usize = self.groups.iter().map(|g| g.rows.len() + 1).sum();
137        let room = cx.viewport().height * SHEET_HEIGHT_SHARE;
138        // Scrolling only when it could not fit even in one column, since a box that scrolls
139        // has a fixed height and would leave a short list in empty space.
140        let scrolls = lines as f32 * SHEET_LINE > room;
141        let node = cx.container(
142            LayoutStyle {
143                spacing: theme.spacing.s5,
144                wrap: true,
145                height: scrolls.then_some(room),
146                overflow_hidden: scrolls,
147                padding: Insets::ZERO,
148                ..LayoutStyle::stack(Axis::Horizontal)
149            },
150            &groups,
151            Paint::None,
152        );
153        if scrolls {
154            cx.scrollable(node);
155            cx.semantics(node, Semantics::new(Role::ScrollView));
156        }
157        node
158    }
159}
160
161impl View for ShortcutSheet {
162    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
163        let title = self.title.clone().unwrap_or_else(|| cx.labels().shortcut_sheet.clone());
164        // The groups are copied into the dialog only while it shows.
165        let groups = if self.open.get() { self.groups.clone() } else { Vec::new() };
166        let dialog = Dialog::new(self.open.clone(), title).width(self.width).content(SheetBody { groups });
167        cx.child(&dialog)
168    }
169}
170
171/// A line of key hints under what they act on: `← → move · 1–5 rate`.
172///
173/// ```
174/// use ravenrs_widgets::ShortcutHintBar;
175///
176/// let hints = ShortcutHintBar::new()
177///     .leading("3 of 40")
178///     .hint(["←", "→"], "move")
179///     .hint(["1", "5"], "rate");
180/// ```
181#[derive(Clone, Debug, Default)]
182#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
183pub struct ShortcutHintBar {
184    leading: Option<String>,
185    hints: Vec<(Vec<String>, String)>,
186}
187
188#[cfg_attr(feature = "live", ravenrs_live::expose)]
189impl ShortcutHintBar {
190    /// A line with no hints yet.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Text before the hints, such as where the selection is.
196    pub fn leading(mut self, text: impl Into<String>) -> Self {
197        self.leading = Some(text.into());
198        self
199    }
200
201    /// Adds the caps of `keys`, followed by what they do.
202    pub fn hint<K: Into<String>>(
203        mut self,
204        keys: impl IntoIterator<Item = K>,
205        does: impl Into<String>,
206    ) -> Self {
207        self.hints.push((keys.into_iter().map(Into::into).collect(), does.into()));
208        self
209    }
210}
211
212impl View for ShortcutHintBar {
213    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
214        let theme = *cx.theme();
215        let muted = theme.color(Semantic::InkMuted);
216        let mut children = Vec::with_capacity(self.hints.len() + 1);
217        if let Some(text) = &self.leading {
218            children.push(util::label(cx, text, TextStyle::Caption, None, muted));
219        }
220        for (keys, does) in &self.hints {
221            let mut hint: Vec<NodeId> = keys.iter().map(|key| cx.child(&Kbd::new(key.clone()))).collect();
222            hint.push(util::label(cx, does, TextStyle::Caption, None, muted));
223            children.push(cx.container(
224                LayoutStyle {
225                    spacing: theme.spacing.s1,
226                    align: Align::Center,
227                    ..LayoutStyle::stack(Axis::Horizontal)
228                },
229                &hint,
230                Paint::None,
231            ));
232        }
233        cx.container(
234            LayoutStyle {
235                spacing: theme.spacing.s3,
236                align: Align::Center,
237                wrap: true,
238                ..LayoutStyle::stack(Axis::Horizontal)
239            },
240            &children,
241            Paint::None,
242        )
243    }
244}