Skip to main content

iced_m3/widget/
navbar.rs

1use iced::{
2    Alignment, Border, Element, Font, Length, Size,
3    advanced::{Text, text::Paragraph},
4    padding,
5};
6use iced_widget::{
7    button, center, center_x, column, responsive, row,
8    text::{LineHeight, Shaping, Wrapping},
9};
10
11use crate::{style::mix_colors, theme::ColorScheme};
12
13const COMPACT_BUTTON_SIZE: Size<f32> = Size {
14    width: 56.0,
15    height: 32.0,
16};
17const LARGE_BUTTON_SIZE: Size<f32> = Size {
18    width: 100.0,
19    height: 40.0,
20};
21const LARGE_BUTTON_SPACING: f32 = 24.0;
22const DEFAULT_LABEL_SIZE: f32 = 14.0;
23const DEFAULT_ICON_SIZE: f32 = 24.0;
24const COMPACT_VERTICAL_PADDING: f32 = 6.0;
25const LARGE_VERTICAL_PADDING: f32 = 12.0;
26const LARGE_BUTTON_INTERNAL_SPACING: f32 = 4.0;
27const LARGE_BUTTON_PADDING: f32 = 16.0;
28
29#[derive(Default)]
30pub enum Mode {
31    Compact,
32    Large,
33    #[default]
34    Auto,
35}
36
37pub struct Item<'a, Message> {
38    pub icon: &'a char,
39    pub label: &'a str,
40    pub message: Message,
41}
42
43pub struct Navbar<'a, Message> {
44    compact: bool,
45    items: Vec<Item<'a, Message>>,
46    theme: &'a dyn ColorScheme,
47    font: Option<Font>,
48    icon_font_active: Option<Font>,
49    icon_font_inactive: Option<Font>,
50    mode: Mode,
51    focused_index: usize,
52    icon_size: f32,
53    label_size: f32,
54}
55
56impl<'a, Message> Navbar<'a, Message> {
57    #[must_use]
58    pub fn new(items: Vec<Item<'a, Message>>, theme: &'a impl ColorScheme) -> Self {
59        Self {
60            compact: false,
61            items,
62            theme,
63            font: None,
64            icon_font_active: None,
65            icon_font_inactive: None,
66            mode: Mode::default(),
67            focused_index: 0,
68            icon_size: DEFAULT_ICON_SIZE,
69            label_size: DEFAULT_LABEL_SIZE,
70        }
71    }
72
73    #[must_use]
74    pub fn compact(mut self, val: bool) -> Self {
75        self.compact = val;
76        self
77    }
78
79    #[must_use]
80    pub fn item(mut self, item: Item<'a, Message>) -> Self {
81        self.items.push(item);
82        self
83    }
84
85    #[must_use]
86    pub fn font(mut self, font: Font) -> Self {
87        self.font = Some(font);
88        self
89    }
90
91    #[must_use]
92    pub fn icon_font_active(mut self, font: Font) -> Self {
93        self.icon_font_active = Some(font);
94        self
95    }
96
97    #[must_use]
98    pub fn icon_font_inactive(mut self, font: Font) -> Self {
99        self.icon_font_inactive = Some(font);
100        self
101    }
102
103    #[must_use]
104    pub fn mode(mut self, mode: Mode) -> Self {
105        self.mode = mode;
106        self
107    }
108
109    #[must_use]
110    pub fn focused_index(mut self, index: usize) -> Self {
111        self.focused_index = index;
112        self
113    }
114
115    #[must_use]
116    pub fn icon_size(mut self, size: f32) -> Self {
117        self.icon_size = size;
118        self
119    }
120
121    #[must_use]
122    pub fn label_size(mut self, size: f32) -> Self {
123        self.label_size = size;
124        self
125    }
126}
127
128impl<'a, Message> From<Navbar<'a, Message>> for Element<'a, Message, iced::Theme, iced::Renderer>
129where
130    Message: 'a + Clone,
131{
132    fn from(navbar: Navbar<'a, Message>) -> Self {
133        let font = navbar.font.unwrap_or_default();
134
135        let mut max_width = 0.0;
136        for item in &navbar.items {
137            let p = iced::advanced::graphics::text::Paragraph::with_text(Text {
138                content: item.label,
139                bounds: Size::INFINITE,
140                size: navbar.label_size.into(),
141                line_height: LineHeight::default(),
142                font,
143                align_x: iced_widget::text::Alignment::Left,
144                align_y: iced::alignment::Vertical::Top,
145                shaping: Shaping::Advanced,
146                wrapping: Wrapping::None,
147            });
148            let width = p.min_bounds().width;
149
150            if width > max_width {
151                max_width = width
152            }
153        }
154
155        // There seems to be a slight mismatch between the calculated and actual text size. The one
156        // I estimate here is sometimes less than one pixel off than the actual size, but that
157        // already causes the text to wrap which just looks bad.
158        //
159        // So I apply the padding here because that less than 1px won't even make a difference
160        // visually.
161        max_width += navbar.icon_size + LARGE_BUTTON_INTERNAL_SPACING + 2.0 * LARGE_BUTTON_PADDING;
162
163        responsive(move |size| {
164            let compact = match navbar.mode {
165                Mode::Compact => true,
166                Mode::Large => false,
167                Mode::Auto => {
168                    navbar.items.len() as f32 * max_width
169                        + (navbar.items.len() as i64 - 1) as f32 * LARGE_BUTTON_SPACING
170                        > size.width
171                }
172            };
173
174            let font = navbar.font.unwrap_or_default();
175            let icon_font_active = navbar
176                .icon_font_active
177                .unwrap_or(navbar.icon_font_inactive.unwrap_or_default());
178            let icon_font_inactive = navbar
179                .icon_font_inactive
180                .unwrap_or(navbar.icon_font_active.unwrap_or_default());
181            let buttons = if navbar.items.is_empty() {
182                Vec::new()
183            } else {
184                if compact {
185                    let mut buttons = Vec::with_capacity(navbar.items.len());
186                    for (i, item) in navbar.items.iter().enumerate() {
187                        let active = i == navbar.focused_index;
188                        let label_color = if active {
189                            navbar.theme.secondary()
190                        } else {
191                            navbar.theme.on_surface_variant()
192                        };
193                        let icon_color = if active {
194                            navbar.theme.on_secondary_container()
195                        } else {
196                            navbar.theme.on_surface_variant()
197                        };
198                        let icon_font = if active {
199                            icon_font_active
200                        } else {
201                            icon_font_inactive
202                        };
203
204                        buttons.push(
205                            center_x(
206                                column![
207                                    button(center(
208                                        iced_widget::text(item.icon)
209                                            .font(icon_font)
210                                            .color(icon_color)
211                                            .size(navbar.icon_size)
212                                            .align_x(Alignment::Center)
213                                            .align_y(Alignment::Center)
214                                    ))
215                                    .style(move |_, status| {
216                                        let state_layer_color =
217                                            navbar.theme.on_secondary_container();
218                                        let layer_opacity = match status {
219                                            button::Status::Active => 0.0,
220                                            button::Status::Hovered => 0.08,
221                                            button::Status::Pressed => 0.1,
222                                            button::Status::Disabled => unreachable!(),
223                                        };
224                                        let color = if active {
225                                            mix_colors(
226                                                state_layer_color,
227                                                navbar.theme.secondary_container(),
228                                                layer_opacity,
229                                            )
230                                        } else {
231                                            state_layer_color.scale_alpha(layer_opacity)
232                                        };
233                                        button::Style {
234                                            background: Some(iced::Background::Color(color)),
235                                            border: Border {
236                                                radius: f32::MAX.into(),
237                                                ..Default::default()
238                                            },
239                                            ..Default::default()
240                                        }
241                                    })
242                                    .on_press(item.message.clone())
243                                    .width(Length::Fixed(COMPACT_BUTTON_SIZE.width))
244                                    .height(Length::Fixed(COMPACT_BUTTON_SIZE.height)),
245                                    iced_widget::text(item.label)
246                                        .font(font)
247                                        .color(label_color)
248                                        .size(navbar.label_size)
249                                        .wrapping(Wrapping::None),
250                                ]
251                                .align_x(Alignment::Center)
252                                .spacing(4.0),
253                            )
254                            .into(),
255                        );
256                    }
257                    buttons
258                } else {
259                    let mut buttons = Vec::with_capacity(navbar.items.len());
260                    for (i, item) in navbar.items.iter().enumerate() {
261                        let active = i == navbar.focused_index;
262                        let label_color = if active {
263                            navbar.theme.secondary()
264                        } else {
265                            navbar.theme.on_surface_variant()
266                        };
267                        let icon_color = if active {
268                            navbar.theme.on_secondary_container()
269                        } else {
270                            navbar.theme.on_surface_variant()
271                        };
272                        let icon_font = if active {
273                            icon_font_active
274                        } else {
275                            icon_font_inactive
276                        };
277
278                        buttons.push(
279                            button(center(
280                                row![
281                                    iced_widget::text(item.icon)
282                                        .font(icon_font)
283                                        .color(icon_color)
284                                        .size(navbar.icon_size),
285                                    iced_widget::text(item.label)
286                                        .font(font)
287                                        .color(label_color)
288                                        .size(navbar.label_size)
289                                ]
290                                .align_y(Alignment::Center)
291                                .spacing(LARGE_BUTTON_INTERNAL_SPACING),
292                            ))
293                            .style(move |_, status| {
294                                let state_layer_color = navbar.theme.on_secondary_container();
295                                let layer_opacity = match status {
296                                    button::Status::Active => 0.0,
297                                    button::Status::Hovered => 0.08,
298                                    button::Status::Pressed => 0.1,
299                                    button::Status::Disabled => unreachable!(),
300                                };
301                                let color = if active {
302                                    mix_colors(
303                                        state_layer_color,
304                                        navbar.theme.secondary_container(),
305                                        layer_opacity,
306                                    )
307                                } else {
308                                    state_layer_color.scale_alpha(layer_opacity)
309                                };
310                                button::Style {
311                                    background: Some(iced::Background::Color(color)),
312                                    border: Border {
313                                        radius: f32::MAX.into(),
314                                        ..Default::default()
315                                    },
316                                    ..Default::default()
317                                }
318                            })
319                            .on_press(item.message.clone())
320                            .width(Length::Fixed(max_width))
321                            .height(Length::Fixed(LARGE_BUTTON_SIZE.height))
322                            .into(),
323                        )
324                    }
325                    buttons
326                }
327            };
328
329            let padding = if compact {
330                COMPACT_VERTICAL_PADDING
331            } else {
332                LARGE_VERTICAL_PADDING
333            };
334            let spacing = if compact { 0.0 } else { LARGE_BUTTON_SPACING };
335            center_x(row(buttons).spacing(spacing))
336                .padding(padding::vertical(padding))
337                .into()
338        })
339        .height(Length::Shrink)
340        .into()
341    }
342}