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    /// # Panics
53    ///
54    /// Panics if no [`ContextMenuViewer`] is mounted in an ancestor scope.
55    #[track_caller]
56    pub fn get() -> Self {
57        try_consume_root_context()
58            .expect("ContextMenu requires a `ContextMenuViewer` in an ancestor scope")
59    }
60
61    pub fn is_open() -> bool {
62        try_consume_root_context::<Self>().is_some_and(|c| c.menu.read().is_some())
63    }
64
65    /// Open the context menu, from a press event or programmatically.
66    pub fn open(menu: Menu) {
67        Self::open_with_phase(menu, ClosePhase::Idle);
68    }
69
70    /// Open the context menu from a pointer down event, like `on_secondary_down`.
71    pub fn open_from_down(menu: Menu) {
72        Self::open_with_phase(menu, ClosePhase::PendingPress);
73    }
74
75    fn open_with_phase(menu: Menu, phase: ClosePhase) {
76        let mut this = Self::get();
77        this.menu.set(Some(((this.location)(), menu)));
78        this.close_phase.set(phase);
79    }
80
81    pub fn close() {
82        if let Some(mut this) = try_consume_root_context::<Self>() {
83            this.menu.set(None);
84        }
85    }
86}
87
88/// Provides the [`ContextMenu`] state and renders the floating menu overlay.
89///
90/// Mount this as high up in your tree as possible (typically in your `app`
91/// component) so the rendered menu inherits styling like `font_size` from
92/// the app's root element.
93///
94/// # Example
95///
96/// ```rust
97/// # use freya::prelude::*;
98/// fn app() -> impl IntoElement {
99///     rect()
100///         .font_size(18.)
101///         .child(ContextMenuViewer::new())
102///         .child("Your app content here")
103/// }
104/// ```
105#[derive(Default, Clone, PartialEq)]
106pub struct ContextMenuViewer {
107    key: DiffKey,
108}
109
110impl KeyExt for ContextMenuViewer {
111    fn write_key(&mut self) -> &mut DiffKey {
112        &mut self.key
113    }
114}
115
116impl ContextMenuViewer {
117    pub fn new() -> Self {
118        Self::default()
119    }
120}
121
122impl ComponentOwned for ContextMenuViewer {
123    fn render(self) -> impl IntoElement {
124        let mut context = use_hook(|| {
125            try_consume_root_context::<ContextMenu>().unwrap_or_else(|| {
126                let state = ContextMenu {
127                    location: State::create_in_scope(CursorPoint::default(), ScopeId::ROOT),
128                    menu: State::create_in_scope(None, ScopeId::ROOT),
129                    close_phase: State::create_in_scope(ClosePhase::Idle, ScopeId::ROOT),
130                };
131                provide_context_for_scope_id(state, ScopeId::ROOT);
132                state
133            })
134        });
135
136        use_side_effect(move || {
137            if !*Platform::get().is_app_focused.read() {
138                context.menu.set(None);
139            }
140        });
141
142        rect()
143            .on_global_pointer_move(move |e: Event<PointerEventData>| {
144                context.location.set(e.global_location());
145            })
146            .on_global_pointer_down(move |_: Event<PointerEventData>| {
147                if context.menu.read().is_some() {
148                    let phase = match (context.close_phase)() {
149                        ClosePhase::PendingPress => ClosePhase::Idle,
150                        _ => ClosePhase::CloseAllowed,
151                    };
152                    context.close_phase.set(phase);
153                }
154            })
155            .maybe_child(context.menu.read().clone().map(|(location, menu)| {
156                let location = location.to_f32();
157                rect()
158                    .layer(Layer::Overlay)
159                    .position(
160                        Position::new_global()
161                            .left(location.x.round())
162                            .top(location.y.round()),
163                    )
164                    .child(
165                        menu.on_close(move |_| {
166                            if (context.close_phase)() == ClosePhase::CloseAllowed {
167                                context.menu.set(None);
168                            }
169                        })
170                        .on_escape(move |_| {
171                            context.menu.set(None);
172                        }),
173                    )
174            }))
175    }
176
177    fn render_key(&self) -> DiffKey {
178        self.key.clone().or(self.default_key())
179    }
180}