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 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 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 #[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 #[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 #[must_use]
57 pub fn gap() -> Self {
58 Self { gap: true, ..Self::plain(String::new()) }
59 }
60
61 #[must_use]
63 pub fn icon(mut self, key: impl Into<String>) -> Self {
64 self.icon = Some(key.into());
65 self
66 }
67
68 #[must_use]
71 pub fn shortcut(mut self, label: impl Into<String>) -> Self {
72 self.shortcut = Some(label.into());
73 self
74 }
75
76 #[must_use]
81 pub fn detail(mut self, text: impl Into<String>) -> Self {
82 self.detail = Some(text.into());
83 self
84 }
85
86 #[must_use]
88 pub fn disabled(mut self, disabled: bool) -> Self {
89 self.disabled = disabled;
90 self
91 }
92
93 #[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
117pub(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
148const MIN_DETAIL: u16 = 4;
151
152fn 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
164pub(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 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 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
189pub(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
201pub(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
206pub(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
215pub(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 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 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}