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}