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/// Closing is only allowed once a new click starts after the menu opened.
14#[derive(Clone, Copy, PartialEq)]
15pub(crate) enum ClosePhase {
16    /// A down event just happened, now waiting for its press.
17    PendingPress,
18    /// Waiting for a new click.
19    Idle,
20    /// A new click started, it may close the menu.
21    CloseAllowed,
22}
23
24/// Global context menu state.
25///
26/// Requires a [`ContextMenuViewer`] in an ancestor scope.
27///
28/// # Example
29///
30/// ```rust
31/// # use freya::prelude::*;
32/// fn app() -> impl IntoElement {
33///     rect().child(ContextMenuViewer::new()).child(
34///         rect()
35///             .on_secondary_down(move |_| {
36///                 ContextMenu::open_from_down(
37///                     Menu::new().child(MenuButton::new().child("Option 1")),
38///                 );
39///             })
40///             .child("Right click to open menu"),
41///     )
42/// }
43/// ```
44#[derive(Clone, Copy, PartialEq)]
45pub struct ContextMenu {
46    pub(crate) location: State<CursorPoint>,
47    pub(crate) menu: State<Option<(CursorPoint, Menu)>>,
48    pub(crate) close_phase: State<ClosePhase>,
49}
50
51impl ContextMenu {
52    /// Returns whether the context menu is open, or `false` if no viewer is mounted.
53    ///
54    /// # Example
55    ///
56    /// ```rust
57    /// # use freya::prelude::*;
58    /// fn app() -> impl IntoElement {
59    ///     rect()
60    ///         .child(ContextMenuViewer::new())
61    ///         .child(if ContextMenu::is_open() {
62    ///             "Menu open"
63    ///         } else {
64    ///             "Menu closed"
65    ///         })
66    /// }
67    /// ```
68    pub fn is_open() -> bool {
69        try_consume_root_context::<Self>().is_some_and(|c| c.menu.read().is_some())
70    }
71
72    /// Open the context menu, from a press event or programmatically.
73    ///
74    /// # Example
75    ///
76    /// ```rust
77    /// # use freya::prelude::*;
78    /// fn app() -> impl IntoElement {
79    ///     rect()
80    ///         .child(ContextMenuViewer::new())
81    ///         .child(Button::new().child("Open menu").on_press(|_| {
82    ///             ContextMenu::open(Menu::new().child(MenuButton::new().child("Copy")));
83    ///         }))
84    /// }
85    /// ```
86    pub fn open(menu: Menu) {
87        Self::open_with_phase(menu, ClosePhase::Idle);
88    }
89
90    /// Open the context menu from a pointer down event, like `on_secondary_down`.
91    ///
92    /// # Example
93    ///
94    /// ```rust
95    /// # use freya::prelude::*;
96    /// fn app() -> impl IntoElement {
97    ///     rect().child(ContextMenuViewer::new()).child(
98    ///         rect().child("Right click here").on_secondary_down(|_| {
99    ///             ContextMenu::open_from_down(Menu::new().child(MenuButton::new().child("Copy")));
100    ///         }),
101    ///     )
102    /// }
103    /// ```
104    pub fn open_from_down(menu: Menu) {
105        Self::open_with_phase(menu, ClosePhase::PendingPress);
106    }
107
108    /// Opens the menu at the current pointer location with the given closing phase.
109    fn open_with_phase(menu: Menu, phase: ClosePhase) {
110        let mut this = try_consume_root_context::<Self>()
111            .expect("ContextMenu requires a `ContextMenuViewer` in an ancestor scope");
112        this.menu.set(Some(((this.location)(), menu)));
113        this.close_phase.set(phase);
114    }
115
116    /// Closes the context menu, doing nothing if no viewer is mounted.
117    ///
118    /// # Example
119    ///
120    /// ```rust
121    /// # use freya::prelude::*;
122    /// fn app() -> impl IntoElement {
123    ///     rect().child(ContextMenuViewer::new()).child(
124    ///         Button::new()
125    ///             .child("Close menu")
126    ///             .on_press(|_| ContextMenu::close()),
127    ///     )
128    /// }
129    /// ```
130    pub fn close() {
131        if let Some(mut this) = try_consume_root_context::<Self>() {
132            this.menu.set(None);
133        }
134    }
135}
136
137/// Provides the [`ContextMenu`] state and renders the floating menu overlay.
138///
139/// Mount this as high up in your tree as possible (typically in your `app`
140/// component) so the rendered menu inherits styling like `font_size` from
141/// the app's root element.
142///
143/// # Example
144///
145/// ```rust
146/// # use freya::prelude::*;
147/// fn app() -> impl IntoElement {
148///     rect()
149///         .font_size(18.)
150///         .child(ContextMenuViewer::new())
151///         .child("Your app content here")
152/// }
153/// ```
154#[derive(Default, Clone, PartialEq)]
155pub struct ContextMenuViewer {
156    key: DiffKey,
157}
158
159impl KeyExt for ContextMenuViewer {
160    fn key_mut(&mut self) -> &mut DiffKey {
161        &mut self.key
162    }
163}
164
165impl ContextMenuViewer {
166    pub fn new() -> Self {
167        Self::default()
168    }
169}
170
171impl ComponentOwned for ContextMenuViewer {
172    fn render(self) -> impl IntoElement {
173        let mut context = use_hook(|| {
174            try_consume_root_context::<ContextMenu>().unwrap_or_else(|| {
175                let state = ContextMenu {
176                    location: State::create_in_scope(CursorPoint::default(), ScopeId::ROOT),
177                    menu: State::create_in_scope(None, ScopeId::ROOT),
178                    close_phase: State::create_in_scope(ClosePhase::Idle, ScopeId::ROOT),
179                };
180                provide_context_for_scope_id(state, ScopeId::ROOT);
181                state
182            })
183        });
184
185        use_side_effect(move || {
186            if !*Platform::get().current_window().is_app_focused.read() {
187                context.menu.set(None);
188            }
189        });
190
191        rect()
192            .on_global_pointer_move(move |e: Event<PointerEventData>| {
193                context.location.set(e.global_location());
194            })
195            .on_global_pointer_down(move |_: Event<PointerEventData>| {
196                if context.menu.read().is_some() {
197                    let phase = match (context.close_phase)() {
198                        ClosePhase::PendingPress => ClosePhase::Idle,
199                        _ => ClosePhase::CloseAllowed,
200                    };
201                    context.close_phase.set(phase);
202                }
203            })
204            .maybe_child(context.menu.read().clone().map(|(location, menu)| {
205                let location = location.to_f32();
206                rect()
207                    .layer(Layer::Overlay)
208                    .position(
209                        Position::new_global()
210                            .left(location.x.round())
211                            .top(location.y.round()),
212                    )
213                    .child(
214                        menu.on_close(move |_| {
215                            if (context.close_phase)() == ClosePhase::CloseAllowed {
216                                context.menu.set(None);
217                            }
218                        })
219                        .on_escape(move |_| {
220                            context.menu.set(None);
221                        }),
222                    )
223            }))
224    }
225
226    fn render_key(&self) -> DiffKey {
227        self.key.clone().or(self.default_key())
228    }
229}