teksilo_widgets/code_editor/widget.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The public editing surfaces: [`CodeEditor`] and [`PlainTextEditor`].
5//!
6//! The wrapper is the focus + event target; it owns the gutter (optional), the
7//! paint-only body, and the overlay scrollbars, joined to them only through the
8//! shared [`CodeEditorState`](super::state::CodeEditorState). This mirrors
9//! `RichTextEditor` exactly — the wrapper carries focus so a future style may
10//! place the body anywhere in its chrome without the focus semantics moving —
11//! and adds the two things a source editor needs on top: a line-number gutter to
12//! the left, and a paint pass that draws the current-line band (across gutter and
13//! body) and the matched-bracket cells behind the text.
14//!
15//! `PlainTextEditor` is the same machinery with the code affordances off and
16//! wrapping on — a notes field, a commit message — so the two never drift.
17//!
18//! ## Pan to scroll
19//!
20//! The surface installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
21//! — the shared wheel arithmetic, a finger's pan, and the `PanClaim`. The wheel
22//! path is unchanged: no tween (these offsets are plain signals), 16 dp a line,
23//! `Ignored` at a hard boundary so the page around it takes the rest, and a
24//! repaint asked for exactly when an axis moved.
25//!
26//! **The claim serves this surface even though it also owns the press
27//! arena**, which its double- and triple-tap recognizers give it. The router
28//! stops its arbitration walk at the press owner only for a `Gesture` member,
29//! whose recognizer the capture dispatch is already driving; a `Pan` member is
30//! decided in that walk and nowhere else, so it is exempt. A finger on the
31//! text therefore scrolls the text, and hands the gesture outward only at this
32//! surface's own boundary. See `docs/kinetic-scrolling.md` §10.1.
33
34use std::cell::Cell;
35use std::rc::Rc;
36
37use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::binding::BindingLevel;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::widget::{
42 CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
43};
44use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
45use teksilo_core::widget_id::WidgetId;
46use teksilo_text::text_document::TextDocument;
47use teksilo_text::{CursorAffinity, WrapMode};
48
49use super::completion::{self, CompletionContext, CompletionItem, CompletionPanel};
50use super::config::{BracketPair, CodeConfig, IndentStyle};
51use super::gutter::CodeGutter;
52use super::policy::{CODE_EDITOR_PRESET, CODE_READ_ONLY_PRESET};
53use super::state::SharedState;
54use super::{CodeEditorHandle, adopt_shared_typesetter, body_for, construct};
55use crate::common::editor_runtime::CaretPolicy;
56use crate::common::scroll::OverscrollBehavior;
57use crate::rich_text::ScrollPolicy;
58use crate::rich_text::touch_mount::ToolbarIntent;
59use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant};
60
61/// Overlay scrollbar thickness, matching the rich-text editor and `ScrollArea`.
62const SCROLLBAR_THICKNESS: f32 = 12.0;
63
64/// A multi-line source-code editing surface: gutter, current-line highlight,
65/// indentation, bracket handling, and multiple carets.
66///
67/// Construct with [`CodeEditor::new`] (editable) or [`CodeEditor::read_only`]
68/// (view + select + copy). Every code affordance is injected configuration, not
69/// a built-in language — see [`CodeConfig`].
70pub struct CodeEditor {
71 pub(super) state: SharedState,
72 v_scroll_policy: ScrollPolicy,
73 h_scroll_policy: ScrollPolicy,
74 overscroll_behavior: OverscrollBehavior,
75 min_lines: Option<u32>,
76 max_lines: Option<u32>,
77 show_gutter: bool,
78
79 // Child ids, filled during `build`.
80 gutter_id: Option<WidgetId>,
81 body_id: Option<WidgetId>,
82 v_scrollbar_id: Option<WidgetId>,
83 h_scrollbar_id: Option<WidgetId>,
84 // Scrollbar window-local bounds, published by `place_children`, read by the
85 // pointer handler to bypass the drag-select latch over an overlay bar.
86 v_scrollbar_bounds: Rc<Cell<Rect>>,
87 h_scrollbar_bounds: Rc<Cell<Rect>>,
88 // Gutter width, published by `place_children`. The bracket cells in `paint`
89 // offset from the body's own `viewport_origin`, which already includes it.
90 gutter_width: Rc<Cell<f32>>,
91 /// The touch-selection mount: the controller, its two overlays, and the
92 /// host intent behind the selection toolbar.
93 ///
94 /// Minted with the widget rather than in `build()` so the ids and the
95 /// toolbar intent survive a rebuild. Inert for a mouse: every entry point
96 /// begins by asking whether the pointer is direct.
97 pub(super) touch: Rc<crate::rich_text::touch_mount::EditorTouch>,
98 /// Install the built-in right-click menu during `build()`. Default `true`;
99 /// [`default_context_menu`](CodeEditor::default_context_menu) turns it off.
100 default_context_menu_enabled: bool,
101 /// A replacement factory, taken (`Option::take`) during `build()` because a
102 /// `Box<dyn Fn>` is not `Clone`.
103 custom_context_menu: Option<super::context_menu::CodeContextMenuFactory>,
104 /// Accessible name, set via [`label`](CodeEditor::label) and applied to
105 /// the body.
106 label: Option<teksilo_i18n::LocalizedString>,
107}
108
109impl std::fmt::Debug for CodeEditor {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.debug_struct("CodeEditor")
112 .field("policy", &self.state.borrow().policy)
113 .field("show_gutter", &self.show_gutter)
114 .finish_non_exhaustive()
115 }
116}
117
118impl CodeEditor {
119 /// An editable code editor bound to `document`: gutter on, current-line
120 /// highlight on, no wrapping. Code affordances (comment token, bracket
121 /// pairs) stay off until the application supplies them — the editor never
122 /// guesses a language.
123 pub fn new(document: TextDocument) -> Self {
124 let this = Self::from_state(construct(
125 document,
126 CODE_EDITOR_PRESET,
127 CodeConfig::default(),
128 WrapMode::None,
129 ));
130 this.state.borrow_mut().current_line_highlight = true;
131 this
132 }
133
134 /// A read-only code viewer bound to `document`: no caret, navigation and
135 /// copy only, `Role::Document`. Still gets the gutter and syntax colours.
136 pub fn read_only(document: TextDocument) -> Self {
137 Self::from_state(construct(
138 document,
139 CODE_READ_ONLY_PRESET,
140 CodeConfig::default(),
141 WrapMode::None,
142 ))
143 }
144
145 fn from_state(state: SharedState) -> Self {
146 let touch = super::touch::mount_for(state.clone());
147 Self {
148 state,
149 v_scroll_policy: ScrollPolicy::Auto,
150 h_scroll_policy: ScrollPolicy::Auto,
151 overscroll_behavior: OverscrollBehavior::default(),
152 min_lines: None,
153 max_lines: None,
154 show_gutter: true,
155 gutter_id: None,
156 body_id: None,
157 v_scrollbar_id: None,
158 h_scrollbar_id: None,
159 v_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
160 h_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
161 gutter_width: Rc::new(Cell::new(0.0)),
162 touch,
163 default_context_menu_enabled: true,
164 custom_context_menu: None,
165 label: None,
166 }
167 }
168
169 // --- Shared builder methods ------------------------------------------
170
171 /// Accessible name for the editor.
172 ///
173 /// Applied to the body that holds the text, which is the node focus is
174 /// published on and the one a screen reader announces ("Code, entry"). The
175 /// editor's own node is structure that no adapter shows. An
176 /// `.access_label(..)`, `.access_labelled_by(..)` or tooltip attached to
177 /// the editor reaches the same node.
178 ///
179 /// Stays locale-reactive: a `tr!(...)` name is re-resolved when the
180 /// locale changes, without a rebuild.
181 pub fn label(mut self, label: impl Into<teksilo_i18n::LocalizedString>) -> Self {
182 self.label = Some(label.into());
183 self
184 }
185
186 /// Replace the built-in right-click menu with `factory`, called on each
187 /// right-click with the **window** position of the click. Returning `None`
188 /// shows no menu.
189 ///
190 /// A replacement is responsible for repositioning the caret if it wants the
191 /// platform convention — the built-in menu does it through
192 /// `context_menu::factory`, and only when a pointer opened the menu
193 /// ([`EventContext::context_menu_trigger`](teksilo_core::widget::EventContext::context_menu_trigger)).
194 /// Shift+F10 hands the factory an anchor, not a place the user chose.
195 pub fn context_menu(
196 mut self,
197 factory: impl Fn(
198 teksilo_canvas::Point,
199 &mut teksilo_core::widget::EventContext,
200 ) -> Option<Box<dyn teksilo_core::widget::Widget>>
201 + 'static,
202 ) -> Self {
203 self.custom_context_menu = Some(Box::new(factory));
204 self
205 }
206
207 /// Whether to install the built-in Cut / Copy / Paste / Select All menu
208 /// (default `true`). `false` lets a right-click bubble past the editor, so an
209 /// application can render its own menu from outside; a factory installed with
210 /// [`context_menu`](Self::context_menu) wins over this either way.
211 ///
212 /// The **touch** selection toolbar is *not* affected. It is raised by the
213 /// controller rather than by a right-click, its rows are the same four
214 /// commands, and a surface with no menu still has to be usable by a finger —
215 /// which has no second button and no chord.
216 pub fn default_context_menu(mut self, enabled: bool) -> Self {
217 self.default_context_menu_enabled = enabled;
218 self
219 }
220
221 /// Set the line-wrap mode. `CodeEditor` defaults to `WrapMode::None` (source
222 /// lines must not fold, or the gutter's one-number-per-line correspondence
223 /// breaks); pair with `.h_scroll_policy(Auto)` to scroll wide lines.
224 pub fn wrap_mode(self, mode: WrapMode) -> Self {
225 {
226 let mut st = self.state.borrow_mut();
227 st.wrap_mode = mode;
228 st.engine.set_wrap_mode(mode);
229 st.needs_full_layout = true;
230 }
231 self
232 }
233
234 /// Vertical scrollbar policy (default `Auto`).
235 pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
236 self.v_scroll_policy = policy;
237 self
238 }
239
240 /// Horizontal scrollbar policy (default `Auto`).
241 pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
242 self.h_scroll_policy = policy;
243 self
244 }
245
246 /// Wheel scroll-chaining at the editor's scroll boundary. `Chain` (default)
247 /// hands leftover scroll to an enclosing scrollable; `Contain` absorbs it.
248 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
249 self.overscroll_behavior = behavior;
250 self
251 }
252
253 /// Cull the render to the visible clip band (default `false`). Turn on only
254 /// for an editor deliberately laid out at full document height inside an
255 /// outer `ScrollArea` (`v_scroll_policy(AlwaysOff)` + `min_lines(1)`): the
256 /// body's bounds then span the whole document, and this renders only the
257 /// on-screen slice instead of every line. A normally-scrolling editor already
258 /// renders just a viewport's worth, so it needs nothing.
259 pub fn window_to_clip(self, on: bool) -> Self {
260 self.state.borrow_mut().window_to_clip = on;
261 self
262 }
263
264 /// Minimum visible height in lines — switches the editor from greedy (fill
265 /// the proposal) to intrinsic sizing (grow with content up to `max_lines`,
266 /// then scroll). The composer pattern.
267 pub fn min_lines(mut self, lines: u32) -> Self {
268 self.min_lines = Some(lines);
269 self
270 }
271
272 /// Maximum visible height in lines — caps intrinsic growth.
273 pub fn max_lines(mut self, lines: u32) -> Self {
274 self.max_lines = Some(lines);
275 self
276 }
277
278 /// Fallback font family for the document's text. `None` (the default) keeps
279 /// the typesetter's registry default; a code editor should pass a monospace
280 /// family so columns line up.
281 pub fn font_family(self, family: impl Into<String>) -> Self {
282 {
283 let mut st = self.state.borrow_mut();
284 let mut d = st.engine.typography_defaults().clone();
285 d.font_family = Some(family.into());
286 st.engine.set_typography_defaults(d);
287 st.needs_full_layout = true;
288 }
289 self
290 }
291
292 /// Per-editor logical font-size multiplier (`1.0` = 100 %), composed with
293 /// the accessibility text scale when [`follow_text_scale`](Self::follow_text_scale)
294 /// is on. Sharp — shapes at a larger ppem.
295 pub fn font_size_scale(self, scale: f32) -> Self {
296 {
297 let mut st = self.state.borrow_mut();
298 st.font_size_scale = scale.clamp(0.1, 10.0);
299 st.last_font_scale = f32::NAN;
300 st.needs_full_layout = true;
301 }
302 self
303 }
304
305 /// Whether the editor grows text with the global accessibility text scale
306 /// (default `true`). Turn off for a WYSIWYG surface whose font sizes are
307 /// document content. Composed with [`font_size_scale`](Self::font_size_scale).
308 pub fn follow_text_scale(self, follow: bool) -> Self {
309 self.state.borrow_mut().follow_text_scale = follow;
310 self
311 }
312
313 /// A callback fired once per drain batch that contained a real content edit.
314 pub fn on_change(self, callback: impl Fn() + 'static) -> Self {
315 self.state.borrow_mut().on_change = Some(Rc::new(callback));
316 self
317 }
318
319 /// Override the editor background colour (accepts `Color`, a theme role, or a
320 /// `Signal`). `None`-equivalent default tracks the theme's `editor_bg`.
321 pub fn background(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
322 self.state.borrow_mut().background_prop = Some(color.into());
323 self
324 }
325
326 /// Override the text colour. Default tracks the theme's `editor_fg`.
327 pub fn text_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
328 self.state.borrow_mut().text_color_prop = Some(color.into());
329 self
330 }
331
332 /// Override the caret colour. Default tracks the theme's `editor_caret`.
333 pub fn caret_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
334 self.state.borrow_mut().caret_color_prop = Some(color.into());
335 self
336 }
337
338 /// Override the selection colour. A pinned colour opts out of the
339 /// window-inactive desaturation.
340 pub fn selection_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
341 self.state.borrow_mut().selection_color_prop = Some(color.into());
342 self
343 }
344
345 // --- Code-only builder methods ---------------------------------------
346
347 /// Whether the line-number gutter is shown (default `true`).
348 pub fn gutter(mut self, show: bool) -> Self {
349 self.show_gutter = show;
350 self
351 }
352
353 /// Whether the caret's line gets a full-width background wash (default
354 /// `true` for `CodeEditor`).
355 pub fn current_line_highlight(self, on: bool) -> Self {
356 self.state.borrow_mut().current_line_highlight = on;
357 self
358 }
359
360 /// Set the indentation style directly (spaces of a width, or tabs rendered a
361 /// width wide).
362 pub fn indent_style(self, style: IndentStyle) -> Self {
363 self.state.borrow_mut().config.indent = style;
364 self
365 }
366
367 /// Set the indent width, keeping the current spaces-vs-tabs kind.
368 pub fn tab_width(self, width: u8) -> Self {
369 {
370 let mut st = self.state.borrow_mut();
371 st.config.indent = match st.config.indent {
372 IndentStyle::Spaces(_) => IndentStyle::Spaces(width),
373 IndentStyle::Tabs { .. } => IndentStyle::Tabs { width },
374 };
375 }
376 self
377 }
378
379 /// Whether indentation is written with spaces (`true`, the default) or a tab
380 /// character (`false`), keeping the current width.
381 pub fn use_soft_tabs(self, soft: bool) -> Self {
382 {
383 let mut st = self.state.borrow_mut();
384 let w = st.config.indent.width();
385 st.config.indent = if soft {
386 IndentStyle::Spaces(w)
387 } else {
388 IndentStyle::Tabs { width: w }
389 };
390 }
391 self
392 }
393
394 /// Whether Enter carries the current line's indentation onto the new line
395 /// (default `true`).
396 pub fn auto_indent(self, on: bool) -> Self {
397 self.state.borrow_mut().config.auto_indent = on;
398 self
399 }
400
401 /// The delimiter pairs the editor auto-closes and match-highlights. Empty
402 /// (the default) disables both.
403 pub fn bracket_pairs(self, pairs: impl Into<Vec<BracketPair>>) -> Self {
404 self.state.borrow_mut().config.brackets = pairs.into();
405 self
406 }
407
408 /// Whether typing an opener inserts its closing partner (default `false`;
409 /// needs configured `bracket_pairs`).
410 pub fn auto_close_brackets(self, on: bool) -> Self {
411 self.state.borrow_mut().config.auto_close_brackets = on;
412 self
413 }
414
415 /// Whether the delimiter matching the caret's is highlighted (default
416 /// `false`; needs configured `bracket_pairs`).
417 pub fn bracket_matching(self, on: bool) -> Self {
418 self.state.borrow_mut().config.match_brackets = on;
419 self
420 }
421
422 /// The token that starts a line comment (`"//"`, `"#"`, `"--"`). Enables
423 /// `Ctrl+/` comment toggling; unset (the default) leaves it a no-op rather
424 /// than guessing.
425 pub fn line_comment(self, token: impl Into<String>) -> Self {
426 self.state.borrow_mut().config.line_comment = Some(token.into());
427 self
428 }
429
430 /// Supply the completion candidates. The provider is called for the word
431 /// being completed and given a [`CompletionContext`]; the editor filters its
432 /// result by the live prefix, shows the popup, and replaces the word on
433 /// accept. Language-agnostic — the app knows the candidates, the editor knows
434 /// the mechanics. Without a provider there is no completion.
435 pub fn completion_provider(
436 self,
437 provider: impl Fn(&CompletionContext) -> Vec<CompletionItem> + 'static,
438 ) -> Self {
439 self.state.borrow_mut().completion.provider = Some(Rc::new(provider));
440 self
441 }
442
443 /// Whether typing an identifier character opens the completion popup
444 /// automatically (default `true`). When off, only `Ctrl+Space` opens it.
445 pub fn auto_complete(self, auto: bool) -> Self {
446 self.state.borrow_mut().completion.auto_trigger = auto;
447 self
448 }
449
450 /// A cloneable handle to drive the editor from a toolbar, shortcut, or test.
451 pub fn handle(&self) -> CodeEditorHandle {
452 CodeEditorHandle::new(self.state.clone())
453 }
454
455 // --- Internal ---------------------------------------------------------
456
457 /// The vertical extent (window y, height) of the caret's line, or `None`
458 /// before a layout exists.
459 fn caret_line_band(st: &super::state::CodeEditorState) -> Option<(f32, f32)> {
460 if !st.engine.has_full_layout() {
461 return None;
462 }
463 let c = st
464 .engine
465 .caret_rect(st.cursor.position(), st.cursor_affinity);
466 let y = st.viewport_origin.y + c[1] - st.scroll_y.get();
467 Some((y, c[3]))
468 }
469}
470
471impl Widget for CodeEditor {
472 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
473 // Tell the framework this widget edits text — registered on *this*
474 // node, the `.focusable(true)` one below, because the registry is keyed
475 // by whichever widget holds the focus. See `teksilo_core::text_surface`.
476 ctx.register_text_surface(std::rc::Rc::new(self.handle()));
477
478 // Swap the private engine for one sharing the app's typesetter (no-op
479 // headless), carrying over builder-set typography.
480 adopt_shared_typesetter(&self.state, ctx);
481
482 {
483 let mut st = self.state.borrow_mut();
484 st.frame_request = Some(ctx.frame_request_handle());
485 st.frame_wake_at = Some(ctx.wake_at_handle());
486 st.self_id = Some(ctx.self_id());
487 // The density ladder the pointer geometry reads. A snapshot, not a
488 // per-event read: `EventContext` exposes no theme, and
489 // `set_input_density` marks at `BindingLevel::Rebuild`, so this
490 // refreshes with the density.
491 st.input_tokens = ctx.theme().input;
492 }
493 // Same dormancy discipline as `RichTextEditor` / `TextInputField`: a
494 // code or plain-text editor parked in a non-selected Switcher branch
495 // must not keep the event loop awake. `PlainTextEditor` is a thin
496 // wrap of this widget, so it inherits the gate for free.
497 let activation = ctx.activation_signal(ctx.self_id());
498 if activation.get() {
499 ctx.request_frame();
500 }
501
502 {
503 let state = self.state.clone();
504 ctx.effect(&activation, move |&active| {
505 if active {
506 // **Re-activated** — re-arm the frame loop. The dormant branch
507 // below does not re-arm `frame_request` (by design: a parked
508 // editor has nothing to paint) and the frame-tick effect is
509 // skipped entirely while dormant, so nothing restarts the tick
510 // on the way back. Only the tick pushes the cursor through to
511 // the engine, so a re-activated editor that is then focused
512 // draws **no caret at all**.
513 //
514 // The in-tree modal path takes this route on every open —
515 // build, `set_dormant`, mount, `activate`, *then* focus (see
516 // `present_in_tree_modal_request`) — as do a tab switch and a
517 // collapsed pane. Same fix as `RichTextEditor`; this file backs
518 // both `CodeEditor` and `PlainTextEditor`.
519 let st = state.borrow();
520 if let Some(handle) = &st.frame_request {
521 handle.set(true);
522 }
523 return;
524 }
525 let mut st = state.borrow_mut();
526 if st.has_focus {
527 st.has_focus = false;
528 st.focus_signal.set_if_changed(false);
529 }
530 st.caret_visible.set_if_changed(false);
531 st.blink.reset();
532 });
533 }
534
535 // Frame-tick effect: drain events, blink, lay out, publish metrics.
536 // Skipped while dormant so multi-tab / multi-page hosts do not pay
537 // O(open editors) per wake for surfaces nobody can see.
538 {
539 let state = self.state.clone();
540 let active = activation.clone();
541 let tick_signal = ctx.frame_tick();
542 ctx.effect(&tick_signal, move |delta| {
543 if !active.get() {
544 return;
545 }
546 let mut st = state.borrow_mut();
547 let more = super::frame_loop::tick(&mut st, *delta);
548 if more && let Some(handle) = &st.frame_request {
549 handle.set(true);
550 }
551 });
552 }
553
554 // Window-active effect: hide the caret synchronously on deactivation
555 // (the loop may not tick while the window is inactive). Re-arm the
556 // frame loop only while this editor is itself active.
557 {
558 let state = self.state.clone();
559 let active = activation.clone();
560 let wa_signal = ctx.window_active_signal();
561 ctx.effect(&wa_signal, move |&window_active| {
562 let mut st = state.borrow_mut();
563 st.window_active = window_active;
564 if window_active {
565 let show =
566 st.has_focus && !matches!(st.policy.caret_policy, CaretPolicy::Hidden);
567 if show {
568 st.caret_visible.set_if_changed(true);
569 }
570 st.blink.reset();
571 } else {
572 st.caret_visible.set_if_changed(false);
573 st.blink.reset();
574 }
575 if active.get()
576 && let Some(handle) = &st.frame_request
577 {
578 handle.set(true);
579 }
580 });
581 }
582
583 // Handlers on the wrapper — the focus + event target.
584 let mut handlers = HandlerSet::new();
585 if !self.state.borrow().policy.is_read_only() {
586 handlers = handlers.ime_input(teksilo_core::ime::ImeContext::text());
587 }
588 handlers = handlers
589 .focusable(true)
590 .cursor(CursorIcon::Text)
591 .on_focus({
592 let state = self.state.clone();
593 let touch = self.touch.clone();
594 move |gained, ctx| {
595 {
596 let mut st = state.borrow_mut();
597 st.has_focus = gained;
598 st.focus_signal.set_if_changed(gained);
599 if gained && matches!(st.policy.caret_policy, CaretPolicy::Blinking) {
600 st.blink.restart();
601 st.caret_visible.set_if_changed(true);
602 }
603 }
604 if gained {
605 super::keyboard::report_ime_cursor_area(&state, ctx);
606 } else {
607 super::keyboard::clear_ime_preedit(&state);
608 // A popup that outlived its editor's focus would float
609 // detached — close it on blur.
610 completion::close(&state, ctx);
611 // The affordance band is exempt from outside-press
612 // dismissal — every caret-moving tap is outside a handle
613 // — so retirement on focus loss is the host's.
614 touch.dismiss();
615 let mut st = state.borrow_mut();
616 st.last_ime_area = None;
617 st.last_chase_pos = None;
618 }
619 ctx.request_frame();
620 }
621 })
622 .on_pointer_event({
623 let state = self.state.clone();
624 let touch = self.touch.clone();
625 let v_sb = self.v_scrollbar_bounds.clone();
626 let h_sb = self.h_scrollbar_bounds.clone();
627 move |event, ctx| {
628 super::mouse::handle_pointer_event(&state, &touch, &v_sb, &h_sb, event, ctx)
629 }
630 })
631 // A hold selects the word under the finger. Attaching this
632 // **withdraws** the tree-owned long-press route (`touch_route`
633 // rule 1: a widget's own `on_long_press` wins), and the selection
634 // toolbar the mount raises is what replaces it — offering the same
635 // commands, from the same rows, as the right-click menu below.
636 .on_long_press({
637 let state = self.state.clone();
638 let touch = self.touch.clone();
639 move |event, ctx| {
640 super::mouse::handle_long_press(&state, &touch, event, ctx);
641 }
642 })
643 .on_key({
644 let state = self.state.clone();
645 let touch = self.touch.clone();
646 move |event, ctx| {
647 let response = super::keyboard::handle_key(&state, event, ctx);
648 // A keystroke moves the caret and edits the text, neither of
649 // which the controller made — so the handles it published
650 // are pointing at where the text used to be. `refresh` is a
651 // no-op until something has been raised.
652 touch.refresh(ctx, ToolbarIntent::Hide);
653 response
654 }
655 })
656 .on_double_tap({
657 let state = self.state.clone();
658 let touch = self.touch.clone();
659 move |event, ctx| {
660 super::mouse::handle_double_tap(&state, event.position, ctx);
661 // A finger can double-tap too, and the selection it just
662 // made is one the controller did not make.
663 if event.pointer.kind.is_direct() {
664 touch.raise(ctx, ToolbarIntent::Show);
665 }
666 }
667 })
668 .on_triple_tap({
669 let state = self.state.clone();
670 let touch = self.touch.clone();
671 move |event, ctx| {
672 super::mouse::handle_triple_tap(&state, event.position, ctx);
673 if event.pointer.kind.is_direct() {
674 touch.raise(ctx, ToolbarIntent::Show);
675 }
676 }
677 })
678 .on_access_action_request({
679 let state = self.state.clone();
680 let touch = self.touch.clone();
681 move |action, target, data, ctx| {
682 let response =
683 super::a11y::handle_access_action(&state, action, target, data, ctx);
684 // An assistive client's `SetTextSelection` / `SetValue` /
685 // `ReplaceSelectedText` moves the selection without going
686 // through the controller, so raised handles would be left
687 // marking the old range.
688 touch.refresh(ctx, ToolbarIntent::Keep);
689 response
690 }
691 });
692 // The right-click menu this editor never had. Built fresh per click so
693 // each row's enabled state reflects the live selection and policy.
694 if let Some(factory) = super::context_menu::resolve_factory(
695 self.custom_context_menu.take(),
696 self.default_context_menu_enabled,
697 self.state.clone(),
698 ) {
699 handlers = handlers.context_menu(move |pos, ctx| factory(pos, ctx));
700 }
701 // Scroll: the wheel path this surface always had, a finger's pan, and
702 // the claim that puts it on a pan's claimant chain — all from
703 // `common::text_scroll`, which the three text surfaces share.
704 {
705 let (x, max_x, y, max_y, scroller) = {
706 let st = self.state.borrow();
707 (
708 st.scroll_x.clone(),
709 st.max_scroll_x.clone(),
710 st.scroll_y.clone(),
711 st.max_scroll_y.clone(),
712 st.scroller.clone(),
713 )
714 };
715 let behavior = crate::common::text_scroll::text_surface_behavior(
716 crate::common::text_scroll::TextScrollState {
717 x,
718 max_x,
719 y,
720 max_y,
721 scroller,
722 },
723 self.overscroll_behavior,
724 ctx.prefers_reduced_motion(),
725 ctx.theme().input.scroll_physics,
726 );
727 handlers = behavior.install(handlers);
728 }
729
730 ctx.apply_self_handlers(handlers);
731
732 // Select-all, the clipboard and undo / redo come through the
733 // rebindable `edit.*` shortcuts, not as keys.
734 {
735 let state = self.state.clone();
736 let touch = self.touch.clone();
737 let surfaces = ctx.text_surfaces();
738 crate::common::standard_edit::register_standard_edit_actions(
739 ctx,
740 move |command, ctx| {
741 let ran = super::keyboard::run_standard_edit(
742 &state,
743 command,
744 surfaces.empty_history_passes_on(),
745 ctx,
746 );
747 if ran {
748 // As after a key: the published handles point at
749 // where the text used to be.
750 touch.refresh(ctx, ToolbarIntent::Hide);
751 }
752 ran
753 },
754 );
755 }
756
757 // Comment and duplicate: shared `code.*` shortcuts, declared while an
758 // editable editor exists, and an action per command here.
759 if !self.state.borrow().policy.is_read_only() {
760 for command in crate::editor_shortcuts::CodeShortcut::ALL {
761 ctx.register_shortcut_shared(command.shortcut());
762 }
763 }
764 for command in crate::editor_shortcuts::CodeShortcut::ALL {
765 let state = self.state.clone();
766 let touch = self.touch.clone();
767 crate::common::standard_edit::register_command_action(ctx, command.id(), move |ctx| {
768 let ran = super::keyboard::run_code_shortcut(&state, command, ctx);
769 if ran {
770 touch.refresh(ctx, ToolbarIntent::Hide);
771 }
772 ran
773 });
774 }
775
776 // The touch-selection overlays: the affordance layer (handles + lens)
777 // and the selection toolbar, both detached content owned by this build.
778 // Inert until a finger raises them.
779 let self_id = ctx.self_id();
780 self.touch.build(ctx, self_id);
781
782 // Body — the pure-paint leaf. Always greedy: the wrapper does intrinsic
783 // sizing (min/max_lines) and hands the body its final rect.
784 let body = body_for(&self.state, None, None);
785 // The name goes on the body, the node focus is published on; see
786 // [`label`](CodeEditor::label).
787 let body_id = match self.label.clone() {
788 Some(label) => ctx.add(body.access_label(label)),
789 None => ctx.add(body),
790 };
791 self.body_id = Some(body_id);
792
793 // Reactive colour overrides repaint the body (the leaf that resolves
794 // them). Theme-role changes already dirty every node; this covers
795 // Signal-bound props.
796 {
797 let props = {
798 let st = self.state.borrow();
799 [
800 st.text_color_prop.clone(),
801 st.caret_color_prop.clone(),
802 st.selection_color_prop.clone(),
803 ]
804 };
805 let registry = ctx.binding_registry();
806 for prop in props.iter().flatten() {
807 prop.register_if_bound(body_id, registry, BindingLevel::RepaintOnly);
808 }
809 }
810
811 let mut children = Vec::with_capacity(4);
812 if self.show_gutter {
813 let gutter_id = ctx.add(CodeGutter::new(&self.state));
814 self.gutter_id = Some(gutter_id);
815 children.push(gutter_id);
816 }
817 children.push(body_id);
818
819 // Overlay scrollbars driven by the metrics the frame loop publishes.
820 let (scroll_x, scroll_y, max_x, max_y, vr_x, vr_y) = {
821 let st = self.state.borrow();
822 (
823 st.scroll_x.clone(),
824 st.scroll_y.clone(),
825 st.max_scroll_x.clone(),
826 st.max_scroll_y.clone(),
827 st.viewport_ratio_x.clone(),
828 st.viewport_ratio_y.clone(),
829 )
830 };
831 if self.v_scroll_policy != ScrollPolicy::AlwaysOff {
832 let v = ScrollBar::new(
833 ScrollBarOrientation::Vertical,
834 scroll_y,
835 max_y.clone(),
836 vr_y,
837 )
838 .visual(ScrollBarVariant::Overlay);
839 let id = ctx.add(v);
840 self.v_scrollbar_id = Some(id);
841 children.push(id);
842 }
843 if self.h_scroll_policy != ScrollPolicy::AlwaysOff {
844 let h = ScrollBar::new(
845 ScrollBarOrientation::Horizontal,
846 scroll_x,
847 max_x.clone(),
848 vr_x,
849 )
850 .visual(ScrollBarVariant::Overlay);
851 let id = ctx.add(h);
852 self.h_scrollbar_id = Some(id);
853 children.push(id);
854 }
855
856 // Completion popup content — pre-created and kept dormant (the ComboBox
857 // dropdown pattern), so it is never an orphan arena root and never
858 // ghost-paints while logically closed. `show_overlay` moves it to the
859 // overlay layer when completion opens.
860 if self.state.borrow().completion.has_provider() {
861 let open = self.state.borrow().completion.open.clone();
862 // Built the first time completion opens, not on every rebuild of the
863 // editor. See `teksilo_core::deferred_subtree::DeferredSubtree`.
864 let panel_id = ctx.add_deferred(open.clone(), CompletionPanel::new(&self.state));
865 ctx.set_dormant(panel_id);
866 ctx.visible_when(panel_id, open);
867 self.state.borrow_mut().completion.panel_id = Some(panel_id);
868 children.push(panel_id);
869 }
870
871 // The `Auto` scrollbars appear only when there is overflow; those maxima
872 // are published by the frame loop, so re-place when they cross zero.
873 let self_id = ctx.self_id();
874 let registry = ctx.binding_registry();
875 max_y.bind_to(self_id, registry, BindingLevel::Relayout);
876 max_x.bind_to(self_id, registry, BindingLevel::Relayout);
877
878 children
879 }
880
881 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
882 let w = proposal.width.unwrap_or(400.0).max(0.0);
883
884 // Greedy unless the composer knobs are set.
885 if self.min_lines.is_none() && self.max_lines.is_none() {
886 let h = proposal.height.unwrap_or(300.0).max(0.0);
887 return Size::new(w, h).into();
888 }
889
890 let st = self.state.borrow();
891 let line_scale = st.effective_font_scale(ctx.text_scale);
892 let line_h = st.engine.default_line_height() * line_scale;
893 let content_h = st.engine.content_height();
894 drop(st);
895
896 let min_h = self.min_lines.map(|n| n as f32 * line_h).unwrap_or(0.0);
897 let max_h = self
898 .max_lines
899 .map(|n| n as f32 * line_h)
900 .unwrap_or(f32::INFINITY);
901 Size::new(w, content_h.clamp(min_h, max_h).max(0.0)).into()
902 }
903
904 fn place_children(
905 &self,
906 bounds: Rect,
907 _proposal: SizeProposal,
908 children: &mut [WidgetPlacement],
909 ctx: &LayoutContext,
910 ) {
911 self.state.borrow_mut().node_origin = Point::new(bounds.x, bounds.y);
912
913 // Gutter width, measured from its intrinsic response (it sizes to the
914 // widest line number the document will ever hold).
915 let gutter_w = self
916 .gutter_id
917 .and_then(|id| ctx.child_size(id, SizeProposal::with_height(bounds.height)))
918 .map(|s| s.width)
919 .unwrap_or(0.0);
920 self.gutter_width.set(gutter_w);
921
922 let body_x = bounds.x + gutter_w;
923 let body_w = (bounds.width - gutter_w).max(0.0);
924
925 let (max_y, max_x) = {
926 let st = self.state.borrow();
927 (st.max_scroll_y.get(), st.max_scroll_x.get())
928 };
929 let show_v = match self.v_scroll_policy {
930 ScrollPolicy::AlwaysOn => true,
931 ScrollPolicy::Auto => max_y > 0.0,
932 ScrollPolicy::AlwaysOff => false,
933 };
934 let show_h = match self.h_scroll_policy {
935 ScrollPolicy::AlwaysOn => true,
936 ScrollPolicy::Auto => max_x > 0.0,
937 ScrollPolicy::AlwaysOff => false,
938 };
939
940 let mut v_rect = Rect::ZERO;
941 let mut h_rect = Rect::ZERO;
942 for child in children.iter_mut() {
943 if Some(child.id) == self.gutter_id {
944 child.origin = Point::new(bounds.x, bounds.y);
945 child.size = Size::new(gutter_w, bounds.height);
946 } else if Some(child.id) == self.body_id {
947 child.origin = Point::new(body_x, bounds.y);
948 child.size = Size::new(body_w, bounds.height);
949 } else if Some(child.id) == self.v_scrollbar_id {
950 if show_v {
951 let h = if show_h {
952 (bounds.height - SCROLLBAR_THICKNESS).max(0.0)
953 } else {
954 bounds.height
955 };
956 child.origin =
957 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
958 child.size = Size::new(SCROLLBAR_THICKNESS, h);
959 v_rect = Rect::new(
960 child.origin.x - bounds.x,
961 child.origin.y - bounds.y,
962 SCROLLBAR_THICKNESS,
963 h,
964 );
965 } else {
966 child.origin = Point::new(bounds.x, bounds.y);
967 child.size = Size::ZERO;
968 }
969 } else if Some(child.id) == self.h_scrollbar_id {
970 if show_h {
971 let w = if show_v {
972 (body_w - SCROLLBAR_THICKNESS).max(0.0)
973 } else {
974 body_w
975 };
976 child.origin =
977 Point::new(body_x, bounds.y + bounds.height - SCROLLBAR_THICKNESS);
978 child.size = Size::new(w, SCROLLBAR_THICKNESS);
979 h_rect = Rect::new(
980 child.origin.x - bounds.x,
981 child.origin.y - bounds.y,
982 w,
983 SCROLLBAR_THICKNESS,
984 );
985 } else {
986 child.origin = Point::new(bounds.x, bounds.y);
987 child.size = Size::ZERO;
988 }
989 }
990 }
991 self.v_scrollbar_bounds.set(v_rect);
992 self.h_scrollbar_bounds.set(h_rect);
993 }
994
995 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
996 // Background fill, then the current-line band, then the matched-bracket
997 // cells — all behind the children (gutter numbers, body text), which
998 // paint on top. A band spanning gutter and body is exactly why it lives
999 // on the wrapper rather than in either child.
1000 let st = self.state.borrow();
1001
1002 let bg = match &st.background_prop {
1003 Some(p) => p.resolve(ctx.theme, true),
1004 None => ctx.theme.colors.editor_bg,
1005 };
1006 canvas.fill_rect(bounds, bg);
1007
1008 // Current-line band: only for a single collapsed caret in a focused,
1009 // active window — a band under a selection or several carets reads as
1010 // noise, which is the convention every editor follows.
1011 let single_collapsed = st.extra_carets.is_empty() && !st.cursor.has_selection();
1012 if st.current_line_highlight
1013 && st.has_focus
1014 && st.window_active
1015 && single_collapsed
1016 && let Some((y, h)) = Self::caret_line_band(&st)
1017 && y + h > bounds.y
1018 && y < bounds.y + bounds.height
1019 {
1020 let band = Rect::new(bounds.x, y, bounds.width, h);
1021 canvas.fill_rect(band, ctx.theme.colors.surface_hover);
1022 }
1023
1024 // Matched-bracket cells: a faint wash behind each of the two brackets.
1025 if let Some((a, b)) = st.bracket_match.get()
1026 && st.engine.has_full_layout()
1027 {
1028 let origin = st.viewport_origin;
1029 let scroll_x = st.scroll_x.get();
1030 let scroll_y = st.scroll_y.get();
1031 for p in [a, b] {
1032 let r0 = st.engine.caret_rect(p, CursorAffinity::Downstream);
1033 let r1 = st.engine.caret_rect(p + 1, CursorAffinity::Downstream);
1034 let x = origin.x + r0[0] - scroll_x;
1035 let w = (r1[0] - r0[0]).max(2.0);
1036 let y = origin.y + r0[1] - scroll_y;
1037 let h = r0[3];
1038 // Clip to the body region so a bracket scrolled behind the
1039 // gutter does not paint over the numbers.
1040 if x + w > origin.x && y + h > bounds.y && y < bounds.y + bounds.height {
1041 canvas.fill_rect(Rect::new(x, y, w, h), ctx.theme.colors.accent_subtle_bg);
1042 }
1043 }
1044 }
1045
1046 drop(st);
1047
1048 // A 1 px border that brightens on focus — minimal chrome until a Tier-3
1049 // style lands.
1050 let focused = self.state.borrow().focus_signal.get();
1051 let border = if focused {
1052 ctx.theme.colors.border_focused
1053 } else {
1054 ctx.theme.colors.border
1055 };
1056 canvas.stroke_rect(bounds, border, 1.0);
1057 }
1058
1059 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1060 // The role, actions and the run tree live on the body leaf, mirroring
1061 // RichTextEditor. The wrapper is structure: with the default
1062 // `Role::Unknown` an adapter would show it as an unnamed "unknown".
1063 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1064 }
1065
1066 fn accessibility_proxy(&self) -> Option<WidgetId> {
1067 // The body stands for the editor: this widget takes the keys, and the
1068 // body holds the text, so focus, a name and anything an application
1069 // attaches here are published on the body.
1070 self.body_id
1071 }
1072
1073 fn children(&self) -> Vec<WidgetId> {
1074 let mut ids = Vec::with_capacity(5);
1075 ids.extend(self.gutter_id);
1076 ids.extend(self.body_id);
1077 ids.extend(self.v_scrollbar_id);
1078 ids.extend(self.h_scrollbar_id);
1079 // The completion popup (a dormant overlay node) — tracked here so it is
1080 // not an orphan; positioned by the overlay manager when shown, skipped by
1081 // `place_children` otherwise.
1082 ids.extend(self.state.borrow().completion.panel_id);
1083 ids
1084 }
1085
1086 fn clips_children(&self) -> bool {
1087 true
1088 }
1089}
1090
1091/// A multi-line plain-text editing surface — the code editor with its code
1092/// affordances off and wrapping on. A notes field, a commit message, a
1093/// description box.
1094///
1095/// It shares [`CodeEditor`]'s machinery (caret, selection, IME, clipboard,
1096/// scrolling, accessibility); the difference is configuration, so the two never
1097/// drift. Construct with [`PlainTextEditor::new`] / [`PlainTextEditor::read_only`].
1098#[derive(Debug)]
1099pub struct PlainTextEditor {
1100 pub(super) inner: Option<CodeEditor>,
1101 inner_id: Option<WidgetId>,
1102}
1103
1104impl PlainTextEditor {
1105 /// An editable plain-text editor bound to `document`: no gutter, no
1106 /// current-line highlight, word wrapping, and no code affordances.
1107 pub fn new(document: TextDocument) -> Self {
1108 Self::wrap(CodeEditor::new(document))
1109 }
1110
1111 /// A read-only plain-text viewer bound to `document`.
1112 pub fn read_only(document: TextDocument) -> Self {
1113 Self::wrap(CodeEditor::read_only(document))
1114 }
1115
1116 fn wrap(editor: CodeEditor) -> Self {
1117 // Plain-text defaults: fold the code chrome away, wrap like prose.
1118 let editor = editor
1119 .gutter(false)
1120 .current_line_highlight(false)
1121 .wrap_mode(WrapMode::Word);
1122 Self {
1123 inner: Some(editor),
1124 inner_id: None,
1125 }
1126 }
1127
1128 /// Restrict growth to `[min, max]` lines (intrinsic sizing — the composer
1129 /// pattern).
1130 pub fn min_lines(mut self, lines: u32) -> Self {
1131 self.map(|e| e.min_lines(lines));
1132 self
1133 }
1134
1135 /// Cap intrinsic growth at `lines`.
1136 pub fn max_lines(mut self, lines: u32) -> Self {
1137 self.map(|e| e.max_lines(lines));
1138 self
1139 }
1140
1141 /// Set the line-wrap mode (default `Word`).
1142 pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
1143 self.map(|e| e.wrap_mode(mode));
1144 self
1145 }
1146
1147 /// Fallback font family.
1148 pub fn font_family(mut self, family: impl Into<String>) -> Self {
1149 self.map(|e| e.font_family(family));
1150 self
1151 }
1152
1153 /// Whether the editor follows the global accessibility text scale.
1154 pub fn follow_text_scale(mut self, follow: bool) -> Self {
1155 self.map(|e| e.follow_text_scale(follow));
1156 self
1157 }
1158
1159 /// Per-editor logical font-size multiplier (`1.0` = 100 %).
1160 pub fn font_size_scale(mut self, scale: f32) -> Self {
1161 self.map(|e| e.font_size_scale(scale));
1162 self
1163 }
1164
1165 /// A callback fired on each content-changing edit batch.
1166 pub fn on_change(mut self, callback: impl Fn() + 'static) -> Self {
1167 self.map(|e| e.on_change(callback));
1168 self
1169 }
1170
1171 /// Override the background colour.
1172 pub fn background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1173 self.map(|e| e.background(color));
1174 self
1175 }
1176
1177 /// Replace the built-in right-click menu — see
1178 /// [`CodeEditor::context_menu`].
1179 pub fn context_menu(
1180 mut self,
1181 factory: impl Fn(
1182 teksilo_canvas::Point,
1183 &mut teksilo_core::widget::EventContext,
1184 ) -> Option<Box<dyn teksilo_core::widget::Widget>>
1185 + 'static,
1186 ) -> Self {
1187 self.map(|e| e.context_menu(factory));
1188 self
1189 }
1190
1191 /// Whether to install the built-in right-click menu — see
1192 /// [`CodeEditor::default_context_menu`].
1193 pub fn default_context_menu(mut self, enabled: bool) -> Self {
1194 self.map(|e| e.default_context_menu(enabled));
1195 self
1196 }
1197
1198 /// Accessible name for the editor: see [`CodeEditor::label`].
1199 pub fn label(mut self, label: impl Into<teksilo_i18n::LocalizedString>) -> Self {
1200 self.map(|e| e.label(label));
1201 self
1202 }
1203
1204 /// A cloneable handle to drive the editor.
1205 pub fn handle(&self) -> CodeEditorHandle {
1206 self.inner.as_ref().expect("handle() before build").handle()
1207 }
1208
1209 /// Apply `f` to the inner editor in place (builders consume and return it).
1210 fn map(&mut self, f: impl FnOnce(CodeEditor) -> CodeEditor) {
1211 if let Some(e) = self.inner.take() {
1212 self.inner = Some(f(e));
1213 }
1214 }
1215}
1216
1217impl Widget for PlainTextEditor {
1218 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1219 let inner = self.inner.take().expect("PlainTextEditor built once");
1220 let id = ctx.add(inner);
1221 self.inner_id = Some(id);
1222 vec![id]
1223 }
1224
1225 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1226 self.inner_id
1227 .and_then(|id| ctx.child_size(id, proposal))
1228 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1229 .into()
1230 }
1231
1232 fn place_children(
1233 &self,
1234 bounds: Rect,
1235 _proposal: SizeProposal,
1236 children: &mut [WidgetPlacement],
1237 _ctx: &LayoutContext,
1238 ) {
1239 if let Some(child) = children.first_mut() {
1240 child.origin = Point::new(bounds.x, bounds.y);
1241 child.size = Size::new(bounds.width, bounds.height);
1242 }
1243 }
1244
1245 fn children(&self) -> Vec<WidgetId> {
1246 self.inner_id.into_iter().collect()
1247 }
1248
1249 fn accessibility_proxy(&self) -> Option<WidgetId> {
1250 // The inner editor stands for this one, and its body for it in turn.
1251 self.inner_id
1252 }
1253}