Skip to main content

teksilo_widgets/rich_text/
context_menu.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default right-click context menu for `RichTextEditor`.
5//!
6//! Uses the framework's built-in
7//! [`context_menu(factory)`](teksilo_core::widget_builder::WidgetBuilder::context_menu)
8//! infrastructure: a fresh menu widget is created on every right-click,
9//! shown at the pointer position, and torn down on dismissal. That
10//! machinery lives in `teksilo-core::widget_tree::event_dispatch_impl::show_context_menu_for`
11//! — we just hand it a factory.
12//!
13//! ## Why no Actions / Intents for the built-in items
14//!
15//! The framework's `show_context_menu_for` adds the menu widget at the
16//! **top of the arena** (via `add_boxed`). It is *not* a child of the
17//! editor. So an intent fired from a menu item walks up the menu's own
18//! subtree and terminates there — it never reaches any `Action`
19//! registered on the editor.
20//!
21//! Additionally, when the menu dismisses (the tap's default behaviour)
22//! the whole subtree flips dormant recursively in the same
23//! `collect_from_ctx` call, *before* the tree's pending-intent queue
24//! drains. Any `Action` inside the dormant subtree is skipped by
25//! `dispatch_intent`'s `is_active` gate.
26//!
27//! Both problems go away by **not using Actions for the default menu**.
28//! Each `MenuItem`'s `on_activate_fn` closure captures a clone of the
29//! editor's [`SharedState`] and calls the corresponding
30//! `rt_clipboard::*` function directly. The work happens inline, during
31//! the tap handler, while the menu subtree is still active — no
32//! dispatch-timing concerns at all.
33//!
34//! ## Reserved intent names (external observation only)
35//!
36//! After doing the work directly, each closure also fires a
37//! `teksilo.rich_text.*` intent for applications that want to observe
38//! (e.g. telemetry, undo-stack annotation, clipboard-manager mirroring).
39//! These intents reach ancestor Actions through the normal walk chain —
40//! the intent walk starts from the *editor* (via
41//! `EventContext::send_intent` which anchors on `source_widget`,
42//! which for a PointerDown is the hit widget — but we're dispatching
43//! from inside the menu item, which is top-level, so the walk
44//! terminates without reaching the editor).
45//!
46//! Since the framework's `show_context_menu_for` makes the menu
47//! top-level, intent observation via this path doesn't reach the host
48//! app either. Applications that want to observe or override should
49//! use the slot instead (see `RichTextEditor::context_menu`). The
50//! reserved intent names are reserved for a future reworked dispatch
51//! but are not currently useful — we emit them anyway so the contract
52//! is stable.
53//!
54//! ## Slot-based replacement
55//!
56//! `RichTextEditor::context_menu` accepts a user-provided factory
57//! that replaces the default entirely. The user's factory returns
58//! whatever widget they want — typically a `MenuList`, but any
59//! `Widget` works (a `Panel` with custom chrome, a domain-specific
60//! command palette, etc.).
61
62use teksilo_i18n::tr_widget;
63
64use teksilo_core::event::Key;
65use teksilo_core::intent::Intent;
66use teksilo_core::shortcut::KeyStroke;
67use teksilo_core::widget::Widget;
68
69use crate::keystroke_format::format_keystroke;
70use crate::menu_item::MenuItem;
71use crate::menu_list::MenuList;
72
73use super::clipboard as rt_clipboard;
74use super::policy::{ClipboardPolicy, EditCommandKind, PolicyBundle};
75use super::state::SharedState;
76
77/// Intent fired when the user activates the **Cut** item of the
78/// built-in context menu. Reserved for the framework — applications
79/// that want bespoke cut semantics should replace the menu via
80/// [`RichTextEditor::context_menu`](super::RichTextEditor::context_menu)
81/// rather than registering a custom `Action` against this name.
82pub const INTENT_CUT: &str = "teksilo.rich_text.cut";
83
84/// Intent fired by the built-in Copy menu item.
85pub const INTENT_COPY: &str = "teksilo.rich_text.copy";
86
87/// Intent fired by the built-in Paste menu item.
88pub const INTENT_PASTE: &str = "teksilo.rich_text.paste";
89
90/// Intent fired by the built-in Paste Unformatted menu item.
91pub const INTENT_PASTE_UNFORMATTED: &str = "teksilo.rich_text.paste_unformatted";
92
93/// Intent fired by the built-in Select All menu item.
94pub const INTENT_SELECT_ALL: &str = "teksilo.rich_text.select_all";
95
96/// Build the default context-menu factory for the given editor
97/// state and policy.
98///
99/// The returned closure is installed on the editor's arena node via
100/// [`HandlerSet::context_menu`](teksilo_core::widget_builder::HandlerSet::context_menu);
101/// the framework calls it on each right-click to produce a **fresh**
102/// menu subtree. That freshness matters: each invocation recomputes
103/// item enabled-state from the live editor state + live clipboard at
104/// the instant the user right-clicks, so greyed entries never lie.
105pub(super) fn default_factory(state: SharedState) -> RichTextContextMenuFactory {
106    // The closure is called each right-click. `state` is captured
107    // once and cloned for each menu item's action closure; the
108    // `Rc<RefCell<...>>` behind `SharedState` makes that cheap. The
109    // built-in menu is unconditional — it always returns
110    // `Some(menu)`, ignoring position and ctx. Callers needing a
111    // position-aware menu install their own via
112    // `RichTextEditor::context_menu`.
113    Box::new(move |pos, _ctx| {
114        // Reposition the caret to the click point (unless it lands inside the
115        // current selection) so Paste — and every other item — acts where the
116        // user right-clicked, matching the single-line field and the platform
117        // convention. Without this a right-click leaves the caret wherever it
118        // last was, and Paste inserted there instead of under the cursor.
119        super::mouse::reposition_caret_for_context_menu(&state, pos);
120        let state_for_build = state.clone();
121        Some(Box::new(build_menu(state_for_build)) as Box<dyn Widget>)
122    })
123}
124
125/// Construct the `MenuList` for the current editor / policy state.
126/// Called from the factory on every right-click.
127fn build_menu(state: SharedState) -> MenuList {
128    let mut list = MenuList::new();
129
130    // Read the policy **now**, not at build time: the command filter is
131    // swappable on a mounted editor (`RichTextEditor::set_command_filter`), and
132    // a menu built from a stale snapshot would keep offering Cut after the host
133    // switched the surface to forward-only drafting.
134    let policy: PolicyBundle = state.borrow().policy;
135
136    let has_selection = state.borrow().cursor.has_selection();
137    let doc_non_empty = !state
138        .borrow()
139        .document
140        .to_plain_text()
141        .unwrap_or_default()
142        .is_empty();
143
144    // --- Cut -----------------------------------------------------
145    // Two independent gates, and both are load-bearing: `ClipboardPolicy`
146    // answers "does this surface have a clipboard at all", while the command
147    // filter answers "may this surface take text away". A forward-only editor
148    // says yes to the first and no to the second — checking only the clipboard
149    // policy would leave a working Cut in the menu of an editor whose Ctrl+X is
150    // blocked.
151    if policy.clipboard_policy.allows_cut() && policy.command_filter.accepts(EditCommandKind::Cut) {
152        let state_for_cut = state.clone();
153        list = list.item(
154            MenuItem::new(tr_widget!(menu_cut()))
155                .shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
156                .enabled(has_selection)
157                .on_activate_fn(move |evt_ctx| {
158                    let mut st = state_for_cut.borrow_mut();
159                    rt_clipboard::cut(&mut st, evt_ctx);
160                    drop(st);
161                    super::sync_cursor_signals(&state_for_cut);
162                    evt_ctx.request_frame();
163                    evt_ctx.send_intent(Intent::new(INTENT_CUT));
164                }),
165        );
166    }
167
168    // --- Copy ----------------------------------------------------
169    {
170        let state_for_copy = state.clone();
171        list = list.item(
172            MenuItem::new(tr_widget!(menu_copy()))
173                .shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
174                .enabled(has_selection)
175                .on_activate_fn(move |evt_ctx| {
176                    let mut st = state_for_copy.borrow_mut();
177                    rt_clipboard::copy(&mut st, evt_ctx);
178                    drop(st);
179                    evt_ctx.send_intent(Intent::new(INTENT_COPY));
180                }),
181        );
182    }
183
184    // --- Paste ---------------------------------------------------
185    // Availability: at factory-call time, probe the clipboard handle
186    // via the EventContext path. But here, inside the factory, we have
187    // no EventContext. We can only check what the editor itself knows
188    // — the stashed `rich_clipboard_fragment` isn't the right signal.
189    // Leave Paste always enabled when the policy allows; the closure
190    // itself silently no-ops if the clipboard is empty (matches the
191    // existing Ctrl+V behaviour).
192    if policy.clipboard_policy.allows_paste() {
193        let state_for_paste = state.clone();
194        list = list.item(
195            MenuItem::new(tr_widget!(menu_paste()))
196                .shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
197                .on_activate_fn(move |evt_ctx| {
198                    let mut st = state_for_paste.borrow_mut();
199                    rt_clipboard::paste(&mut st, evt_ctx);
200                    drop(st);
201                    super::sync_cursor_signals(&state_for_paste);
202                    evt_ctx.request_frame();
203                    evt_ctx.send_intent(Intent::new(INTENT_PASTE));
204                }),
205        );
206    }
207
208    // --- Paste Unformatted ---------------------------------------
209    if policy.clipboard_policy.allows_paste_unformatted() {
210        let state_for_pu = state.clone();
211        list = list.item(
212            MenuItem::new(tr_widget!(menu_paste_unformatted()))
213                .shortcut_label(format_keystroke(KeyStroke::command_shift(Key::V)))
214                .on_activate_fn(move |evt_ctx| {
215                    let mut st = state_for_pu.borrow_mut();
216                    rt_clipboard::paste_unformatted(&mut st, evt_ctx);
217                    drop(st);
218                    super::sync_cursor_signals(&state_for_pu);
219                    evt_ctx.request_frame();
220                    evt_ctx.send_intent(Intent::new(INTENT_PASTE_UNFORMATTED));
221                }),
222        );
223    }
224
225    // --- Toggle blockquote ---------------------------------------
226    // Only meaningful when the editor accepts mutations (read-only
227    // presets get the minimal menu — Cut/Copy/Paste/Select-All).
228    if policy
229        .command_filter
230        .accepts(EditCommandKind::ToggleBlockquote)
231    {
232        let state_for_bq = state.clone();
233        let cross_frame_selection = state.borrow().cursor.selection_spans_multiple_frames();
234        let in_quote = state.borrow().cursor.is_in_blockquote();
235        let label = if in_quote {
236            tr_widget!(menu_remove_blockquote())
237        } else {
238            tr_widget!(menu_toggle_blockquote())
239        };
240        list = list.separator();
241        list = list.item(
242            MenuItem::new(label)
243                .enabled(!cross_frame_selection)
244                .on_activate_fn(move |evt_ctx| {
245                    {
246                        let st = state_for_bq.borrow();
247                        let _ = st.cursor.toggle_blockquote();
248                    }
249                    super::sync_cursor_signals(&state_for_bq);
250                    evt_ctx.request_frame();
251                }),
252        );
253    }
254
255    // Separator before Select All under the full policy; read-only
256    // presets keep the menu minimal (no separator).
257    if matches!(policy.clipboard_policy, ClipboardPolicy::Full) {
258        list = list.separator();
259    }
260
261    // --- Select All ----------------------------------------------
262    {
263        let state_for_sa = state.clone();
264        list = list.item(
265            MenuItem::new(tr_widget!(menu_select_all()))
266                .shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
267                .enabled(doc_non_empty)
268                .on_activate_fn(move |evt_ctx| {
269                    {
270                        let mut st = state_for_sa.borrow_mut();
271                        st.cursor
272                            .select(teksilo_text::text_document::SelectionType::Document);
273                        st.select_all_level = 0;
274                        st.select_all_anchor_cell = None;
275                    }
276                    super::sync_cursor_signals(&state_for_sa);
277                    evt_ctx.request_frame();
278                    evt_ctx.send_intent(Intent::new(INTENT_SELECT_ALL));
279                }),
280        );
281    }
282
283    list
284}
285
286/// Resolve which factory (if any) the editor should install for its
287/// arena node's `context_menu_factory`. Precedence:
288///
289/// 1. **User factory** (supplied via `RichTextEditor::context_menu`) —
290///    always wins when provided. The host app is explicitly replacing
291///    the default.
292/// 2. **Default factory** when `default_context_menu` is enabled
293///    (the default) and no user factory is set.
294/// 3. **No factory** when the host called
295///    `default_context_menu(false)` — right-click bubbles past the
296///    widget unhandled; `context_target_at` remains available so the
297///    app can render its own menu from outside.
298pub(super) fn resolve_factory(
299    user_factory: Option<RichTextContextMenuFactory>,
300    default_enabled: bool,
301    state: SharedState,
302) -> Option<RichTextContextMenuFactory> {
303    if let Some(user) = user_factory {
304        return Some(user);
305    }
306    if default_enabled {
307        return Some(default_factory(state));
308    }
309    None
310}
311
312/// Internal alias — same shape as the framework's
313/// [`teksilo_core::widget_builder::ContextMenuFactory`]. Re-declared
314/// locally so the rich-text module doesn't have to thread the public
315/// alias through every signature.
316pub(super) type RichTextContextMenuFactory = Box<
317    dyn Fn(
318        teksilo_canvas::Point,
319        &mut teksilo_core::widget::EventContext,
320    ) -> Option<Box<dyn Widget>>,
321>;
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn intent_names_use_reserved_teksilo_prefix() {
329        // The `teksilo.` prefix is the framework's reserved namespace
330        // for built-in plumbing; applications that want to register
331        // custom intents should use their own prefix. Locking the
332        // strings makes any rename a deliberate, breaking change.
333        assert_eq!(INTENT_CUT, "teksilo.rich_text.cut");
334        assert_eq!(INTENT_COPY, "teksilo.rich_text.copy");
335        assert_eq!(INTENT_PASTE, "teksilo.rich_text.paste");
336        assert_eq!(
337            INTENT_PASTE_UNFORMATTED,
338            "teksilo.rich_text.paste_unformatted"
339        );
340        assert_eq!(INTENT_SELECT_ALL, "teksilo.rich_text.select_all");
341    }
342}