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::pointer_router::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::intent::Intent;
65use teksilo_core::shortcut::StandardEdit;
66use teksilo_core::widget::Widget;
67use teksilo_core::widget_builder::ContextMenuTrigger;
68
69use crate::menu_item::MenuItem;
70use crate::menu_list::MenuList;
71
72use super::clipboard as rt_clipboard;
73use super::policy::{ClipboardPolicy, EditCommandKind, PolicyBundle};
74use super::state::SharedState;
75
76/// Intent fired when the user activates the **Cut** item of the
77/// built-in context menu. Reserved for the framework — applications
78/// that want bespoke cut semantics should replace the menu via
79/// [`RichTextEditor::context_menu`](super::RichTextEditor::context_menu)
80/// rather than registering a custom `Action` against this name.
81pub const INTENT_CUT: &str = "teksilo.rich_text.cut";
82
83/// Intent fired by the built-in Copy menu item.
84pub const INTENT_COPY: &str = "teksilo.rich_text.copy";
85
86/// Intent fired by the built-in Paste menu item.
87pub const INTENT_PASTE: &str = "teksilo.rich_text.paste";
88
89/// Intent fired by the built-in Paste Unformatted menu item.
90pub const INTENT_PASTE_UNFORMATTED: &str = "teksilo.rich_text.paste_unformatted";
91
92/// Intent fired by the built-in Select All menu item.
93pub const INTENT_SELECT_ALL: &str = "teksilo.rich_text.select_all";
94
95/// Build the default context-menu factory for the given editor
96/// state and policy.
97///
98/// The returned closure is installed on the editor's arena node via
99/// [`HandlerSet::context_menu`](teksilo_core::widget_builder::HandlerSet::context_menu);
100/// the framework calls it on each right-click to produce a **fresh**
101/// menu subtree. That freshness matters: each invocation recomputes
102/// item enabled-state from the live editor state + live clipboard at
103/// the instant the user right-clicks, so greyed entries never lie.
104pub(super) fn default_factory(state: SharedState) -> RichTextContextMenuFactory {
105    // The closure is called each right-click. `state` is captured
106    // once and cloned for each menu item's action closure; the
107    // `Rc<RefCell<...>>` behind `SharedState` makes that cheap. The
108    // built-in menu is unconditional — it always returns
109    // `Some(menu)`, whatever the position. Callers needing a
110    // position-aware menu install their own via
111    // `RichTextEditor::context_menu`.
112    Box::new(move |pos, ctx| {
113        // Reposition the caret to the click point (unless it lands inside the
114        // current selection) so Paste — and every other item — acts where the
115        // user right-clicked, matching the single-line field and the platform
116        // convention. Without this a right-click leaves the caret wherever it
117        // last was, and Paste inserted there instead of under the cursor.
118        //
119        // A pointer's point only. The keyboard's menu is anchored in the
120        // middle of the editor, and moving the caret there sent the menu's
121        // Paste to a place the user had never been.
122        if ctx
123            .context_menu_trigger()
124            .is_none_or(ContextMenuTrigger::is_pointer)
125        {
126            super::mouse::reposition_caret_for_context_menu(&state, pos);
127        }
128        let state_for_build = state.clone();
129        Some(Box::new(build_menu(state_for_build)) as Box<dyn Widget>)
130    })
131}
132
133/// Construct the `MenuList` for the current editor / policy state.
134/// Called from the factory on every right-click.
135fn build_menu(state: SharedState) -> MenuList {
136    let mut list = MenuList::new();
137
138    // Read the policy **now**, not at build time: the command filter is
139    // swappable on a mounted editor (`RichTextEditor::set_command_filter`), and
140    // a menu built from a stale snapshot would keep offering Cut after the host
141    // switched the surface to forward-only drafting.
142    let policy: PolicyBundle = state.borrow().policy;
143
144    let has_selection = state.borrow().cursor.has_selection();
145    let doc_non_empty = !state
146        .borrow()
147        .document
148        .to_plain_text()
149        .unwrap_or_default()
150        .is_empty();
151
152    // --- Cut -----------------------------------------------------
153    // Two independent gates, and both are load-bearing: `ClipboardPolicy`
154    // answers "does this surface have a clipboard at all", while the command
155    // filter answers "may this surface take text away". A forward-only editor
156    // says yes to the first and no to the second — checking only the clipboard
157    // policy would leave a working Cut in the menu of an editor whose Ctrl+X is
158    // blocked.
159    if policy.clipboard_policy.allows_cut() && policy.command_filter.accepts(EditCommandKind::Cut) {
160        list = list.item(row_cut(&state).enabled(has_selection));
161    }
162
163    // --- Copy ----------------------------------------------------
164    list = list.item(row_copy(&state).enabled(has_selection));
165
166    // --- Paste ---------------------------------------------------
167    // Availability: at factory-call time, probe the clipboard handle
168    // via the EventContext path. But here, inside the factory, we have
169    // no EventContext. We can only check what the editor itself knows
170    // — the stashed `rich_clipboard_fragment` isn't the right signal.
171    // Leave Paste always enabled when the policy allows; the closure
172    // itself silently no-ops if the clipboard is empty (matches the
173    // existing Ctrl+V behaviour).
174    if policy.clipboard_policy.allows_paste() {
175        list = list.item(row_paste(&state));
176    }
177
178    // --- Paste Unformatted ---------------------------------------
179    if policy.clipboard_policy.allows_paste_unformatted() {
180        let state_for_pu = state.clone();
181        list = list.item(
182            MenuItem::new(tr_widget!(menu_paste_unformatted()))
183                .for_shortcut(StandardEdit::PastePlain.id())
184                .on_activate_fn(move |evt_ctx| {
185                    let mut st = state_for_pu.borrow_mut();
186                    rt_clipboard::paste_unformatted(&mut st, evt_ctx);
187                    drop(st);
188                    super::sync_cursor_signals(&state_for_pu);
189                    evt_ctx.request_frame();
190                    evt_ctx.send_intent(Intent::new(INTENT_PASTE_UNFORMATTED));
191                }),
192        );
193    }
194
195    // --- Toggle blockquote ---------------------------------------
196    // Only meaningful when the editor accepts mutations (read-only
197    // presets get the minimal menu — Cut/Copy/Paste/Select-All).
198    if policy
199        .command_filter
200        .accepts(EditCommandKind::ToggleBlockquote)
201    {
202        let state_for_bq = state.clone();
203        // Greyed out where the toggle would do nothing: a selection across a
204        // frame boundary, or a wrap that would take a quote past the nesting
205        // ceiling. `toggle_blockquote` itself stops by the same rule.
206        let available = super::nesting::toggle_is_available(&state.borrow());
207        let in_quote = state.borrow().cursor.is_in_blockquote();
208        let label = if in_quote {
209            tr_widget!(menu_remove_blockquote())
210        } else {
211            tr_widget!(menu_toggle_blockquote())
212        };
213        list = list.separator();
214        list = list.item(
215            MenuItem::new(label)
216                .enabled(available)
217                .on_activate_fn(move |evt_ctx| {
218                    super::nesting::toggle_blockquote(&state_for_bq.borrow());
219                    super::sync_cursor_signals(&state_for_bq);
220                    evt_ctx.request_frame();
221                }),
222        );
223    }
224
225    // Separator before Select All under the full policy; read-only
226    // presets keep the menu minimal (no separator).
227    if matches!(policy.clipboard_policy, ClipboardPolicy::Full) {
228        list = list.separator();
229    }
230
231    // --- Select All ----------------------------------------------
232    list = list.item(row_select_all(&state).enabled(doc_non_empty));
233
234    list
235}
236
237// ---------------------------------------------------------------------------
238// The four rows, built once and used twice
239// ---------------------------------------------------------------------------
240//
241// The right-click menu and the touch **selection toolbar** offer the same four
242// commands, so they are the same four rows. Each builder returns the row without
243// an `enabled` gate: the menu adds one (a greyed row is the desktop convention),
244// while the toolbar *omits* a command it cannot offer (which is what every
245// platform's touch toolbar does) through `MenuList::item_when`. See
246// `docs/text-touch-editing.md`.
247
248/// Cut the selection.
249fn row_cut(state: &SharedState) -> MenuItem {
250    let state = state.clone();
251    MenuItem::new(tr_widget!(menu_cut()))
252        .for_shortcut(StandardEdit::Cut.id())
253        .on_activate_fn(move |evt_ctx| {
254            let mut st = state.borrow_mut();
255            rt_clipboard::cut(&mut st, evt_ctx);
256            drop(st);
257            super::sync_cursor_signals(&state);
258            evt_ctx.request_frame();
259            evt_ctx.send_intent(Intent::new(INTENT_CUT));
260        })
261}
262
263/// Copy the selection.
264fn row_copy(state: &SharedState) -> MenuItem {
265    let state = state.clone();
266    MenuItem::new(tr_widget!(menu_copy()))
267        .for_shortcut(StandardEdit::Copy.id())
268        .on_activate_fn(move |evt_ctx| {
269            let mut st = state.borrow_mut();
270            rt_clipboard::copy(&mut st, evt_ctx);
271            drop(st);
272            evt_ctx.send_intent(Intent::new(INTENT_COPY));
273        })
274}
275
276/// Paste at the caret.
277fn row_paste(state: &SharedState) -> MenuItem {
278    let state = state.clone();
279    MenuItem::new(tr_widget!(menu_paste()))
280        .for_shortcut(StandardEdit::Paste.id())
281        .on_activate_fn(move |evt_ctx| {
282            let mut st = state.borrow_mut();
283            rt_clipboard::paste(&mut st, evt_ctx);
284            drop(st);
285            super::sync_cursor_signals(&state);
286            evt_ctx.request_frame();
287            evt_ctx.send_intent(Intent::new(INTENT_PASTE));
288        })
289}
290
291/// Select the whole document.
292fn row_select_all(state: &SharedState) -> MenuItem {
293    let state = state.clone();
294    MenuItem::new(tr_widget!(menu_select_all()))
295        .for_shortcut(StandardEdit::SelectAll.id())
296        .on_activate_fn(move |evt_ctx| {
297            {
298                let mut st = state.borrow_mut();
299                st.cursor
300                    .select(teksilo_text::text_document::SelectionType::Document);
301                st.select_all_level = 0;
302                st.select_all_anchor_cell = None;
303            }
304            super::sync_cursor_signals(&state);
305            evt_ctx.request_frame();
306            evt_ctx.send_intent(Intent::new(INTENT_SELECT_ALL));
307        })
308}
309
310/// The selection toolbar a finger raises: the same four rows, each shown only
311/// while the controller says the surface will honour it **and** this editor's
312/// own policy allows it.
313///
314/// Built once per `build()` rather than per raise, because it is overlay content
315/// and an `EventContext` cannot add widgets. Reactive visibility does the work a
316/// fresh build would: the rows are gated on the controller's published
317/// [`ClipboardActions`](teksilo_core::text_touch::ClipboardActions) — derived
318/// from the surface's own `is_editable` / `allows_copy` / selection — **and** on
319/// the live `PolicyBundle`, which a host may swap on a mounted editor. The
320/// controller cannot see the second: `TextHitSource` reports whether copying is
321/// allowed, but not whether *this* editor's command filter has vetoed Cut or its
322/// clipboard policy has withdrawn Paste.
323pub(super) fn selection_toolbar(
324    state: SharedState,
325    affordances: teksilo_core::text_touch::TextAffordances,
326) -> Box<dyn Widget> {
327    use teksilo_core::text_touch::TextAction;
328    let offers = |action: TextAction, allowed: fn(&PolicyBundle) -> bool| {
329        let aff = affordances.clone();
330        let state = state.clone();
331        affordances.version_signal().map(move |_| {
332            allowed(&state.borrow().policy)
333                && aff.toolbar().is_some_and(|t| t.actions.contains(&action))
334        })
335    };
336    Box::new(
337        MenuList::new()
338            .item_when(
339                row_cut(&state),
340                offers(TextAction::Cut, |p| {
341                    p.clipboard_policy.allows_cut()
342                        && p.command_filter.accepts(EditCommandKind::Cut)
343                }),
344            )
345            .item_when(
346                row_copy(&state),
347                offers(TextAction::Copy, |p| {
348                    p.clipboard_policy.allows_copy()
349                        && p.command_filter.accepts(EditCommandKind::Copy)
350                }),
351            )
352            .item_when(
353                row_paste(&state),
354                offers(TextAction::Paste, |p| p.clipboard_policy.allows_paste()),
355            )
356            .item_when(
357                row_select_all(&state),
358                offers(TextAction::SelectAll, |_| true),
359            ),
360    )
361}
362
363/// Resolve which factory (if any) the editor should install for its
364/// arena node's `context_menu_factory`. Precedence:
365///
366/// 1. **User factory** (supplied via `RichTextEditor::context_menu`) —
367///    always wins when provided. The host app is explicitly replacing
368///    the default.
369/// 2. **Default factory** when `default_context_menu` is enabled
370///    (the default) and no user factory is set.
371/// 3. **No factory** when the host called
372///    `default_context_menu(false)` — right-click bubbles past the
373///    widget unhandled; `context_target_at` remains available so the
374///    app can render its own menu from outside.
375pub(super) fn resolve_factory(
376    user_factory: Option<RichTextContextMenuFactory>,
377    default_enabled: bool,
378    state: SharedState,
379) -> Option<RichTextContextMenuFactory> {
380    if let Some(user) = user_factory {
381        return Some(user);
382    }
383    if default_enabled {
384        return Some(default_factory(state));
385    }
386    None
387}
388
389/// Internal alias — same shape as the framework's
390/// [`teksilo_core::widget_builder::ContextMenuFactory`]. Re-declared
391/// locally so the rich-text module doesn't have to thread the public
392/// alias through every signature.
393pub(super) type RichTextContextMenuFactory = Box<
394    dyn Fn(
395        teksilo_canvas::Point,
396        &mut teksilo_core::widget::EventContext,
397    ) -> Option<Box<dyn Widget>>,
398>;
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn intent_names_use_reserved_teksilo_prefix() {
406        // The `teksilo.` prefix is the framework's reserved namespace
407        // for built-in plumbing; applications that want to register
408        // custom intents should use their own prefix. Locking the
409        // strings makes any rename a deliberate, breaking change.
410        assert_eq!(INTENT_CUT, "teksilo.rich_text.cut");
411        assert_eq!(INTENT_COPY, "teksilo.rich_text.copy");
412        assert_eq!(INTENT_PASTE, "teksilo.rich_text.paste");
413        assert_eq!(
414            INTENT_PASTE_UNFORMATTED,
415            "teksilo.rich_text.paste_unformatted"
416        );
417        assert_eq!(INTENT_SELECT_ALL, "teksilo.rich_text.select_all");
418    }
419}