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}