Skip to main content

nifty_runtime/
style.rs

1//! Appearance options shared by every backend.
2
3use crate::geometry::{Frame, Point, Size};
4
5coded_enum! {
6    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
7    pub enum Axis {
8        Horizontal,
9        Vertical,
10    }
11}
12
13coded_enum! {
14    /// Placement of children across a stack's axis.
15    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16    pub enum Align {
17        #[default]
18        Stretch,
19        Start,
20        Center,
21        End,
22    }
23}
24
25coded_enum! {
26    /// Distribution of children along a stack's axis.
27    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28    pub enum Justify {
29        #[default]
30        Start,
31        Center,
32        End,
33        SpaceBetween,
34    }
35}
36
37coded_enum! {
38    /// What a stack is drawn on. Each surface is one native view behind the stack's
39    /// children, using the platform's own materials and colors.
40    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41    pub enum Surface {
42        /// Nothing: the children sit on whatever is behind the stack.
43        #[default]
44        None,
45        /// The side panel of a window, translucent where the platform supports it.
46        Sidebar,
47        /// The window's main pane, in the color content is read on.
48        Content,
49        /// A rounded, outlined panel that groups related content.
50        Card,
51    }
52}
53
54coded_enum! {
55    /// What a window is drawn on, in each platform's own materials.
56    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57    pub enum Backdrop {
58        /// The platform's usual window: Mica in the frame on Windows 11, a plain window
59        /// on macOS.
60        #[default]
61        Standard,
62        /// See-through: Acrylic in the frame on Windows 11, and a translucent window that
63        /// shows what's behind it on macOS.
64        Translucent,
65        /// Opaque everywhere, with no material.
66        Solid,
67    }
68}
69
70/// `repr(C)` because compiled apps store it as is.
71#[derive(Clone, Copy, Debug, PartialEq)]
72#[repr(C)]
73pub struct StackStyle {
74    pub axis: Axis,
75    pub spacing: f32,
76    pub padding: f32,
77    pub align: Align,
78    pub justify: Justify,
79    pub surface: Surface,
80    /// Whether the stack takes the space its parent has left along the parent's axis.
81    pub grow: bool,
82    /// A fixed width in points, or [`StackStyle::FIT`] to fit the content.
83    pub width: f32,
84    /// The widest the stack may get in points, or [`StackStyle::FIT`] for no limit. A
85    /// stack with a limit fills the width it is given, up to the limit.
86    pub max_width: f32,
87}
88
89impl StackStyle {
90    /// A `width` or `max_width` that sets no size; compiled apps store sizes as plain
91    /// numbers, so this stands in for "none".
92    pub const FIT: f32 = 0.0;
93
94    #[must_use]
95    pub const fn new(axis: Axis) -> Self {
96        Self {
97            axis,
98            spacing: 0.0,
99            padding: 0.0,
100            align: Align::Stretch,
101            justify: Justify::Start,
102            surface: Surface::None,
103            grow: false,
104            width: Self::FIT,
105            max_width: Self::FIT,
106        }
107    }
108
109    /// The fixed width, if the stack has one.
110    #[must_use]
111    pub fn fixed_width(&self) -> Option<f32> {
112        sets_size(self.width)
113    }
114
115    /// The width limit, if the stack has one.
116    #[must_use]
117    pub fn width_limit(&self) -> Option<f32> {
118        sets_size(self.max_width)
119    }
120}
121
122fn sets_size(points: f32) -> Option<f32> {
123    (points > StackStyle::FIT).then_some(points)
124}
125
126/// The size an image is shown at: a fixed `width` or `height` in points, or
127/// [`ImageSize::FIT`] for the image's own. Fixing only one keeps the image's shape.
128/// `repr(C)` because compiled apps store it as is.
129#[derive(Clone, Copy, Debug, PartialEq)]
130#[repr(C)]
131pub struct ImageSize {
132    pub width: f32,
133    pub height: f32,
134}
135
136impl ImageSize {
137    /// A `width` or `height` that sets no size.
138    pub const FIT: f32 = 0.0;
139
140    /// The frame an image of `natural` size takes: the fixed sizes, with the other side
141    /// following the image's shape, or the image's own size when neither is fixed. An
142    /// image with no size, such as one that can't be read, takes the fixed sizes alone.
143    #[must_use]
144    pub fn frame(self, natural: Size) -> Size {
145        let ratio = |along: f32, across: f32| if along > 0.0 { across / along } else { 0.0 };
146        match (sets_size(self.width), sets_size(self.height)) {
147            (Some(width), Some(height)) => Size::new(width, height),
148            (Some(width), None) => Size::new(width, width * ratio(natural.width, natural.height)),
149            (None, Some(height)) => {
150                Size::new(height * ratio(natural.height, natural.width), height)
151            }
152            (None, None) => natural,
153        }
154    }
155
156    /// Where an image of `natural` size is drawn in `frame`: as large as fits, keeping its
157    /// shape, centred.
158    #[must_use]
159    pub fn fit(natural: Size, frame: Size) -> Frame {
160        if natural.width <= 0.0 || natural.height <= 0.0 {
161            return Frame::default();
162        }
163        let scale = (frame.width / natural.width).min(frame.height / natural.height);
164        let size = Size::new(natural.width * scale, natural.height * scale);
165        Frame {
166            origin: Point {
167                x: (frame.width - size.width) / 2.0,
168                y: (frame.height - size.height) / 2.0,
169            },
170            size,
171        }
172    }
173}
174
175coded_enum! {
176    /// The platform's system text styles, so apps follow the system font and accessibility sizes.
177    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
178    pub enum TextStyle {
179        #[default]
180        Body,
181        Title,
182        Headline,
183        Caption,
184        /// The largest heading, for the name of what a window shows.
185        LargeTitle,
186    }
187}
188
189impl TextStyle {
190    /// Captions are secondary text; every other style reads as primary.
191    #[must_use]
192    pub const fn default_color(self) -> TextColor {
193        match self {
194            Self::Caption => TextColor::Secondary,
195            Self::Body | Self::Title | Self::Headline | Self::LargeTitle => TextColor::Primary,
196        }
197    }
198}
199
200coded_enum! {
201    /// Semantic text colors, so text follows the system appearance and accent color.
202    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203    pub enum TextColor {
204        #[default]
205        Primary,
206        Secondary,
207        Tertiary,
208        Accent,
209        Success,
210        Warning,
211        Danger,
212    }
213}
214
215coded_enum! {
216    /// Placement of text within its frame.
217    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
218    pub enum TextAlign {
219        #[default]
220        Start,
221        Center,
222        End,
223    }
224}
225
226/// `repr(C)` because compiled apps store it as is.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228#[repr(C)]
229pub struct LabelStyle {
230    pub font: TextStyle,
231    pub color: TextColor,
232    pub align: TextAlign,
233    /// Whether the user can select and copy the text.
234    pub selectable: bool,
235}
236
237impl LabelStyle {
238    #[must_use]
239    pub const fn new(font: TextStyle) -> Self {
240        Self {
241            font,
242            color: font.default_color(),
243            align: TextAlign::Start,
244            selectable: false,
245        }
246    }
247}
248
249impl Default for LabelStyle {
250    fn default() -> Self {
251        Self::new(TextStyle::default())
252    }
253}
254
255coded_enum! {
256    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
257    pub enum ButtonStyle {
258        #[default]
259        Normal,
260        /// The window's default action, drawn in the accent color and triggered by Return.
261        Primary,
262        /// Borderless, for actions that shouldn't draw the eye, such as those in a row.
263        Plain,
264        /// Borderless and showing only its icon; the title becomes its tooltip and what
265        /// screen readers say.
266        Icon,
267    }
268}
269
270coded_enum! {
271    /// How a control that turns a `bool` on and off looks.
272    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
273    pub enum ToggleStyle {
274        /// A tick box before its title.
275        #[default]
276        Checkbox,
277        /// A switch at the end of its title, as in settings.
278        Switch,
279    }
280}
281
282coded_enum! {
283    /// Symbols from the platform's own icon set: SF Symbols on macOS, Segoe Fluent Icons
284    /// on Windows. Nothing is bundled with the app.
285    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
286    pub enum Icon {
287        /// No icon.
288        #[default]
289        None,
290        Plus,
291        Trash,
292        Star,
293        StarFill,
294        Check,
295        CheckCircle,
296        Circle,
297        Home,
298        Inbox,
299        Archive,
300        List,
301        Calendar,
302        Sun,
303        Flag,
304        Close,
305        Gear,
306        Search,
307        Mail,
308        Send,
309        Folder,
310        Clock,
311        Tag,
312        Person,
313        Document,
314        Download,
315        Heart,
316        Bell,
317        Pencil,
318        Share,
319        Info,
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn round_trips<T: Copy + PartialEq + std::fmt::Debug>(
328        all: &[T],
329        code: fn(T) -> u8,
330        from_code: fn(u8) -> Option<T>,
331    ) {
332        for (index, variant) in all.iter().enumerate() {
333            assert_eq!(usize::from(code(*variant)), index);
334            assert_eq!(from_code(code(*variant)), Some(*variant));
335        }
336        let past_the_end = u8::try_from(all.len()).expect("few variants");
337        assert_eq!(from_code(past_the_end), None);
338    }
339
340    #[test]
341    fn images_keep_their_shape_unless_both_sides_are_fixed() {
342        let natural = Size::new(200.0, 100.0);
343        let size = |width, height| ImageSize { width, height };
344        assert_eq!(size(0.0, 0.0).frame(natural), natural);
345        assert_eq!(size(50.0, 0.0).frame(natural), Size::new(50.0, 25.0));
346        assert_eq!(size(0.0, 50.0).frame(natural), Size::new(100.0, 50.0));
347        assert_eq!(size(30.0, 30.0).frame(natural), Size::new(30.0, 30.0));
348        assert_eq!(size(30.0, 0.0).frame(Size::ZERO), Size::new(30.0, 0.0));
349        assert_eq!(
350            ImageSize::fit(natural, Size::new(50.0, 50.0)),
351            Frame {
352                origin: Point { x: 0.0, y: 12.5 },
353                size: Size::new(50.0, 25.0),
354            }
355        );
356    }
357
358    #[test]
359    fn every_style_round_trips_through_its_code() {
360        round_trips(Axis::ALL, Axis::code, Axis::from_code);
361        round_trips(Align::ALL, Align::code, Align::from_code);
362        round_trips(Justify::ALL, Justify::code, Justify::from_code);
363        round_trips(Surface::ALL, Surface::code, Surface::from_code);
364        round_trips(Backdrop::ALL, Backdrop::code, Backdrop::from_code);
365        round_trips(TextStyle::ALL, TextStyle::code, TextStyle::from_code);
366        round_trips(TextColor::ALL, TextColor::code, TextColor::from_code);
367        round_trips(TextAlign::ALL, TextAlign::code, TextAlign::from_code);
368        round_trips(ButtonStyle::ALL, ButtonStyle::code, ButtonStyle::from_code);
369        round_trips(ToggleStyle::ALL, ToggleStyle::code, ToggleStyle::from_code);
370        round_trips(Icon::ALL, Icon::code, Icon::from_code);
371    }
372}