Skip to main content

inset_services/
system_context_menu.rs

1//! Flutter counterpart: `SystemContextMenuController` and
2//! `IOSSystemContextMenuItemData*` in `services/text_input.dart`.
3
4use std::collections::HashMap;
5use std::fmt::{self, Debug};
6
7use inset_embedder::{Rect, SystemContextMenuItem};
8use inset_foundation::{App, Handle, Listener};
9
10use crate::text_input::TextInput;
11
12/// Describes a context menu button that will be rendered in the system context
13/// menu.
14///
15/// Dart's sealed `IOSSystemContextMenuItemData` subclasses are the variants.
16#[derive(Clone, Debug)]
17pub enum IOSSystemContextMenuItemData {
18    /// The system's built-in copy button.
19    Copy,
20    /// The system's built-in cut button.
21    Cut,
22    /// The system's built-in paste button.
23    Paste,
24    /// The system's built-in select-all button.
25    SelectAll,
26    /// The system's built-in look-up button.
27    LookUp {
28        /// Localized title.
29        title: String,
30    },
31    /// The system's built-in search-web button.
32    SearchWeb {
33        /// Localized title.
34        title: String,
35    },
36    /// The system's built-in share button.
37    Share {
38        /// Localized title.
39        title: String,
40    },
41    /// The system's built-in Live Text button.
42    LiveText,
43    /// A developer-defined button.
44    Custom {
45        /// Title shown in the menu.
46        title: String,
47        /// Callback when the item is selected.
48        on_pressed: Listener,
49        /// Id sent to the host and back on custom action.
50        callback_id: String,
51    },
52}
53
54impl IOSSystemContextMenuItemData {
55    /// The text to display to the user.
56    ///
57    /// Not exposed for some built-in menu items whose title is always set by the
58    /// platform.
59    pub fn title(&self) -> Option<&str> {
60        match self {
61            Self::LookUp { title }
62            | Self::SearchWeb { title }
63            | Self::Share { title }
64            | Self::Custom { title, .. } => Some(title.as_str()),
65            _ => None,
66        }
67    }
68
69    fn to_host(&self) -> SystemContextMenuItem {
70        match self {
71            Self::Copy => SystemContextMenuItem::Copy,
72            Self::Cut => SystemContextMenuItem::Cut,
73            Self::Paste => SystemContextMenuItem::Paste,
74            Self::SelectAll => SystemContextMenuItem::SelectAll,
75            Self::LookUp { title } => SystemContextMenuItem::LookUp {
76                title: title.clone(),
77            },
78            Self::SearchWeb { title } => SystemContextMenuItem::SearchWeb {
79                title: title.clone(),
80            },
81            Self::Share { title } => SystemContextMenuItem::Share {
82                title: title.clone(),
83            },
84            Self::LiveText => SystemContextMenuItem::LiveText,
85            Self::Custom {
86                title, callback_id, ..
87            } => SystemContextMenuItem::Custom {
88                id: callback_id.clone(),
89                title: title.clone(),
90            },
91        }
92    }
93}
94
95impl PartialEq for IOSSystemContextMenuItemData {
96    fn eq(&self, other: &IOSSystemContextMenuItemData) -> bool {
97        match (self, other) {
98            (Self::Copy, Self::Copy)
99            | (Self::Cut, Self::Cut)
100            | (Self::Paste, Self::Paste)
101            | (Self::SelectAll, Self::SelectAll)
102            | (Self::LiveText, Self::LiveText) => true,
103            (Self::LookUp { title: a }, Self::LookUp { title: b })
104            | (Self::SearchWeb { title: a }, Self::SearchWeb { title: b })
105            | (Self::Share { title: a }, Self::Share { title: b }) => a == b,
106            (
107                Self::Custom {
108                    title: a,
109                    on_pressed: a_cb,
110                    callback_id: a_id,
111                },
112                Self::Custom {
113                    title: b,
114                    on_pressed: b_cb,
115                    callback_id: b_id,
116                },
117            ) => a == b && a_cb == b_cb && a_id == b_id,
118            _ => false,
119        }
120    }
121}
122
123/// Dart's `SystemContextMenuController._lastShown` and
124/// `ServicesBinding.systemContextMenuClient`.
125#[derive(Default)]
126struct SystemContextMenuRegistry {
127    last_shown: Option<Handle<SystemContextMenuController>>,
128}
129
130impl SystemContextMenuRegistry {
131    fn instance(app: &mut App) -> Handle<SystemContextMenuRegistry> {
132        app.singleton::<SystemContextMenuRegistry>()
133    }
134}
135
136/// Allows access to the system context menu.
137///
138/// The context menu is the menu that appears, for example, when doing text
139/// selection. Flutter typically draws this menu itself, but on some platforms
140/// this class calls a platform method to show the system-drawn context menu
141/// instead.
142///
143/// There can only be one system context menu visible at a time. Calling [show]
144/// on one instance of this class while another instance is already shown will
145/// hide the old one.
146///
147/// Call [dispose] when no longer needed.
148pub struct SystemContextMenuController {
149    /// Called when the system has hidden the context menu.
150    pub on_system_hide: Option<Listener>,
151    last_target_rect: Option<Rect>,
152    last_items: Option<Vec<IOSSystemContextMenuItemData>>,
153    hidden_by_system: bool,
154    disposed: bool,
155    custom_action_callbacks: HashMap<String, Listener>,
156}
157
158impl Debug for SystemContextMenuController {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.debug_struct("SystemContextMenuController")
161            .field("hidden_by_system", &self.hidden_by_system)
162            .field("disposed", &self.disposed)
163            .finish_non_exhaustive()
164    }
165}
166
167impl SystemContextMenuController {
168    /// Creates an instance of [`SystemContextMenuController`].
169    ///
170    /// Not shown until [`show`](Self::show) is called.
171    pub fn new(app: &mut App, on_system_hide: Option<Listener>) -> Handle<Self> {
172        app.create(SystemContextMenuController {
173            on_system_hide,
174            last_target_rect: None,
175            last_items: None,
176            hidden_by_system: false,
177            disposed: false,
178            custom_action_callbacks: HashMap::new(),
179        })
180    }
181
182    fn is_visible(self: Handle<Self>, app: &mut App) -> bool {
183        let registry = SystemContextMenuRegistry::instance(app);
184        app.get(registry).last_shown == Some(self) && !app.get(self).hidden_by_system
185    }
186
187    /// Called when the system has hidden the context menu.
188    pub fn handle_system_hide(self: Handle<Self>, app: &mut App) {
189        debug_assert!(!app.get(self).disposed);
190        if app.get(self).disposed || !self.is_visible(app) {
191            return;
192        }
193        let registry = SystemContextMenuRegistry::instance(app);
194        if app.get(registry).last_shown == Some(self) {
195            app.get_mut(registry).last_shown = None;
196        }
197        app.get_mut(self).hidden_by_system = true;
198        app.get_mut(self).custom_action_callbacks.clear();
199        if let Some(on_hide) = app.get(self).on_system_hide.clone() {
200            on_hide.call(app);
201        }
202    }
203
204    /// Called when the user selects a custom menu item.
205    pub fn handle_custom_context_menu_action(self: Handle<Self>, app: &mut App, callback_id: &str) {
206        let callback = app
207            .get(self)
208            .custom_action_callbacks
209            .get(callback_id)
210            .cloned();
211        debug_assert!(
212            callback.is_some(),
213            "Custom action callback not found for id: {callback_id}. \
214             This may indicate that the menu item was not properly registered."
215        );
216        if let Some(callback) = callback {
217            callback.call(app);
218        }
219    }
220
221    /// Shows the system context menu anchored on the given [`Rect`].
222    ///
223    /// Currently this system context menu is bound to text input. Using this
224    /// without an active `TextInputConnection` will be a noop.
225    #[deprecated(note = "Use `show_with_items` instead.")]
226    pub fn show(self: Handle<Self>, app: &mut App, target_rect: Rect) {
227        debug_assert!(!app.get(self).disposed);
228        debug_assert!(
229            TextInput::instance(app).current_connection(app).is_some(),
230            "Currently, the system context menu can only be shown for an active text input connection"
231        );
232        if self.is_visible(app) && app.get(self).last_target_rect == Some(target_rect) {
233            return;
234        }
235        debug_assert!(
236            {
237                let registry = SystemContextMenuRegistry::instance(app);
238                let last = app.get(registry).last_shown;
239                last.is_none()
240                    || last == Some(self)
241                    || last.is_some_and(|last| !last.is_visible(app))
242            },
243            "Attempted to show while another instance was still visible."
244        );
245        let registry = SystemContextMenuRegistry::instance(app);
246        app.get_mut(self).last_target_rect = Some(target_rect);
247        app.get_mut(registry).last_shown = Some(self);
248        app.get_mut(self).hidden_by_system = false;
249        if let Some(menu) = app.platform().system_context_menu() {
250            menu.show(target_rect, None);
251        }
252    }
253
254    /// Shows the system context menu anchored on the given [`Rect`] with the given
255    /// buttons.
256    pub fn show_with_items(
257        self: Handle<Self>,
258        app: &mut App,
259        target_rect: Rect,
260        items: Vec<IOSSystemContextMenuItemData>,
261    ) {
262        debug_assert!(!app.get(self).disposed);
263        debug_assert!(!items.is_empty());
264        debug_assert!(
265            TextInput::instance(app).current_connection(app).is_some(),
266            "Currently, the system context menu can only be shown for an active text input connection"
267        );
268        if self.is_visible(app)
269            && app.get(self).last_target_rect == Some(target_rect)
270            && app.get(self).last_items.as_ref() == Some(&items)
271        {
272            return;
273        }
274        debug_assert!(
275            {
276                let registry = SystemContextMenuRegistry::instance(app);
277                let last = app.get(registry).last_shown;
278                last.is_none()
279                    || last == Some(self)
280                    || last.is_some_and(|last| !last.is_visible(app))
281            },
282            "Attempted to show while another instance was still visible."
283        );
284
285        app.get_mut(self).custom_action_callbacks.clear();
286        for item in &items {
287            if let IOSSystemContextMenuItemData::Custom {
288                callback_id,
289                on_pressed,
290                ..
291            } = item
292            {
293                app.get_mut(self)
294                    .custom_action_callbacks
295                    .insert(callback_id.clone(), on_pressed.clone());
296            }
297        }
298        let host_items: Vec<SystemContextMenuItem> =
299            items.iter().map(|item| item.to_host()).collect();
300        app.get_mut(self).last_target_rect = Some(target_rect);
301        app.get_mut(self).last_items = Some(items);
302        let registry = SystemContextMenuRegistry::instance(app);
303        app.get_mut(registry).last_shown = Some(self);
304        app.get_mut(self).hidden_by_system = false;
305        if let Some(menu) = app.platform().system_context_menu() {
306            menu.show(target_rect, Some(&host_items));
307        }
308    }
309
310    /// Hides this system context menu.
311    pub fn hide(self: Handle<Self>, app: &mut App) {
312        debug_assert!(!app.get(self).disposed);
313        let registry = SystemContextMenuRegistry::instance(app);
314        if app.get(registry).last_shown != Some(self) {
315            return;
316        }
317        app.get_mut(registry).last_shown = None;
318        app.get_mut(self).custom_action_callbacks.clear();
319        if let Some(menu) = app.platform().system_context_menu() {
320            menu.hide();
321        }
322    }
323
324    /// Used to release resources when this instance will never be used again.
325    pub fn dispose(self: Handle<Self>, app: &mut App) {
326        debug_assert!(!app.get(self).disposed);
327        self.hide(app);
328        app.get_mut(self).disposed = true;
329    }
330
331    /// Dispatch a system-hide from the host to the currently shown controller.
332    pub fn dispatch_system_hide(app: &mut App) {
333        let registry = SystemContextMenuRegistry::instance(app);
334        if let Some(shown) = app.get(registry).last_shown {
335            shown.handle_system_hide(app);
336        }
337    }
338
339    /// Dispatch a custom action from the host to the currently shown controller.
340    pub fn dispatch_custom_action(app: &mut App, callback_id: &str) {
341        let registry = SystemContextMenuRegistry::instance(app);
342        if let Some(shown) = app.get(registry).last_shown {
343            shown.handle_custom_context_menu_action(app, callback_id);
344        }
345    }
346}