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