Skip to main content

iced_cube/overlay/
menu.rs

1//! The look every menu and list shares: dropdown and context menus, the
2//! command list, and the combobox and select lists.
3//!
4//! Rows are 32 pixels tall: 14 pixel text on a 20 pixel line, 6 pixels of
5//! padding above and below and 8 at the sides, inset 4 pixels from the
6//! surface edge. [`row_style`] resolves their colours for a [`RowStatus`].
7
8use iced::widget::text::LineHeight;
9use iced::widget::{Row, container, row, text};
10use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
11
12use crate::icon::{self, Glyph, themed};
13use crate::theme::{Tokens, fade, radius, space, text_size};
14
15/// Padding inside a row: 6 pixels above and below, 8 at the sides.
16pub const ROW_PADDING: Padding = Padding {
17    top: 6.0,
18    bottom: 6.0,
19    left: space::SM,
20    right: space::SM,
21};
22/// Line height of a row's label.
23pub const LINE_HEIGHT: f32 = 20.0;
24/// Line height of a group label.
25pub const LABEL_LINE_HEIGHT: f32 = 16.0;
26/// Size of leading icons, check marks and chevrons.
27pub const ICON_SIZE: f32 = 16.0;
28
29/// How a row is drawn.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
31pub enum RowStatus {
32    #[default]
33    Idle,
34    /// Under the pointer or the keyboard highlight.
35    Highlighted,
36    Disabled,
37}
38
39impl RowStatus {
40    pub const ALL: [RowStatus; 3] = [RowStatus::Idle, RowStatus::Highlighted, RowStatus::Disabled];
41}
42
43/// The colours of one row. Icons are drawn with an opaque tint; a disabled
44/// row fades them with [`icon::opacity`].
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct RowStyle {
47    pub background: Option<Color>,
48    pub text: Color,
49    /// The leading icon.
50    pub icon: Color,
51    /// The shortcut hint.
52    pub hint: Color,
53}
54
55/// Resolves a row's colours. Destructive rows keep the destructive colour
56/// for their text and icon, and highlight with a tint of it.
57pub fn row_style(tokens: &Tokens, status: RowStatus, destructive: bool) -> RowStyle {
58    match (status, destructive) {
59        (RowStatus::Disabled, _) => RowStyle {
60            background: None,
61            text: tokens.muted_foreground,
62            icon: tokens.muted_foreground,
63            hint: fade(tokens.muted_foreground, 0.5),
64        },
65        (RowStatus::Idle, false) => RowStyle {
66            background: None,
67            text: tokens.foreground,
68            icon: tokens.muted_foreground,
69            hint: tokens.muted_foreground,
70        },
71        (RowStatus::Highlighted, false) => RowStyle {
72            background: Some(tokens.accent),
73            text: tokens.accent_foreground,
74            icon: tokens.muted_foreground,
75            hint: tokens.muted_foreground,
76        },
77        (RowStatus::Idle, true) => RowStyle {
78            background: None,
79            text: tokens.destructive,
80            icon: tokens.destructive,
81            hint: tokens.muted_foreground,
82        },
83        (RowStatus::Highlighted, true) => RowStyle {
84            background: Some(fade(
85                tokens.destructive,
86                if tokens.is_dark { 0.2 } else { 0.1 },
87            )),
88            text: tokens.destructive,
89            icon: tokens.destructive,
90            hint: tokens.muted_foreground,
91        },
92    }
93}
94
95/// The container style of a row: its background, text colour and radius.
96pub fn row_container_style(style: RowStyle) -> container::Style {
97    container::Style {
98        background: style.background.map(Background::Color),
99        text_color: Some(style.text),
100        border: Border {
101            radius: radius::SM.into(),
102            ..Border::default()
103        },
104        ..container::Style::default()
105    }
106}
107
108/// The group label style.
109pub fn label_style(tokens: &Tokens) -> text::Style {
110    text::Style {
111        color: Some(tokens.muted_foreground),
112    }
113}
114
115/// The separator line.
116pub fn separator_style(tokens: &Tokens) -> container::Style {
117    container::Style {
118        background: Some(Background::Color(tokens.border)),
119        ..container::Style::default()
120    }
121}
122
123/// Space a label or row leaves for the check mark slot in a menu with
124/// checkbox or radio items, so every label lines up.
125#[cfg(any(feature = "command", feature = "dropdown-menu"))]
126const INDICATOR_WIDTH: f32 = ICON_SIZE + space::SM;
127
128/// What sits in front of a row's label. Menus that reserve a leading slot
129/// give every row one, so all labels line up: a check mark or radio dot
130/// when checked, otherwise the row's icon, otherwise nothing.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub(crate) enum Leading {
133    /// No slot. The icon, if any, still comes first.
134    None,
135    /// The slot holds the icon, or stays empty without one.
136    #[cfg(feature = "dropdown-menu")]
137    Empty,
138    #[cfg(feature = "dropdown-menu")]
139    Check,
140    #[cfg(feature = "dropdown-menu")]
141    Dot,
142}
143
144/// What sits at the end of a row, after any shortcut hint.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub(crate) enum Trailing {
147    None,
148    /// The selected option in a single-choice list.
149    #[cfg(any(feature = "combobox", feature = "select"))]
150    Check,
151    /// A row that opens a submenu.
152    #[cfg(feature = "dropdown-menu")]
153    Chevron,
154}
155
156/// The parts of a row, laid out by [`content`].
157pub(crate) struct Parts<'a> {
158    pub label: &'a str,
159    pub icon: Option<Glyph>,
160    pub hint: Option<&'a str>,
161    pub leading: Leading,
162    pub trailing: Trailing,
163}
164
165impl<'a> Parts<'a> {
166    pub fn label(label: &'a str) -> Self {
167        Self {
168            label,
169            icon: None,
170            hint: None,
171            leading: Leading::None,
172            trailing: Trailing::None,
173        }
174    }
175}
176
177/// An icon tinted by a row's colours: `pick` chooses the colour.
178fn glyph<'a, Message: 'a>(
179    glyph: Glyph,
180    colours: impl Fn(&Theme) -> RowStyle + 'a,
181    pick: fn(&RowStyle) -> Color,
182    opacity: f32,
183) -> Element<'a, Message> {
184    themed(glyph, ICON_SIZE, opacity, move |theme| {
185        pick(&colours(theme))
186    })
187    .into()
188}
189
190/// A row's content, without its padding or background. The label takes
191/// the text colour of whatever holds the row.
192pub(crate) fn content<'a, Message: 'a>(
193    parts: Parts<'_>,
194    status: RowStatus,
195    destructive: bool,
196) -> Row<'a, Message> {
197    let colours = move |theme: &Theme| row_style(&Tokens::of(theme), status, destructive);
198    let opacity = icon::opacity(status != RowStatus::Disabled);
199
200    let mut content = row![].spacing(space::SM).align_y(Alignment::Center);
201    let icon = parts
202        .icon
203        .map(|icon| glyph(icon, colours, |style| style.icon, opacity));
204    let leading: Option<Element<'a, Message>> = match parts.leading {
205        Leading::None => icon,
206        #[cfg(feature = "dropdown-menu")]
207        Leading::Empty => Some(icon.unwrap_or_else(|| iced::widget::space().into())),
208        #[cfg(feature = "dropdown-menu")]
209        Leading::Check => Some(glyph(
210            crate::lucide!(Check),
211            colours,
212            |style| style.text,
213            opacity,
214        )),
215        #[cfg(feature = "dropdown-menu")]
216        Leading::Dot => Some(dot(colours)),
217    };
218    if let Some(leading) = leading {
219        content = content.push(container(leading).center_x(ICON_SIZE).center_y(ICON_SIZE));
220    }
221    content = content.push(
222        text(parts.label.to_owned())
223            .size(text_size::SM)
224            .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
225            .width(Length::Fill),
226    );
227    if let Some(hint) = parts.hint {
228        content = content.push(
229            text(hint.to_owned())
230                .size(text_size::XS)
231                .style(move |theme| text::Style {
232                    color: Some(colours(theme).hint),
233                }),
234        );
235    }
236    let trailing = match parts.trailing {
237        Trailing::None => None,
238        #[cfg(any(feature = "combobox", feature = "select"))]
239        Trailing::Check => Some(crate::lucide!(Check)),
240        #[cfg(feature = "dropdown-menu")]
241        Trailing::Chevron => Some(crate::lucide!(ChevronRight)),
242    };
243    if let Some(trailing) = trailing {
244        content = content.push(glyph(trailing, colours, |style| style.text, opacity));
245    }
246    content
247}
248
249/// The dot in front of a checked radio item.
250#[cfg(feature = "dropdown-menu")]
251fn dot<'a, Message: 'a>(colours: impl Fn(&Theme) -> RowStyle + 'a) -> Element<'a, Message> {
252    container(iced::widget::space())
253        .width(8)
254        .height(8)
255        .style(move |theme| container::Style {
256            background: Some(Background::Color(colours(theme).text)),
257            border: Border {
258                radius: radius::FULL.into(),
259                ..Border::default()
260            },
261            ..container::Style::default()
262        })
263        .into()
264}
265
266/// A full row: the content with its padding and background, highlighted
267/// when the pointer enters and activated when it is pressed. Without
268/// messages the row is inert.
269#[cfg(any(feature = "combobox", feature = "command", feature = "dropdown-menu"))]
270pub(crate) fn item<'a, Message: Clone + 'a>(
271    parts: Parts<'_>,
272    status: RowStatus,
273    destructive: bool,
274    messages: Option<(Message, Message)>,
275) -> Element<'a, Message> {
276    let body = container(content(parts, status, destructive))
277        .padding(ROW_PADDING)
278        .width(Length::Fill)
279        .style(move |theme| {
280            row_container_style(row_style(&Tokens::of(theme), status, destructive))
281        });
282    let Some((highlight, activate)) = messages else {
283        return inset(body);
284    };
285    inset(
286        iced::widget::mouse_area(body)
287            .on_enter(highlight)
288            .on_press(activate)
289            .interaction(iced::mouse::Interaction::Pointer),
290    )
291}
292
293/// A heading for the rows below it. `indented` lines it up with the labels
294/// of rows that have a check mark slot.
295#[cfg(any(feature = "command", feature = "dropdown-menu"))]
296pub(crate) fn group_label<'a, Message: 'a>(label: &str, indented: bool) -> Element<'a, Message> {
297    let left = ROW_PADDING.left + if indented { INDICATOR_WIDTH } else { 0.0 };
298    inset(
299        container(
300            text(label.to_owned())
301                .size(text_size::XS)
302                .line_height(LineHeight::Absolute(LABEL_LINE_HEIGHT.into()))
303                .font(crate::theme::semibold())
304                .style(|theme| label_style(&Tokens::of(theme))),
305        )
306        .padding(ROW_PADDING.left(left))
307        .width(Length::Fill),
308    )
309}
310
311/// A line between groups, running the full width of the surface.
312#[cfg(any(feature = "command", feature = "dropdown-menu"))]
313pub(crate) fn separator<'a, Message: 'a>() -> Element<'a, Message> {
314    container(
315        container(iced::widget::space())
316            .width(Length::Fill)
317            .height(1)
318            .style(|theme| separator_style(&Tokens::of(theme))),
319    )
320    .padding([space::XS, 0.0])
321    .into()
322}
323
324/// Muted text in place of rows, such as "No results found."
325#[cfg(any(feature = "combobox", feature = "command"))]
326pub(crate) fn empty<'a, Message: 'a>(message: &str) -> Element<'a, Message> {
327    container(
328        text(message.to_owned())
329            .size(text_size::SM)
330            .style(|theme| label_style(&Tokens::of(theme))),
331    )
332    .padding([space::XL, space::SM])
333    .center_x(Length::Fill)
334    .into()
335}
336
337/// Rows sit inside the surface's horizontal padding; separators do not, so
338/// they run the full width.
339pub(crate) fn inset<'a, Message: 'a>(
340    content: impl Into<Element<'a, Message>>,
341) -> Element<'a, Message> {
342    container(content)
343        .padding([0.0, space::XS])
344        .width(Length::Fill)
345        .into()
346}
347
348/// The floating surface around a list of rows, with its vertical padding.
349#[cfg(any(feature = "combobox", feature = "dropdown-menu", feature = "select"))]
350pub(crate) fn surface<'a, Message: 'a>(
351    rows: impl Into<Element<'a, Message>>,
352) -> container::Container<'a, Message> {
353    container(rows)
354        .padding([space::XS, 0.0])
355        .style(|theme| crate::overlay::anchored::surface_style(&Tokens::of(theme)))
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::theme::{dark, light};
362
363    #[test]
364    fn row_metrics_add_up_to_32_pixels() {
365        assert_eq!(ROW_PADDING.top + LINE_HEIGHT + ROW_PADDING.bottom, 32.0);
366        assert_eq!(ROW_PADDING.left, 8.0);
367        assert_eq!(ICON_SIZE, 16.0);
368    }
369
370    #[test]
371    fn highlighted_rows_use_the_accent_in_both_themes() {
372        for theme in [light(), dark()] {
373            let tokens = Tokens::of(&theme);
374            let idle = row_style(&tokens, RowStatus::Idle, false);
375            let highlighted = row_style(&tokens, RowStatus::Highlighted, false);
376            assert_eq!(idle.background, None);
377            assert_eq!(idle.text, tokens.foreground);
378            assert_eq!(highlighted.background, Some(tokens.accent));
379            assert_eq!(highlighted.text, tokens.accent_foreground);
380            assert_eq!(idle.icon, tokens.muted_foreground);
381            assert_eq!(idle.hint, tokens.muted_foreground);
382        }
383    }
384
385    #[test]
386    fn disabled_rows_are_muted_with_opaque_faded_icons() {
387        for theme in [light(), dark()] {
388            let tokens = Tokens::of(&theme);
389            for destructive in [false, true] {
390                let style = row_style(&tokens, RowStatus::Disabled, destructive);
391                assert_eq!(style.background, None);
392                assert_eq!(style.text, tokens.muted_foreground);
393                assert_eq!(style.icon.a, tokens.muted_foreground.a, "icons stay opaque");
394                assert!(style.hint.a < tokens.muted_foreground.a);
395            }
396        }
397    }
398
399    #[test]
400    fn destructive_rows_keep_their_colour_and_highlight_with_a_tint() {
401        for theme in [light(), dark()] {
402            let tokens = Tokens::of(&theme);
403            for status in [RowStatus::Idle, RowStatus::Highlighted] {
404                let style = row_style(&tokens, status, true);
405                assert_eq!(style.text, tokens.destructive);
406                assert_eq!(style.icon, tokens.destructive);
407            }
408            let tint = row_style(&tokens, RowStatus::Highlighted, true).background;
409            let expected = if tokens.is_dark { 0.2 } else { 0.1 };
410            assert!(tint.is_some_and(|colour| (colour.a - expected).abs() < 1e-6));
411        }
412    }
413
414    #[test]
415    fn every_status_resolves_distinctly() {
416        for theme in [light(), dark()] {
417            let tokens = Tokens::of(&theme);
418            for destructive in [false, true] {
419                let styles = RowStatus::ALL.map(|status| row_style(&tokens, status, destructive));
420                for (i, a) in styles.iter().enumerate() {
421                    assert!(styles[i + 1..].iter().all(|b| a != b));
422                }
423            }
424        }
425    }
426
427    #[test]
428    fn row_containers_carry_the_background_text_and_small_radius() {
429        let tokens = Tokens::of(&light());
430        let style = row_container_style(row_style(&tokens, RowStatus::Highlighted, false));
431        assert_eq!(style.background, Some(Background::Color(tokens.accent)));
432        assert_eq!(style.text_color, Some(tokens.accent_foreground));
433        assert_eq!(style.border.radius, radius::SM.into());
434        let idle = row_container_style(row_style(&tokens, RowStatus::Idle, false));
435        assert_eq!(idle.background, None);
436    }
437
438    #[test]
439    fn label_and_separator_styles() {
440        for theme in [light(), dark()] {
441            let tokens = Tokens::of(&theme);
442            assert_eq!(label_style(&tokens).color, Some(tokens.muted_foreground));
443            assert_eq!(
444                separator_style(&tokens).background,
445                Some(Background::Color(tokens.border))
446            );
447        }
448    }
449}