Skip to main content

fui/controls/
context_menu.rs

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}