Skip to main content

freya_components/
context_menu.rs

1use freya_core::{
2    integration::ScopeId,
3    layers::Layer,
4    prelude::*,
5};
6use torin::prelude::{
7    CursorPoint,
8    Position,
9};
10
11use crate::menu::Menu;
12
13#[derive(Clone, Copy, PartialEq)]
14pub(crate) enum ContextMenuCloseRequest {
15    None,
16    Pending,
17}
18
19/// Global context menu state.
20///
21/// Requires a [`ContextMenuViewer`] in an ancestor scope.
22///
23/// # Example
24///
25/// ```rust
26/// # use freya::prelude::*;
27/// fn app() -> impl IntoElement {
28///     rect().child(ContextMenuViewer::new()).child(
29///         rect()
30///             .on_secondary_down(move |e: Event<PressEventData>| {
31///                 ContextMenu::open_from_event(
32///                     &e,
33///                     Menu::new().child(MenuButton::new().child("Option 1")),
34///                 );
35///             })
36///             .child("Right click to open menu"),
37///     )
38/// }
39/// ```
40#[derive(Clone, Copy, PartialEq)]
41pub struct ContextMenu {
42    pub(crate) location: State<CursorPoint>,
43    pub(crate) menu: State<Option<(CursorPoint, Menu)>>,
44    pub(crate) close_request: State<ContextMenuCloseRequest>,
45}
46
47impl ContextMenu {
48    /// # Panics
49    ///
50    /// Panics if no [`ContextMenuViewer`] is mounted in an ancestor scope.
51    pub fn get() -> Self {
52        try_consume_root_context()
53            .expect("ContextMenu requires a `ContextMenuViewer` in an ancestor scope")
54    }
55
56    pub fn is_open() -> bool {
57        try_consume_root_context::<Self>().is_some_and(|c| c.menu.read().is_some())
58    }
59
60    /// Open the context menu with the given menu.
61    /// Prefer using [`ContextMenu::open_from_event`] instead as it correctly handles
62    /// the close behavior based on the source event.
63    pub fn open(menu: Menu) {
64        let mut this = Self::get();
65        this.menu.set(Some(((this.location)(), menu)));
66        this.close_request.set(ContextMenuCloseRequest::None);
67    }
68
69    /// Open the context menu with the given menu, using the source event to determine
70    /// the close behavior. When opened from a primary button (left click) press event,
71    /// the first close request is consumed to prevent the menu from closing immediately.
72    /// When opened from a secondary button (right click) down event, the menu can be
73    /// closed with a single click.
74    pub fn open_from_event(event: &Event<PressEventData>, menu: Menu) {
75        let mut this = Self::get();
76        this.menu.set(Some(((this.location)(), menu)));
77
78        let close_request = match event.data() {
79            PressEventData::Mouse(mouse) if mouse.button == Some(MouseButton::Left) => {
80                ContextMenuCloseRequest::Pending
81            }
82            _ => ContextMenuCloseRequest::None,
83        };
84        this.close_request.set(close_request);
85    }
86
87    pub fn close() {
88        if let Some(mut this) = try_consume_root_context::<Self>() {
89            this.menu.set(None);
90        }
91    }
92}
93
94/// Provides the [`ContextMenu`] state and renders the floating menu overlay.
95///
96/// Mount this as high up in your tree as possible (typically in your `app`
97/// component) so the rendered menu inherits styling like `font_size` from
98/// the app's root element.
99///
100/// # Example
101///
102/// ```rust
103/// # use freya::prelude::*;
104/// fn app() -> impl IntoElement {
105///     rect()
106///         .font_size(18.)
107///         .child(ContextMenuViewer::new())
108///         .child("Your app content here")
109/// }
110/// ```
111#[derive(Default, Clone, PartialEq)]
112pub struct ContextMenuViewer {
113    key: DiffKey,
114}
115
116impl KeyExt for ContextMenuViewer {
117    fn write_key(&mut self) -> &mut DiffKey {
118        &mut self.key
119    }
120}
121
122impl ContextMenuViewer {
123    pub fn new() -> Self {
124        Self::default()
125    }
126}
127
128impl ComponentOwned for ContextMenuViewer {
129    fn render(self) -> impl IntoElement {
130        let mut context = use_hook(|| {
131            try_consume_root_context::<ContextMenu>().unwrap_or_else(|| {
132                let state = ContextMenu {
133                    location: State::create_in_scope(CursorPoint::default(), ScopeId::ROOT),
134                    menu: State::create_in_scope(None, ScopeId::ROOT),
135                    close_request: State::create_in_scope(
136                        ContextMenuCloseRequest::None,
137                        ScopeId::ROOT,
138                    ),
139                };
140                provide_context_for_scope_id(state, ScopeId::ROOT);
141                state
142            })
143        });
144
145        rect()
146            .on_global_pointer_move(move |e: Event<PointerEventData>| {
147                context.location.set(e.global_location());
148            })
149            .maybe_child(context.menu.read().clone().map(|(location, menu)| {
150                let location = location.to_f32();
151                rect()
152                    .layer(Layer::Overlay)
153                    .position(Position::new_global().left(location.x).top(location.y))
154                    .child(menu.on_close(move |_| match (context.close_request)() {
155                        ContextMenuCloseRequest::None => {
156                            context.close_request.set(ContextMenuCloseRequest::Pending);
157                        }
158                        ContextMenuCloseRequest::Pending => {
159                            context.menu.set(None);
160                            context.close_request.set(ContextMenuCloseRequest::None);
161                        }
162                    }))
163            }))
164    }
165
166    fn render_key(&self) -> DiffKey {
167        self.key.clone().or(self.default_key())
168    }
169}