1use crate::geometry::{Rect, Size, clamp_u16};
4use crate::text;
5use crate::theme::State;
6use crate::widget::PaintCx;
7
8use super::cells;
9
10#[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 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 #[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 #[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 #[must_use]
55 pub fn gap() -> Self {
56 Self { gap: true, ..Self::plain(String::new()) }
57 }
58
59 #[must_use]
61 pub fn icon(mut self, key: impl Into<String>) -> Self {
62 self.icon = Some(key.into());
63 self
64 }
65
66 #[must_use]
69 pub fn shortcut(mut self, label: impl Into<String>) -> Self {
70 self.shortcut = Some(label.into());
71 self
72 }
73
74 #[must_use]
76 pub fn disabled(mut self, disabled: bool) -> Self {
77 self.disabled = disabled;
78 self
79 }
80
81 #[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
105pub(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
135fn 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
147pub(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 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
166pub(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
178pub(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
183pub(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
192pub(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 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}