1use super::actions::DrawingToolbarAction;
31use crate::icons::{Icon, icons};
32use crate::localization as l10n;
33use crate::styles::{focus::focus_ring_color, icons as icon_sizes};
34use crate::theme::ThemeExt;
35use crate::tokens::DESIGN_TOKENS;
36use crate::ui_kit::ext::UiExt;
37use egui::{
38 Align2, FontId, Frame, Id, Key, Margin, Modifiers, Popup, PopupCloseBehavior, Pos2, Rect,
39 RectAlign, Response, Role, ScrollArea, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo,
40};
41
42const ROW_HEIGHT: f32 = 28.0;
44
45pub struct SubmenuBuilder<'a> {
51 ui: &'a mut Ui,
52 items: Vec<SubmenuItem>,
53 width: f32,
54 toggle: Option<ToggleConfig<'a>>,
55}
56
57#[derive(Default)]
59pub struct SubmenuItem {
60 pub icon: Option<&'static Icon>,
62 pub label: String,
64 pub tooltip: String,
66 pub is_sel: bool,
68 pub is_favorite: bool,
70 pub is_checked: bool,
72 pub is_danger: bool,
74 pub on_click: Option<Box<dyn Fn() -> Option<DrawingToolbarAction>>>,
76}
77
78pub struct ToggleConfig<'a> {
80 pub label: String,
82 pub value: &'a mut bool,
84}
85
86impl<'a> SubmenuBuilder<'a> {
87 pub fn new(ui: &'a mut Ui) -> Self {
93 Self {
94 ui,
95 items: Vec::new(),
96 width: DESIGN_TOKENS.sizing.drawing_toolbar_ext.submenu_width_xl,
97 toggle: None,
98 }
99 }
100
101 pub fn with_width(mut self, width: f32) -> Self {
103 self.width = width;
104 self
105 }
106
107 pub fn add_icon_item(
109 mut self,
110 icon: &'static Icon,
111 label: impl Into<String>,
112 tooltip: impl Into<String>,
113 ) -> Self {
114 self.items.push(SubmenuItem {
115 icon: Some(icon),
116 label: label.into(),
117 tooltip: tooltip.into(),
118 ..Default::default()
119 });
120 self
121 }
122
123 pub fn add_text_item(mut self, label: impl Into<String>, tooltip: impl Into<String>) -> Self {
125 self.items.push(SubmenuItem {
126 label: label.into(),
127 tooltip: tooltip.into(),
128 ..Default::default()
129 });
130 self
131 }
132
133 pub fn add_text_item_with_action<F>(
135 mut self,
136 label: impl Into<String>,
137 tooltip: impl Into<String>,
138 action: F,
139 ) -> Self
140 where
141 F: Fn() -> Option<DrawingToolbarAction> + 'static,
142 {
143 self.items.push(SubmenuItem {
144 label: label.into(),
145 tooltip: tooltip.into(),
146 on_click: Some(Box::new(action)),
147 ..Default::default()
148 });
149 self
150 }
151
152 #[must_use]
154 pub(super) fn icon(mut self, icon: &'static Icon) -> Self {
155 if let Some(item) = self.items.last_mut() {
156 item.icon = Some(icon);
157 }
158 self
159 }
160
161 #[must_use]
163 pub fn checked(mut self, checked: bool) -> Self {
164 if let Some(item) = self.items.last_mut() {
165 item.is_checked = checked;
166 }
167 self
168 }
169
170 #[must_use]
172 pub fn danger(mut self, icon: &'static Icon) -> Self {
173 if let Some(item) = self.items.last_mut() {
174 item.icon = Some(icon);
175 item.is_danger = true;
176 }
177 self
178 }
179
180 pub fn add_toggle(mut self, config: ToggleConfig<'a>) -> Self {
182 self.toggle = Some(config);
183 self
184 }
185
186 pub fn show(self) -> Option<DrawingToolbarAction> {
188 let Self {
189 ui,
190 items,
191 width,
192 mut toggle,
193 } = self;
194
195 let rows: Vec<FlyoutRow> = items.iter().map(FlyoutRow::from).collect();
196 let width = rows
197 .iter()
198 .map(|row| row_width(ui, row))
199 .fold(width, f32::max);
200 ui.set_min_width(width);
201
202 let mut result = None;
203 for (item, row) in items.iter().zip(rows) {
204 let response = flyout_row(ui, width, &row).row;
205 if response.clicked()
206 && let Some(on_click) = &item.on_click
207 {
208 result = on_click();
209 }
210 response.on_hover_text(l10n::text(ui.ctx(), &item.tooltip));
211 }
212
213 if let Some(toggle) = &mut toggle {
214 ui.spaced_separator();
215 ui.checkbox(toggle.value, l10n::text(ui.ctx(), &toggle.label));
216 }
217
218 result
219 }
220}
221
222#[derive(Default)]
224pub(super) struct FlyoutRow<'a> {
225 pub icon: Option<&'a Icon>,
226 pub label: &'a str,
227 pub shortcut: Option<&'a str>,
229 pub selected: bool,
231 pub check: bool,
233 pub danger: bool,
234 pub star: Option<bool>,
237}
238
239impl<'a> From<&'a SubmenuItem> for FlyoutRow<'a> {
240 fn from(item: &'a SubmenuItem) -> Self {
241 Self {
242 icon: item.icon,
243 label: &item.label,
244 selected: item.is_sel,
245 check: item.is_checked,
246 danger: item.is_danger,
247 ..Default::default()
248 }
249 }
250}
251
252pub(super) struct RowResponse {
254 pub row: Response,
255 pub star: Option<Response>,
256}
257
258fn gap(ui: &Ui) -> f32 {
260 ui.active_theme().spacing.lg
261}
262
263pub(super) fn row_width(ui: &Ui, row: &FlyoutRow) -> f32 {
266 let gap = gap(ui);
267 let theme = ui.active_theme();
268 let text_width = |text: &str, size: f32| {
269 ui.painter()
270 .layout_no_wrap(
271 l10n::text(ui.ctx(), text),
272 FontId::proportional(size),
273 theme.ui.text,
274 )
275 .size()
276 .x
277 };
278 let icon = row.icon.map_or(0.0, |_| icon_sizes::SM + gap);
279 let shortcut = row
280 .shortcut
281 .map_or(0.0, |s| gap + text_width(s, theme.text.small));
282 gap + icon + text_width(row.label, theme.text.body) + shortcut + gap + icon_sizes::SM + gap
283}
284
285pub(super) fn flyout_row(ui: &mut Ui, width: f32, row: &FlyoutRow) -> RowResponse {
287 let theme = ui.active_theme();
288 let gap = gap(ui);
289 let (rect, row_response) = ui.allocate_exact_size(Vec2::new(width, ROW_HEIGHT), Sense::click());
290 let translated_label = l10n::text(ui.ctx(), row.label);
291 row_response.widget_info(|| {
292 WidgetInfo::selected(
293 Role::MenuItem,
294 row_response.enabled(),
295 row.selected || row.check,
296 &translated_label,
297 )
298 });
299
300 let slot = |right_edge: f32| {
301 Rect::from_center_size(
302 Pos2::new(right_edge - icon_sizes::SM / 2.0, rect.center().y),
303 Vec2::splat(icon_sizes::SM),
304 )
305 };
306 let right_slot = slot(rect.right() - gap);
307 let star_response = row.star.map(|favorite| {
309 let response = ui.interact(right_slot, row_response.id.with("star"), Sense::click());
310 let translated_label =
311 l10n::format_text(ui.ctx(), "Favourite {0}", &[("0", row.label.to_string())]);
312 response.widget_info(|| {
313 WidgetInfo::selected(
314 Role::Button,
315 response.enabled(),
316 favorite,
317 &translated_label,
318 )
319 });
320 response
321 });
322 let hovered = row_response.hovered() || star_response.as_ref().is_some_and(Response::hovered);
323
324 if ui.is_rect_visible(rect) {
325 let radius = ui.active_theme().radius.sm;
326 let painter = ui.painter();
327 let fill = match (row.selected, hovered) {
328 (true, true) => Some(theme.ui.accent_hover),
329 (true, false) => Some(theme.ui.accent),
330 (false, true) => Some(ui.visuals().widgets.hovered.bg_fill),
331 (false, false) => None,
332 };
333 if let Some(fill) = fill {
334 painter.rect_filled(rect, radius, fill);
335 }
336 let (text, secondary) = if row.selected {
337 (theme.ui.accent_text, theme.ui.accent_text)
338 } else if row.danger {
339 (theme.ui.error, theme.ui.error)
340 } else {
341 (theme.ui.text, theme.ui.text_secondary)
342 };
343
344 let mut x = rect.left() + gap;
345 if let Some(icon) = row.icon {
346 let icon_rect = Rect::from_min_size(
347 Pos2::new(x, rect.center().y - icon_sizes::SM / 2.0),
348 Vec2::splat(icon_sizes::SM),
349 );
350 icon.as_image_tinted(icon_rect.size(), text)
351 .paint_at(ui, icon_rect);
352 x += icon_sizes::SM + gap;
353 }
354 ui.painter().text(
355 Pos2::new(x, rect.center().y),
356 Align2::LEFT_CENTER,
357 l10n::text(ui.ctx(), row.label),
358 FontId::proportional(theme.text.body),
359 text,
360 );
361 if let Some(shortcut) = row.shortcut {
362 ui.painter().text(
363 Pos2::new(right_slot.left() - gap, rect.center().y),
364 Align2::RIGHT_CENTER,
365 l10n::text(ui.ctx(), shortcut),
366 FontId::proportional(theme.text.small),
367 secondary,
368 );
369 }
370 match row.star {
371 Some(true) => star(ui, &icons::STAR_FILLED, right_slot, theme.ui.warning, row),
372 Some(false) if hovered => {
373 star(ui, &icons::STAR_EMPTY, right_slot, secondary, row);
374 }
375 _ if row.check => {
376 icons::CHECK
377 .as_image_tinted(right_slot.size(), text)
378 .paint_at(ui, right_slot);
379 }
380 _ => {}
381 }
382 if row_response.has_focus() {
383 let (ring, ring_rect) = if row.selected {
384 (
385 theme.ui.accent_text,
386 rect.shrink(DESIGN_TOKENS.stroke.thick),
387 )
388 } else {
389 (focus_ring_color(ui), rect)
390 };
391 ui.painter().rect_stroke(
392 ring_rect,
393 radius,
394 Stroke::new(DESIGN_TOKENS.stroke.thick, ring),
395 StrokeKind::Inside,
396 );
397 }
398 }
399
400 RowResponse {
401 row: row_response,
402 star: star_response,
403 }
404}
405
406fn star(ui: &Ui, icon: &Icon, rect: Rect, color: egui::Color32, row: &FlyoutRow) {
409 let color = if row.selected {
410 ui.active_theme().ui.accent_text
411 } else {
412 color
413 };
414 icon.as_image_tinted(rect.size(), color).paint_at(ui, rect);
415}
416
417pub(super) fn section_header(ui: &mut Ui, text: &str, width: f32) {
419 let theme = ui.active_theme();
420 let gap = gap(ui);
421 let (rect, _) = ui.allocate_exact_size(
422 Vec2::new(width, gap + theme.text.small + gap / 2.0),
423 Sense::hover(),
424 );
425 ui.painter().text(
426 Pos2::new(rect.left() + gap, rect.bottom() - gap / 2.0),
427 Align2::LEFT_BOTTOM,
428 l10n::text(ui.ctx(), text),
429 FontId::proportional(theme.text.small),
430 theme.ui.text_secondary,
431 );
432}
433
434pub(super) fn flyout_id(category: &str) -> Id {
436 Id::unique(("drawing_toolbar_flyout", category))
437}
438
439pub(super) fn show_flyout<R>(
444 ui: &Ui,
445 category: &str,
446 anchor: Rect,
447 content: impl FnOnce(&mut Ui) -> R,
448) -> Option<R> {
449 let ctx = ui.ctx();
450 let lg = ui.active_theme().spacing.lg;
451 let window = ctx.content_rect();
452 let max_height = window.height() - 2.0 * lg - ui.style().spacing.menu_margin.sum().y;
453 let popup = Popup::new(flyout_id(category), ctx.clone(), anchor, ui.layer_id())
454 .open_memory(None)
455 .align(RectAlign::RIGHT_START)
456 .align_alternatives(&[])
457 .gap(ui.active_theme().spacing.sm)
458 .frame(Frame::menu(ui.style()).inner_margin(Margin::symmetric(
460 ui.active_theme().spacing.md as i8,
461 ui.active_theme().spacing.sm as i8,
462 )))
463 .close_behavior(PopupCloseBehavior::CloseOnClickOutside);
464 let height = popup.get_expected_size().map_or(0.0, |size| size.y);
466 let top = anchor
467 .min
468 .y
469 .min(window.max.y - lg - height)
470 .max(window.min.y + lg);
471 let shown = popup
472 .anchor(anchor.translate(Vec2::new(0.0, top - anchor.min.y)))
473 .show(|ui| {
474 ui.spacing_mut().item_spacing = Vec2::ZERO;
476 ui.set_max_height(max_height);
478 ScrollArea::vertical()
479 .max_height(max_height)
480 .show(ui, content)
481 .inner
482 })
483 .map(|response| response.inner);
484 if !Popup::is_id_open(ctx, flyout_id(category)) {
486 ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape));
487 }
488 shown
489}