1use super::ContextMenuAppearance;
2use crate::bindings::ui;
3use crate::event::{self, PointerButton, PointerEventArgs, PointerType};
4use crate::ffi::{
5 BorderStyle, CursorStyle, HandleValue, KeyEventType, SemanticRole, TextAlign, TextOverflow,
6 Unit,
7};
8use crate::logger::warn;
9use crate::navigation;
10use crate::node::{
11 column, flex_box, grid, portal, Border, BoxStyleSurface, FlexBox, Grid, GridTrack,
12 LayoutSurface, Node, NodeRef, TextNode, ThemeBindable, WeakFlexBox,
13};
14use crate::popup_presenter::{PopupPresenter, PopupPresenterEventTarget};
15use crate::theme::{current_theme, subscribe, Theme};
16use crate::{FontFamily, FontStyle, FontWeight};
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19
20const MENU_WIDTH: f32 = 220.0;
21const MENU_SEPARATOR_HEIGHT: f32 = 9.0;
22const MENU_EDGE_PADDING: f32 = 8.0;
23const MAX_ITEMS: usize = 25;
24const DEFAULT_PANEL_BACKGROUND_BLUR_SIGMA: f32 = 10.0;
25const SHORTCUT_SHARED_SIZE_GROUP: &str = "ContextMenuShortcutColumn";
26
27thread_local! {
28 static ACTIVE_CONTEXT_MENU: RefCell<Option<ContextMenuEventTarget>> = const { RefCell::new(None) };
29}
30
31fn is_primary_activation_pointer(event: &PointerEventArgs) -> bool {
32 event.button == PointerButton::Primary
33 || event.pointer_type == PointerType::Touch
34 || event.pointer_type == PointerType::Pen
35}
36
37fn write_payload_to_clipboard(text: &str) {
38 let bytes = text.as_bytes();
39 unsafe {
40 crate::ffi::fui_copy_text(
41 if bytes.is_empty() {
42 0
43 } else {
44 bytes.as_ptr() as usize
45 },
46 bytes.len() as u32,
47 )
48 }
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52enum MenuItemKind {
53 Action,
54 Separator,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum ContextMenuAction {
59 CopyCurrentSelection = 0,
60 ReloadPage = 1,
61 OpenLink = 2,
62 OpenLinkInNewTab = 3,
63 NavigateBack = 4,
64 NavigateForward = 5,
65 UndoTextEdit = 6,
66 RedoTextEdit = 7,
67 CutTextSelection = 8,
68 PasteText = 9,
69 SelectAllText = 10,
70 OpenImage = 11,
71 OpenImageInNewTab = 12,
72}
73
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct ContextMenuVisibilityChangedEventArgs {
76 pub visible: bool,
77}
78
79type MenuInvokeCallback = Rc<dyn Fn()>;
80type VisibilityChangedCallback = Rc<dyn Fn(ContextMenuVisibilityChangedEventArgs)>;
81
82#[derive(Clone)]
83pub struct MenuItem {
84 pub label: String,
85 pub action: ContextMenuAction,
86 pub payload: Option<String>,
87 pub shortcut_label: Option<String>,
88 pub disabled: bool,
89 target_handle: u64,
90 selection_start: u32,
91 selection_end: u32,
92 focus_target_after_action: bool,
93 on_invoke: Option<MenuInvokeCallback>,
94 kind: MenuItemKind,
95}
96
97impl MenuItem {
98 pub fn new(label: impl Into<String>, action: ContextMenuAction) -> Self {
99 Self {
100 label: label.into(),
101 action,
102 payload: None,
103 shortcut_label: None,
104 disabled: false,
105 target_handle: HandleValue::Invalid as u64,
106 selection_start: 0,
107 selection_end: 0,
108 focus_target_after_action: false,
109 on_invoke: None,
110 kind: MenuItemKind::Action,
111 }
112 }
113
114 pub fn separator() -> Self {
115 Self {
116 label: String::new(),
117 action: ContextMenuAction::ReloadPage,
118 payload: None,
119 shortcut_label: None,
120 disabled: false,
121 target_handle: HandleValue::Invalid as u64,
122 selection_start: 0,
123 selection_end: 0,
124 focus_target_after_action: false,
125 on_invoke: None,
126 kind: MenuItemKind::Separator,
127 }
128 }
129
130 pub fn payload(&self, value: impl Into<String>) -> Self {
131 let mut next = self.clone();
132 next.payload = Some(value.into());
133 next
134 }
135
136 pub fn shortcut_label(&self, value: impl Into<String>) -> Self {
137 let mut next = self.clone();
138 next.shortcut_label = Some(value.into());
139 next
140 }
141
142 pub fn disabled(&self, flag: bool) -> Self {
143 let mut next = self.clone();
144 next.disabled = flag;
145 next
146 }
147
148 pub fn target<T: Node>(&self, target: &T) -> Self {
149 let mut next = self.clone();
150 next.target_handle = target.handle().raw();
151 next
152 }
153
154 pub(crate) fn target_handle(&self, target_handle: u64) -> Self {
155 let mut next = self.clone();
156 next.target_handle = target_handle;
157 next
158 }
159
160 pub fn with_selection_range(&self, start: u32, end: u32) -> Self {
161 let mut next = self.clone();
162 next.selection_start = start;
163 next.selection_end = end;
164 next
165 }
166
167 pub fn focus_target_after_action(&self, flag: bool) -> Self {
168 let mut next = self.clone();
169 next.focus_target_after_action = flag;
170 next
171 }
172
173 pub fn on_invoke(&self, callback: impl Fn() + 'static) -> Self {
174 let mut next = self.clone();
175 next.on_invoke = Some(Rc::new(callback));
176 next
177 }
178
179 pub fn is_separator(&self) -> bool {
180 self.kind == MenuItemKind::Separator
181 }
182}
183
184fn commit_focused_text_action_if_needed(item: &MenuItem) {
185 if item.focus_target_after_action && item.target_handle != HandleValue::Invalid as u64 {
186 unsafe { crate::ffi::fui_commit_text_action_focus(item.target_handle) };
187 }
188}
189
190pub fn run_context_menu_action(item: &MenuItem) {
191 let host = crate::platform::host_context();
192 let unsupported = match item.action {
193 ContextMenuAction::NavigateBack | ContextMenuAction::NavigateForward => {
194 !host.supports(crate::platform::HostCapability::BrowserHistory)
195 }
196 ContextMenuAction::ReloadPage => !host.supports(crate::platform::HostCapability::Reload),
197 ContextMenuAction::OpenLinkInNewTab | ContextMenuAction::OpenImageInNewTab => {
198 !host.supports(crate::platform::HostCapability::NewBrowsingContext)
199 }
200 ContextMenuAction::OpenLink | ContextMenuAction::OpenImage => {
201 !host.supports(crate::platform::HostCapability::OpenExternalUri)
202 }
203 ContextMenuAction::CopyCurrentSelection | ContextMenuAction::CutTextSelection => {
204 !host.supports(crate::platform::HostCapability::ClipboardWrite)
205 }
206 ContextMenuAction::PasteText => {
207 !host.supports(crate::platform::HostCapability::ClipboardRead)
208 }
209 _ => false,
210 };
211 if unsupported {
212 return;
213 }
214 if item.disabled {
215 let action_needs_live_selection = item.target_handle != HandleValue::Invalid as u64
216 && matches!(
217 item.action,
218 ContextMenuAction::CopyCurrentSelection | ContextMenuAction::CutTextSelection
219 )
220 && (unsafe { crate::ffi::fui_has_text_selection_snapshot(item.target_handle) }
221 || unsafe { crate::ffi::ui_has_text_selection(item.target_handle) });
222 if !action_needs_live_selection {
223 return;
224 }
225 }
226
227 if let Some(callback) = item.on_invoke.as_ref() {
228 callback();
229 }
230
231 match item.action {
232 ContextMenuAction::CopyCurrentSelection => {
233 if let Some(payload) = item.payload.as_ref() {
234 write_payload_to_clipboard(payload);
235 commit_focused_text_action_if_needed(item);
236 return;
237 }
238 if item.target_handle != HandleValue::Invalid as u64
239 && unsafe { crate::ffi::fui_copy_text_selection_snapshot(item.target_handle) }
240 {
241 commit_focused_text_action_if_needed(item);
242 return;
243 }
244 if item.target_handle != HandleValue::Invalid as u64 {
245 unsafe { crate::ffi::ui_copy_text_selection(item.target_handle) };
246 commit_focused_text_action_if_needed(item);
247 return;
248 }
249 unsafe { crate::ffi::ui_copy_current_selection() };
250 }
251 ContextMenuAction::UndoTextEdit if item.target_handle != HandleValue::Invalid as u64 => {
252 unsafe { crate::ffi::ui_undo_text_edit(item.target_handle) };
253 commit_focused_text_action_if_needed(item);
254 }
255 ContextMenuAction::RedoTextEdit if item.target_handle != HandleValue::Invalid as u64 => {
256 unsafe { crate::ffi::ui_redo_text_edit(item.target_handle) };
257 commit_focused_text_action_if_needed(item);
258 }
259 ContextMenuAction::CutTextSelection
260 if item.target_handle != HandleValue::Invalid as u64 =>
261 {
262 if let Some(payload) = item.payload.as_ref() {
263 write_payload_to_clipboard(payload);
264 }
265 if item.selection_start != item.selection_end
266 && unsafe {
267 crate::ffi::fui_cut_text_range_snapshot(
268 item.target_handle,
269 item.selection_start,
270 item.selection_end,
271 )
272 }
273 {
274 commit_focused_text_action_if_needed(item);
275 return;
276 }
277 if unsafe { crate::ffi::fui_cut_text_selection_snapshot(item.target_handle) } {
278 commit_focused_text_action_if_needed(item);
279 return;
280 }
281 if item.selection_start != item.selection_end
282 && unsafe {
283 crate::ffi::fui_delete_focused_text_range(
284 item.selection_start,
285 item.selection_end,
286 )
287 }
288 {
289 commit_focused_text_action_if_needed(item);
290 return;
291 }
292 if unsafe { crate::ffi::fui_cut_focused_text_selection() } {
293 commit_focused_text_action_if_needed(item);
294 return;
295 }
296 if item.payload.is_none() {
297 unsafe { crate::ffi::fui_copy_text_selection_snapshot(item.target_handle) };
298 }
299 unsafe { crate::ffi::ui_cut_text_selection(item.target_handle) };
300 commit_focused_text_action_if_needed(item);
301 }
302 ContextMenuAction::PasteText if item.target_handle != HandleValue::Invalid as u64 => {
303 unsafe { crate::ffi::ui_paste_text(item.target_handle) };
304 commit_focused_text_action_if_needed(item);
305 }
306 ContextMenuAction::SelectAllText if item.target_handle != HandleValue::Invalid as u64 => {
307 unsafe { crate::ffi::ui_select_all_text(item.target_handle) };
308 commit_focused_text_action_if_needed(item);
309 }
310 ContextMenuAction::ReloadPage => unsafe { crate::ffi::fui_reload_page() },
311 ContextMenuAction::OpenLink | ContextMenuAction::OpenImage => {
312 if let Some(payload) = item.payload.as_ref() {
313 navigation::navigate_to(payload, false);
314 }
315 }
316 ContextMenuAction::OpenLinkInNewTab | ContextMenuAction::OpenImageInNewTab => {
317 if let Some(payload) = item.payload.as_ref() {
318 navigation::navigate_to(payload, true);
319 }
320 }
321 ContextMenuAction::NavigateBack => navigation::navigate_back(),
322 ContextMenuAction::NavigateForward => navigation::navigate_forward(),
323 _ => {}
324 }
325}
326
327#[derive(Clone)]
328struct ContextMenuEntryStyle {
329 item_height: f32,
330 padding_left: f32,
331 padding_top: f32,
332 padding_right: f32,
333 padding_bottom: f32,
334 corner_top_left: f32,
335 corner_top_right: f32,
336 corner_bottom_right: f32,
337 corner_bottom_left: f32,
338 text_color: u32,
339 background_color: u32,
340 hover_background_color: u32,
341 font_family: FontFamily,
342 font_weight: FontWeight,
343 font_style: FontStyle,
344 font_size: f32,
345}
346
347impl ContextMenuEntryStyle {
348 fn from_theme(theme: &Theme) -> Self {
349 Self {
350 item_height: theme.context_menu.item.height,
351 padding_left: theme.context_menu.item.padding_left,
352 padding_top: theme.context_menu.item.padding_top,
353 padding_right: theme.context_menu.item.padding_right,
354 padding_bottom: theme.context_menu.item.padding_bottom,
355 corner_top_left: theme.context_menu.item.corner_radius,
356 corner_top_right: theme.context_menu.item.corner_radius,
357 corner_bottom_right: theme.context_menu.item.corner_radius,
358 corner_bottom_left: theme.context_menu.item.corner_radius,
359 text_color: theme.context_menu.item.text_color,
360 background_color: theme.context_menu.item.background,
361 hover_background_color: theme.context_menu.item.hover_background,
362 font_family: theme.context_menu.item.font_family.clone(),
363 font_weight: FontWeight::Regular,
364 font_style: FontStyle::Normal,
365 font_size: theme.context_menu.item.font_size,
366 }
367 }
368}
369
370fn context_menu_entry_line_height(style: &ContextMenuEntryStyle) -> f32 {
371 let content_height = style.item_height - style.padding_top - style.padding_bottom;
372 if content_height > 1.0 {
373 content_height
374 } else {
375 1.0
376 }
377}
378
379#[derive(Clone)]
380struct ContextMenuEntry {
381 root: Grid,
382 label_node: TextNode,
383 shortcut_node: TextNode,
384 slot: usize,
385 hovered: Rc<Cell<bool>>,
386 pressed: Rc<Cell<bool>>,
387 disabled: Rc<Cell<bool>>,
388 style: Rc<RefCell<ContextMenuEntryStyle>>,
389}
390
391impl ContextMenuEntry {
392 fn new(slot: usize) -> Self {
393 let theme = current_theme();
394 let root = grid();
395 let label_node = TextNode::new("");
396 label_node
397 .font_family(theme.context_menu.item.font_family.clone())
398 .font_size(theme.context_menu.item.font_size)
399 .line_height(
400 theme.context_menu.item.height
401 - theme.context_menu.item.padding_top
402 - theme.context_menu.item.padding_bottom,
403 )
404 .text_color(theme.context_menu.item.text_color)
405 .text_overflow(TextOverflow::Ellipsis)
406 .selectable(false);
407 let shortcut_node = TextNode::new("");
408 shortcut_node
409 .font_family(theme.context_menu.item.font_family.clone())
410 .font_size(theme.context_menu.item.font_size)
411 .line_height(
412 theme.context_menu.item.height
413 - theme.context_menu.item.padding_top
414 - theme.context_menu.item.padding_bottom,
415 )
416 .text_color(theme.colors.text_muted)
417 .text_align(TextAlign::Left)
418 .selectable(false);
419
420 root.width(100.0, Unit::Percent)
421 .height(theme.context_menu.item.height, Unit::Pixel)
422 .padding(
423 theme.context_menu.item.padding_left,
424 theme.context_menu.item.padding_top,
425 theme.context_menu.item.padding_right,
426 theme.context_menu.item.padding_bottom,
427 )
428 .interactive(true)
429 .semantic_role(SemanticRole::Button)
430 .cursor(CursorStyle::Pointer)
431 .columns(vec![GridTrack::star(1.0), GridTrack::auto()])
432 .rows(vec![GridTrack::star(1.0)])
433 .column_shared_size_group(1, SHORTCUT_SHARED_SIZE_GROUP)
434 .place_child(&label_node, 0, 0, 1, 1)
435 .place_child(&shortcut_node, 0, 1, 1, 1);
436
437 let entry = Self {
438 root,
439 label_node,
440 shortcut_node,
441 slot,
442 hovered: Rc::new(Cell::new(false)),
443 pressed: Rc::new(Cell::new(false)),
444 disabled: Rc::new(Cell::new(false)),
445 style: Rc::new(RefCell::new(ContextMenuEntryStyle::from_theme(&theme))),
446 };
447 entry.bind_events();
448 entry.apply_theme();
449 entry
450 }
451
452 fn bind_events(&self) {
453 let event_target = self.event_target();
454 self.root.on_pointer_enter(move |_event| {
455 if event_target.disabled.get() {
456 return;
457 }
458 event_target.hovered.set(true);
459 event_target.apply_theme();
460 });
461
462 let event_target = self.event_target();
463 self.root.on_pointer_leave(move |_event| {
464 event_target.hovered.set(false);
465 event_target.pressed.set(false);
466 event_target.apply_theme();
467 });
468
469 let event_target = self.event_target();
470 self.root.on_pointer_down(move |_event| {
471 event_target.pressed.set(!event_target.disabled.get());
472 });
473
474 let event_target = self.event_target();
475 self.root.on_pointer_up(move |event| {
476 let should_invoke = event_target.pressed.get()
477 && event_target.hovered.get()
478 && !event_target.disabled.get();
479 event_target.pressed.set(false);
480 event.handled = true;
481 if should_invoke {
482 ContextMenu::invoke_active_slot(event_target.slot as i32);
483 }
484 });
485
486 let event_target = self.event_target();
487 self.root.on_pointer_cancel(move |_event| {
488 event_target.pressed.set(false);
489 });
490 }
491
492 fn item(&self, item: &MenuItem) {
493 self.hovered.set(false);
494 self.pressed.set(false);
495 self.disabled.set(item.disabled);
496 self.root
497 .semantic_label(item.label.clone())
498 .semantic_disabled(item.disabled)
499 .cursor(if item.disabled {
500 CursorStyle::Default
501 } else {
502 CursorStyle::Pointer
503 });
504 self.label_node.text(item.label.clone());
505 self.shortcut_node
506 .text(item.shortcut_label.clone().unwrap_or_default());
507 self.apply_theme();
508 }
509
510 fn configure_style(&self, style: &ContextMenuEntryStyle) {
511 *self.style.borrow_mut() = style.clone();
512 self.apply_theme();
513 }
514
515 fn apply_theme(&self) {
516 let style = self.style.borrow().clone();
517 let theme = current_theme();
518 self.root
519 .height(style.item_height, Unit::Pixel)
520 .padding(
521 style.padding_left,
522 style.padding_top,
523 style.padding_right,
524 style.padding_bottom,
525 )
526 .corners(
527 style.corner_top_left,
528 style.corner_top_right,
529 style.corner_bottom_right,
530 style.corner_bottom_left,
531 )
532 .bg_color(if self.hovered.get() && !self.disabled.get() {
533 style.hover_background_color
534 } else {
535 style.background_color
536 });
537 self.label_node
538 .font_family(style.font_family.clone())
539 .font_weight(style.font_weight)
540 .font_style(style.font_style)
541 .font_size(style.font_size)
542 .line_height(context_menu_entry_line_height(&style))
543 .text_color(if self.disabled.get() {
544 theme.colors.text_muted
545 } else {
546 style.text_color
547 });
548 self.shortcut_node
549 .font_family(style.font_family.clone())
550 .font_weight(style.font_weight)
551 .font_style(style.font_style)
552 .font_size(style.font_size)
553 .line_height(context_menu_entry_line_height(&style))
554 .text_color(theme.colors.text_muted);
555 }
556
557 fn event_target(&self) -> ContextMenuEntryEventTarget {
558 ContextMenuEntryEventTarget {
559 slot: self.slot,
560 hovered: self.hovered.clone(),
561 pressed: self.pressed.clone(),
562 disabled: self.disabled.clone(),
563 root: self.root.downgrade(),
564 label_node: self.label_node.clone(),
565 shortcut_node: self.shortcut_node.clone(),
566 style: self.style.clone(),
567 }
568 }
569}
570
571#[derive(Clone)]
572struct ContextMenuEntryEventTarget {
573 slot: usize,
574 hovered: Rc<Cell<bool>>,
575 pressed: Rc<Cell<bool>>,
576 disabled: Rc<Cell<bool>>,
577 root: WeakFlexBox,
578 label_node: TextNode,
579 shortcut_node: TextNode,
580 style: Rc<RefCell<ContextMenuEntryStyle>>,
581}
582
583impl ContextMenuEntryEventTarget {
584 fn apply_theme(&self) {
585 let style = self.style.borrow().clone();
586 let theme = current_theme();
587 if let Some(root) = self.root.upgrade() {
588 root.bg_color(if self.hovered.get() && !self.disabled.get() {
589 style.hover_background_color
590 } else {
591 style.background_color
592 });
593 }
594 self.label_node
595 .font_family(style.font_family.clone())
596 .font_size(style.font_size)
597 .line_height(context_menu_entry_line_height(&style))
598 .text_color(if self.disabled.get() {
599 theme.colors.text_muted
600 } else {
601 style.text_color
602 });
603 self.shortcut_node
604 .font_family(style.font_family.clone())
605 .font_size(style.font_size)
606 .line_height(context_menu_entry_line_height(&style))
607 .text_color(theme.colors.text_muted);
608 }
609}
610
611#[derive(Clone)]
612struct ContextMenuSeparator {
613 root: FlexBox,
614 line: FlexBox,
615 color: Rc<Cell<u32>>,
616}
617
618impl ContextMenuSeparator {
619 fn new() -> Self {
620 let theme = current_theme();
621 let root = column();
622 let line = flex_box();
623 line.width(100.0, Unit::Percent).height(1.0, Unit::Pixel);
624 root.width(100.0, Unit::Percent)
625 .height(MENU_SEPARATOR_HEIGHT, Unit::Pixel)
626 .padding(4.0, 0.0, 4.0, 0.0)
627 .child(&line);
628 let separator = Self {
629 root,
630 line,
631 color: Rc::new(Cell::new(theme.context_menu.separator_color)),
632 };
633 separator.apply_theme();
634 separator
635 }
636
637 fn configure_style(&self, color: u32) {
638 self.color.set(color);
639 self.apply_theme();
640 }
641
642 fn apply_theme(&self) {
643 self.line.bg_color(self.color.get());
644 }
645}
646
647struct ContextMenuState {
648 appearance: Option<ContextMenuAppearance>,
649 visible: bool,
650 suppress_next_pointer_up_activation: bool,
651 key_filter_token: u32,
652 menu_width: f32,
653 item_style: ContextMenuEntryStyle,
654 panel_background_color: u32,
655 panel_border_width: f32,
656 panel_border_color: u32,
657 panel_border_style: BorderStyle,
658 panel_corner_top_left: f32,
659 panel_corner_top_right: f32,
660 panel_corner_bottom_right: f32,
661 panel_corner_bottom_left: f32,
662 separator_color: u32,
663 panel_shadow_color: u32,
664 panel_shadow_offset_x: f32,
665 panel_shadow_offset_y: f32,
666 panel_shadow_blur: f32,
667 panel_shadow_spread: f32,
668 panel_background_blur_sigma: f32,
669 panel_border_dash_on: f32,
670 panel_border_dash_off: f32,
671 visibility_changed_callback: Option<VisibilityChangedCallback>,
672}
673
674impl ContextMenuState {
675 fn from_theme(theme: Theme) -> Self {
676 Self {
677 appearance: None,
678 visible: false,
679 suppress_next_pointer_up_activation: false,
680 key_filter_token: 0,
681 menu_width: MENU_WIDTH,
682 item_style: ContextMenuEntryStyle::from_theme(&theme),
683 panel_background_color: theme.context_menu.panel_background,
684 panel_border_width: 1.0,
685 panel_border_color: theme.context_menu.panel_border_color,
686 panel_border_style: BorderStyle::Solid,
687 panel_corner_top_left: theme.context_menu.panel_corner_radius,
688 panel_corner_top_right: theme.context_menu.panel_corner_radius,
689 panel_corner_bottom_right: theme.context_menu.panel_corner_radius,
690 panel_corner_bottom_left: theme.context_menu.panel_corner_radius,
691 separator_color: theme.context_menu.separator_color,
692 panel_shadow_color: theme.context_menu.panel_shadow_color,
693 panel_shadow_offset_x: 0.0,
694 panel_shadow_offset_y: theme.context_menu.shadow_offset_y,
695 panel_shadow_blur: theme.context_menu.shadow_blur,
696 panel_shadow_spread: theme.context_menu.shadow_spread,
697 panel_background_blur_sigma: DEFAULT_PANEL_BACKGROUND_BLUR_SIGMA,
698 panel_border_dash_on: 0.0,
699 panel_border_dash_off: 0.0,
700 visibility_changed_callback: None,
701 }
702 }
703}
704
705#[derive(Clone)]
706struct ContextMenuEventTarget {
707 panel: WeakFlexBox,
708 presenter: PopupPresenterEventTarget,
709 entries: Vec<ContextMenuEntry>,
710 separators: Vec<ContextMenuSeparator>,
711 current_items: Rc<RefCell<Vec<MenuItem>>>,
712 current_item_tops: Rc<RefCell<Vec<f32>>>,
713 current_item_heights: Rc<RefCell<Vec<f32>>>,
714 state: Rc<RefCell<ContextMenuState>>,
715}
716
717impl ContextMenuEventTarget {
718 fn clear_panel(&self) {
719 let Some(panel) = self.panel.upgrade() else {
720 return;
721 };
722 for entry in &self.entries {
723 panel.remove_child(&entry.root);
724 }
725 for separator in &self.separators {
726 panel.remove_child(&separator.root);
727 }
728 }
729
730 fn hide(&self) {
731 let was_visible = self.state.borrow().visible || self.presenter.is_open();
732 if !was_visible {
733 return;
734 }
735 self.clear_panel();
736 self.current_items.borrow_mut().clear();
737 self.current_item_tops.borrow_mut().clear();
738 self.current_item_heights.borrow_mut().clear();
739 self.presenter.hide();
740 let callback = {
741 let mut state = self.state.borrow_mut();
742 state.visible = false;
743 state.suppress_next_pointer_up_activation = false;
744 let token = state.key_filter_token;
745 state.key_filter_token = 0;
746 if token != 0 {
747 event::remove_key_filter(token);
748 }
749 state.visibility_changed_callback.clone()
750 };
751 ACTIVE_CONTEXT_MENU.with(|slot| {
752 let should_clear = slot
753 .borrow()
754 .as_ref()
755 .is_some_and(|active| Rc::ptr_eq(&active.state, &self.state));
756 if should_clear {
757 slot.borrow_mut().take();
758 }
759 });
760 if let Some(callback) = callback {
761 callback(ContextMenuVisibilityChangedEventArgs { visible: false });
762 }
763 }
764
765 fn invoke_slot(&self, slot: usize) {
766 let item = self.current_items.borrow().get(slot).cloned();
767 if let Some(item) = item {
768 run_context_menu_action(&item);
769 self.hide();
770 }
771 }
772
773 fn handle_overlay_pointer_up(&self, event: &mut PointerEventArgs) {
774 if !self.state.borrow().visible {
775 return;
776 }
777 if !is_primary_activation_pointer(event) {
778 if ContextMenu::consume_opening_pointer_up_suppression() {
779 event.handled = true;
780 return;
781 }
782 event.handled = true;
783 return;
784 }
785 let item_count = self.current_items.borrow().len();
786 for slot in 0..item_count {
787 let entry = &self.entries[slot];
788 let Some(bounds) = ui::get_bounds(entry.root.handle().raw()) else {
789 continue;
790 };
791 let left = bounds[0];
792 let top = bounds[1];
793 let right = left + bounds[2];
794 let bottom = top + bounds[3];
795 if event.scene_x >= left
796 && event.scene_x <= right
797 && event.scene_y >= top
798 && event.scene_y <= bottom
799 {
800 event.handled = true;
801 self.invoke_slot(slot);
802 return;
803 }
804 }
805 let local_x = event.scene_x - self.presenter.surface_x();
806 let local_y = event.scene_y - self.presenter.surface_y();
807 if local_x < 0.0 || local_x > self.state.borrow().menu_width || local_y < 0.0 {
808 event.handled = true;
809 self.hide();
810 return;
811 }
812 let slot_to_invoke = {
813 let tops = self.current_item_tops.borrow();
814 let heights = self.current_item_heights.borrow();
815 let mut slot_to_invoke = None;
816 for slot in 0..tops.len() {
817 let top = tops[slot];
818 let height = heights[slot];
819 if local_y >= top && local_y <= top + height {
820 slot_to_invoke = Some(slot);
821 break;
822 }
823 }
824 slot_to_invoke
825 };
826 if let Some(slot) = slot_to_invoke {
827 event.handled = true;
828 self.invoke_slot(slot);
829 return;
830 }
831 event.handled = true;
832 self.hide();
833 }
834
835 fn handle_global_key_event(&self, event_type: KeyEventType, key: &str) -> bool {
836 if event_type == KeyEventType::Down && key == "Escape" {
837 self.hide();
838 return true;
839 }
840 false
841 }
842
843 fn apply_theme(&self) {
844 let Some(panel) = self.panel.upgrade() else {
845 return;
846 };
847 let state = self.state.borrow();
848 Grid::shared_size_scope(&panel, true);
849 panel
850 .width(state.menu_width, Unit::Pixel)
851 .bg_color(state.panel_background_color)
852 .background_blur(state.panel_background_blur_sigma)
853 .corners(
854 state.panel_corner_top_left,
855 state.panel_corner_top_right,
856 state.panel_corner_bottom_right,
857 state.panel_corner_bottom_left,
858 )
859 .border_config(Border {
860 width: state.panel_border_width,
861 color: state.panel_border_color,
862 style: state.panel_border_style,
863 dash_on: state.panel_border_dash_on,
864 dash_off: state.panel_border_dash_off,
865 })
866 .drop_shadow(
867 state.panel_shadow_color,
868 state.panel_shadow_offset_x,
869 state.panel_shadow_offset_y,
870 state.panel_shadow_blur,
871 state.panel_shadow_spread,
872 );
873 let item_style = state.item_style.clone();
874 let separator_color = state.separator_color;
875 drop(state);
876 for entry in &self.entries {
877 entry.configure_style(&item_style);
878 }
879 for separator in &self.separators {
880 separator.configure_style(separator_color);
881 }
882 }
883
884 fn handle_theme_changed(&self) {
885 let theme = current_theme();
886 let mut state = self.state.borrow_mut();
887 let appearance = state.appearance.clone().unwrap_or_default();
888 let panel = appearance.panel.unwrap_or_default();
889 let backdrop = appearance.backdrop.unwrap_or_default();
890 let item = appearance.item.unwrap_or_default();
891
892 state.menu_width = appearance.width.unwrap_or(MENU_WIDTH);
893 state.panel_background_color = panel
894 .background
895 .unwrap_or(theme.context_menu.panel_background);
896 let border = panel
897 .border
898 .unwrap_or_else(|| Border::solid(1.0, theme.context_menu.panel_border_color));
899 state.panel_border_width = border.width;
900 state.panel_border_color = border.color;
901 state.panel_border_style = border.style;
902 state.panel_border_dash_on = border.dash_on;
903 state.panel_border_dash_off = border.dash_off;
904 let corners = panel
905 .corners
906 .unwrap_or_else(|| crate::Corners::all(theme.context_menu.panel_corner_radius));
907 state.panel_corner_top_left = corners.top_left;
908 state.panel_corner_top_right = corners.top_right;
909 state.panel_corner_bottom_right = corners.bottom_right;
910 state.panel_corner_bottom_left = corners.bottom_left;
911 let shadow = panel.shadow.unwrap_or_else(|| {
912 crate::Shadow::new(
913 theme.context_menu.panel_shadow_color,
914 0.0,
915 theme.context_menu.shadow_offset_y,
916 theme.context_menu.shadow_blur,
917 theme.context_menu.shadow_spread,
918 )
919 });
920 state.panel_shadow_color = shadow.color;
921 state.panel_shadow_offset_x = shadow.offset_x;
922 state.panel_shadow_offset_y = shadow.offset_y;
923 state.panel_shadow_blur = shadow.blur_sigma;
924 state.panel_shadow_spread = shadow.spread;
925 state.panel_background_blur_sigma = panel
926 .background_blur
927 .unwrap_or(DEFAULT_PANEL_BACKGROUND_BLUR_SIGMA);
928 state.separator_color = appearance
929 .separator_color
930 .unwrap_or(theme.context_menu.separator_color);
931
932 state.item_style.item_height = item.height.unwrap_or(theme.context_menu.item.height);
933 let padding = item.padding.unwrap_or_else(|| {
934 crate::EdgeInsets::new(
935 theme.context_menu.item.padding_left,
936 theme.context_menu.item.padding_top,
937 theme.context_menu.item.padding_right,
938 theme.context_menu.item.padding_bottom,
939 )
940 });
941 state.item_style.padding_left = padding.left;
942 state.item_style.padding_top = padding.top;
943 state.item_style.padding_right = padding.right;
944 state.item_style.padding_bottom = padding.bottom;
945 state.item_style.text_color = item
946 .text_color
947 .unwrap_or(theme.context_menu.item.text_color);
948 state.item_style.background_color = item
949 .background
950 .unwrap_or(theme.context_menu.item.background);
951 state.item_style.hover_background_color = item
952 .hover_background
953 .unwrap_or(theme.context_menu.item.hover_background);
954 let item_corners = item
955 .corners
956 .unwrap_or_else(|| crate::Corners::all(theme.context_menu.item.corner_radius));
957 state.item_style.corner_top_left = item_corners.top_left;
958 state.item_style.corner_top_right = item_corners.top_right;
959 state.item_style.corner_bottom_right = item_corners.bottom_right;
960 state.item_style.corner_bottom_left = item_corners.bottom_left;
961 state.item_style.font_family = item
962 .font_family
963 .unwrap_or_else(|| theme.context_menu.item.font_family.clone());
964 state.item_style.font_weight = item.font_weight.unwrap_or(FontWeight::Regular);
965 state.item_style.font_style = item.font_style.unwrap_or(FontStyle::Normal);
966 state.item_style.font_size = item.font_size.unwrap_or(theme.context_menu.item.font_size);
967 drop(state);
968 self.presenter
969 .backdrop_color(backdrop.color.unwrap_or(0x00000000));
970 self.presenter.background_blur(backdrop.blur.unwrap_or(0.0));
971 self.apply_theme();
972 }
973}
974
975#[derive(Clone)]
976pub struct ContextMenu {
977 root: FlexBox,
978 panel: FlexBox,
979 popup_presenter: PopupPresenter,
980 items: Rc<RefCell<Vec<MenuItem>>>,
981 entries: Vec<ContextMenuEntry>,
982 separators: Vec<ContextMenuSeparator>,
983 current_items: Rc<RefCell<Vec<MenuItem>>>,
984 current_item_tops: Rc<RefCell<Vec<f32>>>,
985 current_item_heights: Rc<RefCell<Vec<f32>>>,
986 state: Rc<RefCell<ContextMenuState>>,
987}
988
989impl Default for ContextMenu {
990 fn default() -> Self {
991 Self::new()
992 }
993}
994
995impl ContextMenu {
996 pub fn new() -> Self {
997 let theme = current_theme();
998 let root = portal();
999 let panel = column();
1000 panel.position_type(crate::ffi::PositionType::Absolute);
1001 Grid::shared_size_scope(&panel, true);
1002 panel
1003 .width(MENU_WIDTH, Unit::Pixel)
1004 .padding(4.0, 4.0, 4.0, 4.0)
1005 .border(1.0, theme.context_menu.panel_border_color);
1006 let popup_presenter = PopupPresenter::new(root.clone(), panel.clone());
1007 root.position_type(crate::ffi::PositionType::Absolute)
1008 .position(0.0, 0.0)
1009 .width(100.0, Unit::Percent)
1010 .height(100.0, Unit::Percent);
1011
1012 let menu = Self {
1013 root,
1014 panel,
1015 popup_presenter,
1016 items: Rc::new(RefCell::new(Vec::new())),
1017 entries: (0..MAX_ITEMS).map(ContextMenuEntry::new).collect(),
1018 separators: (0..MAX_ITEMS)
1019 .map(|_| ContextMenuSeparator::new())
1020 .collect(),
1021 current_items: Rc::new(RefCell::new(Vec::new())),
1022 current_item_tops: Rc::new(RefCell::new(Vec::new())),
1023 current_item_heights: Rc::new(RefCell::new(Vec::new())),
1024 state: Rc::new(RefCell::new(ContextMenuState::from_theme(theme))),
1025 };
1026 menu.bind_events();
1027 menu.install_theme_subscription();
1028 menu.apply_theme();
1029 menu
1030 }
1031
1032 pub fn hide_active_menu() {
1033 let menu = ACTIVE_CONTEXT_MENU.with(|slot| slot.borrow().as_ref().cloned());
1034 if let Some(menu) = menu {
1035 menu.hide();
1036 }
1037 }
1038
1039 pub(crate) fn is_active_menu_visible() -> bool {
1040 ACTIVE_CONTEXT_MENU.with(|slot| slot.borrow().is_some())
1041 }
1042
1043 pub(crate) fn dismiss_for_outside_pointer_down(scene_x: f32, scene_y: f32) -> bool {
1044 let menu = ACTIVE_CONTEXT_MENU.with(|slot| slot.borrow().as_ref().cloned());
1045 let Some(menu) = menu else {
1046 return false;
1047 };
1048 let Some(panel) = menu.panel.upgrade() else {
1049 menu.hide();
1050 return true;
1051 };
1052 let Some(bounds) = ui::get_bounds(panel.handle().raw()) else {
1053 menu.hide();
1054 return true;
1055 };
1056 let inside = scene_x >= bounds[0]
1057 && scene_x <= bounds[0] + bounds[2]
1058 && scene_y >= bounds[1]
1059 && scene_y <= bounds[1] + bounds[3];
1060 if inside {
1061 return false;
1062 }
1063 menu.hide();
1064 true
1065 }
1066
1067 pub(crate) fn invoke_active_slot(slot: i32) {
1068 if slot < 0 {
1069 return;
1070 }
1071 let menu = ACTIVE_CONTEXT_MENU.with(|slot_state| slot_state.borrow().as_ref().cloned());
1072 if let Some(menu) = menu {
1073 menu.invoke_slot(slot as usize);
1074 }
1075 }
1076
1077 pub(crate) fn consume_opening_pointer_up_suppression() -> bool {
1078 ACTIVE_CONTEXT_MENU.with(|slot| {
1079 let Some(menu) = slot.borrow().as_ref().cloned() else {
1080 return false;
1081 };
1082 let mut state = menu.state.borrow_mut();
1083 if !state.suppress_next_pointer_up_activation {
1084 return false;
1085 }
1086 state.suppress_next_pointer_up_activation = false;
1087 true
1088 })
1089 }
1090
1091 pub fn items<I>(&self, items: I) -> &Self
1092 where
1093 I: IntoIterator<Item = MenuItem>,
1094 {
1095 self.items.borrow_mut().clear();
1096 self.items.borrow_mut().extend(items);
1097 self
1098 }
1099
1100 pub fn clear_items(&self) -> &Self {
1101 self.items.borrow_mut().clear();
1102 self
1103 }
1104
1105 pub fn is_open(&self) -> bool {
1106 self.state.borrow().visible
1107 }
1108
1109 pub fn on_visibility_changed(
1110 &self,
1111 handler: impl Fn(ContextMenuVisibilityChangedEventArgs) + 'static,
1112 ) -> &Self {
1113 self.state.borrow_mut().visibility_changed_callback = Some(Rc::new(handler));
1114 self
1115 }
1116
1117 pub fn clear_visibility_changed(&self) -> &Self {
1118 self.state.borrow_mut().visibility_changed_callback = None;
1119 self
1120 }
1121
1122 pub fn appearance(&self, appearance: ContextMenuAppearance) -> &Self {
1123 self.state.borrow_mut().appearance = Some(appearance);
1124 self.event_target().handle_theme_changed();
1125 self
1126 }
1127
1128 pub fn clear_appearance(&self) -> &Self {
1129 self.state.borrow_mut().appearance = None;
1130 self.event_target().handle_theme_changed();
1131 self
1132 }
1133
1134 pub fn show(&self, x: f32, y: f32) {
1135 self.show_impl(x, y, false);
1136 }
1137
1138 pub fn show_relative_to<T: Node>(&self, target: &T, x: f32, y: f32) {
1139 if let Some(bounds) = ui::get_bounds(target.handle().raw()) {
1140 self.show(bounds[0] + x, bounds[1] + y);
1141 } else {
1142 self.show(x, y);
1143 }
1144 }
1145
1146 pub fn show_from_context_pointer(&self, x: f32, y: f32) {
1147 self.show_impl(x, y, true);
1148 }
1149
1150 pub fn show_from_context_pointer_relative_to<T: Node>(&self, target: &T, x: f32, y: f32) {
1151 if let Some(bounds) = ui::get_bounds(target.handle().raw()) {
1152 self.show_from_context_pointer(bounds[0] + x, bounds[1] + y);
1153 } else {
1154 self.show_from_context_pointer(x, y);
1155 }
1156 }
1157
1158 fn show_impl(&self, x: f32, y: f32, suppress_opening_pointer_up: bool) {
1159 self.clear_panel();
1160 self.apply_theme();
1161 self.current_items.borrow_mut().clear();
1162 self.current_item_tops.borrow_mut().clear();
1163 self.current_item_heights.borrow_mut().clear();
1164
1165 let items = self.items.borrow().clone();
1166 let item_height = self.state.borrow().item_style.item_height;
1167 let menu_width = self.state.borrow().menu_width;
1168 let mut action_count = 0usize;
1169 let mut separator_count = 0usize;
1170 let mut estimated_height = 8.0;
1171 let mut content_y = 0.0;
1172 let mut last_was_separator = true;
1173 let count = items.len().min(MAX_ITEMS);
1174
1175 if items.len() > MAX_ITEMS {
1176 warn(
1177 "Layout",
1178 &format!(
1179 "ContextMenu.show() received {} items; truncating to {MAX_ITEMS}.",
1180 items.len()
1181 ),
1182 );
1183 }
1184
1185 for (index, item) in items.into_iter().take(count).enumerate() {
1186 if item.is_separator() {
1187 if last_was_separator || index == count - 1 {
1188 continue;
1189 }
1190 let separator = &self.separators[separator_count];
1191 separator.apply_theme();
1192 self.panel.child(&separator.root);
1193 separator_count += 1;
1194 estimated_height += MENU_SEPARATOR_HEIGHT;
1195 content_y += MENU_SEPARATOR_HEIGHT;
1196 last_was_separator = true;
1197 continue;
1198 }
1199
1200 let entry = &self.entries[action_count];
1201 entry.item(&item);
1202 entry.apply_theme();
1203 self.current_items.borrow_mut().push(item);
1204 self.current_item_tops.borrow_mut().push(content_y);
1205 self.current_item_heights.borrow_mut().push(item_height);
1206 self.panel.child(&entry.root);
1207 action_count += 1;
1208 estimated_height += item_height;
1209 content_y += item_height;
1210 last_was_separator = false;
1211 }
1212
1213 if action_count == 0 {
1214 self.clear_panel();
1215 return;
1216 }
1217
1218 let max_x = (ui::get_viewport_width() - menu_width - MENU_EDGE_PADDING).max(0.0);
1219 let max_y = (ui::get_viewport_height() - estimated_height - MENU_EDGE_PADDING).max(0.0);
1220 let clamped_x = x.clamp(MENU_EDGE_PADDING, max_x);
1221 let clamped_y = y.clamp(MENU_EDGE_PADDING, max_y);
1222 self.popup_presenter
1223 .show_at_point(clamped_x, clamped_y, menu_width, estimated_height);
1224 {
1225 let mut state = self.state.borrow_mut();
1226 state.visible = true;
1227 state.suppress_next_pointer_up_activation = suppress_opening_pointer_up;
1228 }
1229 ACTIVE_CONTEXT_MENU.with(|slot| {
1230 *slot.borrow_mut() = Some(self.event_target());
1231 });
1232 if let Some(callback) = self.state.borrow().visibility_changed_callback.clone() {
1233 callback(ContextMenuVisibilityChangedEventArgs { visible: true });
1234 }
1235 if self.state.borrow().key_filter_token == 0 {
1236 let event_target = self.event_target();
1237 let token = event::push_key_filter(move |event_type, key, _modifiers| {
1238 event_target.handle_global_key_event(event_type, key)
1239 });
1240 self.state.borrow_mut().key_filter_token = token;
1241 }
1242 }
1243
1244 pub fn hide(&self) {
1245 self.event_target().hide();
1246 }
1247
1248 fn bind_events(&self) {
1249 let event_target = self.event_target();
1250 self.popup_presenter
1251 .overlay_node()
1252 .interactive(true)
1253 .on_pointer_up(move |event| {
1254 event_target.handle_overlay_pointer_up(event);
1255 });
1256 }
1257
1258 fn install_theme_subscription(&self) {
1259 let event_target = self.event_target();
1260 let guard = subscribe(move |_theme| {
1261 event_target.handle_theme_changed();
1262 });
1263 self.root
1264 .retained_node_ref()
1265 .retain_attachment(Rc::new(guard));
1266 }
1267
1268 fn clear_panel(&self) {
1269 self.event_target().clear_panel();
1270 }
1271
1272 fn apply_theme(&self) {
1273 self.event_target().apply_theme();
1274 }
1275
1276 fn event_target(&self) -> ContextMenuEventTarget {
1277 ContextMenuEventTarget {
1278 panel: self.panel.downgrade(),
1279 presenter: self.popup_presenter.event_target(),
1280 entries: self.entries.clone(),
1281 separators: self.separators.clone(),
1282 current_items: self.current_items.clone(),
1283 current_item_tops: self.current_item_tops.clone(),
1284 current_item_heights: self.current_item_heights.clone(),
1285 state: self.state.clone(),
1286 }
1287 }
1288}
1289
1290impl Node for ContextMenu {
1291 fn retained_node_ref(&self) -> NodeRef {
1292 self.root.retained_node_ref()
1293 }
1294
1295 fn retained_owner_attachment(&self) -> Option<Rc<dyn std::any::Any>> {
1296 Some(Rc::new(self.clone()))
1297 }
1298
1299 fn build_self(&self) {
1300 self.root.build_self();
1301 }
1302
1303 fn dispose(&self) {
1304 self.hide();
1305 self.popup_presenter.dispose();
1306 for entry in &self.entries {
1307 if entry.root.handle() != crate::node::NodeHandle::INVALID {
1308 entry.root.dispose();
1309 }
1310 }
1311 for separator in &self.separators {
1312 if separator.root.handle() != crate::node::NodeHandle::INVALID {
1313 separator.root.dispose();
1314 }
1315 }
1316 self.root.dispose();
1317 }
1318}
1319
1320impl crate::node::HasFlexBoxRoot for ContextMenu {
1321 fn flex_box_root(&self) -> &FlexBox {
1322 &self.root
1323 }
1324}
1325
1326impl ThemeBindable for ContextMenu {
1327 fn theme_binding_node(&self) -> NodeRef {
1328 self.root.retained_node_ref()
1329 }
1330
1331 fn weak_theme_target(&self) -> Box<dyn Fn() -> Option<Self>> {
1332 let root = self.root.downgrade();
1333 let panel = self.panel.downgrade();
1334 let popup_presenter = self.popup_presenter.downgrade();
1335 let items = Rc::downgrade(&self.items);
1336 let entries = self.entries.clone();
1337 let separators = self.separators.clone();
1338 let current_items = Rc::downgrade(&self.current_items);
1339 let current_item_tops = Rc::downgrade(&self.current_item_tops);
1340 let current_item_heights = Rc::downgrade(&self.current_item_heights);
1341 let state = Rc::downgrade(&self.state);
1342 Box::new(move || {
1343 Some(Self {
1344 root: root.upgrade()?,
1345 panel: panel.upgrade()?,
1346 popup_presenter: popup_presenter.upgrade()?,
1347 items: items.upgrade()?,
1348 entries: entries.clone(),
1349 separators: separators.clone(),
1350 current_items: current_items.upgrade()?,
1351 current_item_tops: current_item_tops.upgrade()?,
1352 current_item_heights: current_item_heights.upgrade()?,
1353 state: state.upgrade()?,
1354 })
1355 })
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::{run_context_menu_action, ContextMenuAction, MenuItem};
1362 use crate::ffi;
1363 use std::cell::Cell;
1364 use std::rc::Rc;
1365
1366 #[test]
1367 fn unsupported_stale_context_menu_actions_do_not_invoke() {
1368 ffi::test::reset();
1369 ffi::test::set_host_environment(3);
1370 ffi::test::set_host_capabilities(0);
1371 let count = Rc::new(Cell::new(0));
1372 let item = MenuItem::new("New Tab", ContextMenuAction::OpenLinkInNewTab)
1373 .payload("https://example.test")
1374 .on_invoke({
1375 let count = count.clone();
1376 move || count.set(count.get() + 1)
1377 });
1378
1379 run_context_menu_action(&item);
1380
1381 assert_eq!(count.get(), 0);
1382 }
1383}