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}