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}