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