Skip to main content

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