Skip to main content

qframe/widgets/
context_item.rs

1//! Context menu items and the rows that draw them.
2
3use crate::geometry::{Rect, Size, clamp_u16};
4use crate::text;
5use crate::theme::State;
6use crate::widget::PaintCx;
7
8use super::cells;
9
10/// One row of a menu: an action, a submenu or a gap between groups.
11///
12/// Style keys: `context-menu` (`bg`), `context-item` with `hover` and `disabled`,
13/// `context-item.danger`, `context-item-shortcut`, `context-item-chevron`.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ContextItem<Msg> {
16    label: String,
17    icon: Option<String>,
18    shortcut: Option<String>,
19    message: Option<Msg>,
20    submenu: Vec<ContextItem<Msg>>,
21    disabled: bool,
22    danger: bool,
23    gap: bool,
24}
25
26impl<Msg> ContextItem<Msg> {
27    /// A row labelled `label` with no message, submenu or option yet.
28    fn plain(label: String) -> Self {
29        Self {
30            label,
31            icon: None,
32            shortcut: None,
33            message: None,
34            submenu: Vec::new(),
35            disabled: false,
36            danger: false,
37            gap: false,
38        }
39    }
40
41    /// An action labelled `label` that sends `message` when chosen.
42    #[must_use]
43    pub fn new(label: impl Into<String>, message: Msg) -> Self {
44        Self { message: Some(message), ..Self::plain(label.into()) }
45    }
46
47    /// A row that opens `items` beside the menu.
48    #[must_use]
49    pub fn submenu(label: impl Into<String>, items: impl IntoIterator<Item = Self>) -> Self {
50        Self { submenu: items.into_iter().collect(), ..Self::plain(label.into()) }
51    }
52
53    /// An empty row that separates groups; menus never draw lines.
54    #[must_use]
55    pub fn gap() -> Self {
56        Self { gap: true, ..Self::plain(String::new()) }
57    }
58
59    /// Icon key drawn before the label.
60    #[must_use]
61    pub fn icon(mut self, key: impl Into<String>) -> Self {
62        self.icon = Some(key.into());
63        self
64    }
65
66    /// Faint key label kept at the right edge, e.g. `"ctrl r"`. It only describes the key; bind
67    /// the key itself in the keymap.
68    #[must_use]
69    pub fn shortcut(mut self, label: impl Into<String>) -> Self {
70        self.shortcut = Some(label.into());
71        self
72    }
73
74    /// Greys the row out; it cannot be chosen or highlighted.
75    #[must_use]
76    pub fn disabled(mut self, disabled: bool) -> Self {
77        self.disabled = disabled;
78        self
79    }
80
81    /// Draws the row in the danger colour, for destructive actions such as delete.
82    #[must_use]
83    pub fn danger(mut self, danger: bool) -> Self {
84        self.danger = danger;
85        self
86    }
87
88    pub(crate) fn selectable(&self) -> bool {
89        !self.gap && !self.disabled
90    }
91
92    pub(crate) fn children(&self) -> &[Self] {
93        &self.submenu
94    }
95
96    pub(crate) fn has_submenu(&self) -> bool {
97        !self.submenu.is_empty()
98    }
99
100    pub(crate) fn message(&self) -> Option<&Msg> {
101        self.message.as_ref()
102    }
103}
104
105/// Replaces the message of every row in `items`, submenus included, with its position in the
106/// returned list of messages. A widget can run a menu on the positions and send the chosen message
107/// by value, so its application's messages need not be `Clone`.
108pub(crate) fn keyed<Msg>(items: Vec<ContextItem<Msg>>) -> (Vec<ContextItem<usize>>, Vec<Msg>) {
109    fn key<Msg>(items: Vec<ContextItem<Msg>>, messages: &mut Vec<Msg>) -> Vec<ContextItem<usize>> {
110        items
111            .into_iter()
112            .map(|item| {
113                let message = item.message.map(|message| {
114                    messages.push(message);
115                    messages.len() - 1
116                });
117                ContextItem {
118                    label: item.label,
119                    icon: item.icon,
120                    shortcut: item.shortcut,
121                    message,
122                    submenu: key(item.submenu, messages),
123                    disabled: item.disabled,
124                    danger: item.danger,
125                    gap: item.gap,
126                }
127            })
128            .collect()
129    }
130    let mut messages = Vec::new();
131    let items = key(items, &mut messages);
132    (items, messages)
133}
134
135/// Cells of the icon column of `items`: the widest icon and a space, or nothing when no row has
136/// an icon. Labels line up after it, so rows without an icon keep the column empty.
137fn icon_column<Msg>(cx: &PaintCx<'_>, items: &[ContextItem<Msg>]) -> u16 {
138    let icons = cx.env().icons();
139    items
140        .iter()
141        .filter_map(|item| item.icon.as_deref())
142        .map(|key| text::width(&icons.glyph(key)).saturating_add(1))
143        .max()
144        .unwrap_or(0)
145}
146
147/// The size of a menu layer showing `items`.
148pub(crate) fn size<Msg>(cx: &PaintCx<'_>, items: &[ContextItem<Msg>]) -> Size {
149    let icons = cx.env().icons();
150    let lead =
151        cells::sum([items.iter().map(|item| text::width(&item.label)).max().unwrap_or(0), icon_column(cx, items)]);
152    let trail = items
153        .iter()
154        .map(|item| {
155            let shortcut = item.shortcut.as_deref().map_or(0, text::width);
156            let chevron = if item.has_submenu() { text::width(&icons.glyph("chevron-right")) } else { 0 };
157            shortcut.max(chevron)
158        })
159        .max()
160        .unwrap_or(0);
161    // Pillar and space, the label with its spare slide cell, a gap, the trailing column, a margin.
162    let width = cells::sum([2, lead, 1, if trail > 0 { trail.saturating_add(3) } else { 2 }, 2]);
163    Size::new(width.max(18), clamp_u16(i32::try_from(items.len()).unwrap_or(i32::MAX)))
164}
165
166/// The next selectable row after `from` in direction `step`, wrapping around.
167pub(crate) fn step<Msg>(items: &[ContextItem<Msg>], from: Option<usize>, forward: bool) -> Option<usize> {
168    let len = items.len();
169    if len == 0 {
170        return None;
171    }
172    let start = from.unwrap_or(if forward { len - 1 } else { 0 });
173    (1..=len)
174        .map(|offset| if forward { (start + offset) % len } else { (start + len * 2 - offset) % len })
175        .find(|index| items[*index].selectable())
176}
177
178/// The first or last selectable row.
179pub(crate) fn edge<Msg>(items: &[ContextItem<Msg>], last: bool) -> Option<usize> {
180    if last { items.iter().rposition(ContextItem::selectable) } else { items.iter().position(ContextItem::selectable) }
181}
182
183/// The next selectable row after `from` whose label starts with `typed`.
184pub(crate) fn type_ahead<Msg>(items: &[ContextItem<Msg>], from: Option<usize>, typed: char) -> Option<usize> {
185    let start = from.unwrap_or(items.len().saturating_sub(1));
186    super::popup_menu::type_ahead_by(items.len(), start, typed, |index| {
187        let item = &items[index];
188        item.selectable().then_some(item.label.as_str())
189    })
190}
191
192/// Draws `items` into `rect` (already the visible part of the layer at `full`), with row
193/// `highlight` raised.
194pub(crate) fn paint<Msg>(
195    cx: &mut PaintCx<'_>,
196    items: &[ContextItem<Msg>],
197    full: Rect,
198    shown: Rect,
199    highlight: Option<usize>,
200) {
201    let background = cx.style("context-menu", None, &[]).text().bg.unwrap_or_else(|| cx.color("overlay"));
202    let grounds = cx.grounds_around(shown);
203    cx.clear(shown, background);
204    cx.register_hit(shown);
205    let slide = cx.env().slide();
206    let chevron = cx.env().icons().glyph("chevron-right").into_owned();
207    let icon_column = icon_column(cx, items);
208    cx.with_clip(shown, |cx| {
209        for (row, item) in items.iter().enumerate().take(usize::from(full.height)) {
210            if item.gap {
211                continue;
212            }
213            let rect = full.row(clamp_u16(i32::try_from(row).unwrap_or(i32::MAX)));
214            let mut states = Vec::new();
215            if item.disabled {
216                states.push(State::Disabled);
217            } else if highlight == Some(row) {
218                states.push(State::Hover);
219            }
220            let variant = item.danger.then_some("danger");
221            let style = cx.style("context-item", variant, &states);
222            let text_style = style.text();
223            if let Some(bg) = text_style.bg {
224                cx.fill(rect, bg);
225            }
226            if let Some(color) = style.color("pillar") {
227                cx.pillar(rect.x, rect.y, color);
228            }
229
230            let right = rect.right() - 2;
231            let mut trail_x = right;
232            if let Some(shortcut) = &item.shortcut {
233                let width = text::width(shortcut);
234                trail_x = right - i32::from(width);
235                let shortcut_style = cx.style("context-item-shortcut", None, &states).text();
236                cx.text(trail_x, rect.y, shortcut, shortcut_style, width);
237            } else if item.has_submenu() {
238                let width = text::width(&chevron);
239                trail_x = right - i32::from(width);
240                let chevron_style = cx.style("context-item-chevron", None, &states).text();
241                cx.text(trail_x, rect.y, &chevron, chevron_style, width);
242            }
243
244            let shift = i32::from(slide && states.contains(&State::Hover));
245            let mut x = rect.x + 2 + shift;
246            // The label column keeps one spare cell for the slide and stops short of the trail.
247            let limit = trail_x - 2;
248            let mut label_style = text_style;
249            label_style.bg = None;
250            if let Some(icon) = &item.icon {
251                let glyph = cx.env().icons().glyph(icon).into_owned();
252                cx.text(x, rect.y, &glyph, label_style, clamp_u16(limit - x));
253            }
254            x += i32::from(icon_column);
255            let budget = clamp_u16(limit - x);
256            let label = text::truncate(&item.label, budget).into_owned();
257            cx.text(x, rect.y, &label, label_style, budget);
258        }
259    });
260    cx.stand_apart(shown, &grounds, Some(background));
261}