Skip to main content

frus_widgets/
kbd.rs

1//! [`Kbd`]: a **keyboard key** cap, used as a shortcut hint.
2
3use frus_core::{FontFamily, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4use frus_layout::{Dimension, Style};
5
6use crate::interaction::Status;
7use crate::theme::Theme;
8use crate::widget::Widget;
9
10const PAD_X: f32 = 7.0;
11const PAD_Y: f32 = 3.0;
12
13/// A key cap showing a label, such as "Ctrl" or "Enter".
14pub struct Kbd {
15    label: String,
16    text_style: Option<TextStyle>,
17}
18
19impl Kbd {
20    /// Creates a key cap with the given label.
21    pub fn new(label: impl Into<String>) -> Self {
22        Self {
23            label: label.into(),
24            text_style: None,
25        }
26    }
27
28    /// The label's type, over the theme's and the framework's.
29    #[must_use]
30    pub fn text_style(mut self, style: TextStyle) -> Self {
31        self.text_style = Some(style);
32        self
33    }
34
35    /// The style this widget's text is drawn in, **resolved once** so that the number the
36    /// box is measured with is the number the glyphs are drawn at. Resolving is the single
37    /// place the reader's font setting is applied (milestone 403); a size that never
38    /// passes through it is a size the reader cannot change.
39    ///
40    /// The reference has no key cap, so the default is argued rather than read: a shortcut
41    /// hint is a **label**, and a cap stands for what is printed on a keyboard — hence
42    /// monospaced. Both halves of that are a theme's to overrule.
43    fn label_style(&self, theme: Option<&Theme>) -> ResolvedTextStyle {
44        self.text_style
45            .or(theme.and_then(|t| t.widgets.kbd.text_style))
46            .unwrap_or_else(|| {
47                crate::theme::type_scale(theme)
48                    .label_medium
49                    .family(FontFamily::Monospace)
50            })
51            .resolved()
52    }
53
54    fn sizing(&self, theme: Option<&Theme>) -> Style {
55        let measured = frus_text::measure_resolved(&self.label, &self.label_style(theme));
56        Style {
57            width: Dimension::Length((measured.width + PAD_X * 2.0).ceil()),
58            height: Dimension::Length((measured.height + PAD_Y * 2.0).ceil()),
59            ..Default::default()
60        }
61    }
62}
63
64impl<Msg> Widget<Msg> for Kbd {
65    fn style(&self) -> Style {
66        self.sizing(None)
67    }
68
69    fn style_themed(&self, theme: &Theme) -> Style {
70        self.sizing(Some(theme))
71    }
72
73    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
74        &[]
75    }
76
77    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
78        let o = status.opacity;
79        scene.draw_rect(
80            bounds,
81            theme.surface.fade(o),
82            5.0,
83            1.0,
84            theme.border.fade(o),
85        );
86        scene.text(
87            Point::new(bounds.x + PAD_X, bounds.y + PAD_Y),
88            self.label.clone(),
89            &self.label_style(Some(theme)),
90            theme.muted.fade(o),
91        );
92    }
93
94    fn on_click(&self) -> Option<Msg> {
95        None
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use frus_core::Primitive;
103
104    #[test]
105    fn paints_cap_and_label() {
106        let kbd = Kbd::new("Ctrl");
107        let mut scene = Scene::new();
108        Widget::<()>::paint(
109            &kbd,
110            Rect::new(0.0, 0.0, 40.0, 20.0),
111            Status::default(),
112            &Theme::default(),
113            &mut scene,
114        );
115        assert!(scene
116            .primitives()
117            .iter()
118            .any(|p| matches!(p, Primitive::Rect { border_width, .. } if *border_width > 0.0)));
119        assert!(scene
120            .primitives()
121            .iter()
122            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Ctrl")));
123    }
124}