CodeEditor, PlainTextEditor, and LogView
Three multi-line text surfaces over one core
(crates/teksilo-widgets/src/code_editor/):
CodeEditor, a source editor: a line-number gutter, a current-line band, indentation, bracket handling, multiple carets, and completion.PlainTextEditor, the same core with the code affordances off and wrapping on: a notes field, a commit message, a description box.LogView, a read-only, append-only, tail-following streaming view that scales to 100 000+ lines. Its own page: Log view.
They are one implementation because they differ in configuration, not in kind , all three are a run of lines with a caret in it. A widget per face would triplicate the caret, selection, IME, clipboard, scrolling, and accessibility and let them drift.
Why not RichTextEditor
RichTextEditor already edits
multi-line text, and this deliberately does not build on it. Its command
vocabulary is tables, lists, blockquotes, and bold, reusing it would put
Tab-navigates-a-table-cell and Ctrl+B-emboldens into a source file, where the
first is wrong and the second is meaningless. Its state carries a table-aware
Ctrl+A ladder and a rich clipboard fragment; this one carries an indent policy
and a caret vector. What the two genuinely share, the caret blink clock, the
debounce window, the scroll arithmetic, lives in the crate-internal
common::editor_runtime,
used by both, so the overlap is factored, not copied.
Language-agnostic by construction
There is no Language enum anywhere in this module. Comment tokens, bracket
pairs, indent width, and completion candidates are
CodeConfig values the
application supplies: the editor knows how to toggle a line comment, not that
Rust uses //. Guessing would be worse than not knowing, inserting // into a
Python file corrupts it silently, so the defaults do only what needs no language
knowledge (indent, auto-indent) and leave comment toggling and bracket handling
off until the application says what the tokens are.
#![allow(unused)] fn main() { use teksilo::widgets::{CodeEditor, COMMON_BRACKETS}; use teksilo::text_document::TextDocument; let doc = TextDocument::new(); doc.set_plain_text(source).unwrap(); let editor = CodeEditor::new(doc) .font_family("monospace") // a code editor wants a monospace family .line_comment("//") // enables Ctrl+/ .bracket_pairs(COMMON_BRACKETS.to_vec()) .auto_close_brackets(true) // typing '(' inserts ')' .bracket_matching(true) // the caret's bracket + its match wash .completion_provider(|ctx| complete(ctx.prefix)); let handle = editor.handle(); // drive it from a toolbar / status bar }
CodeEditor::read_only(doc) is the same, minus a visible caret: navigation,
selection, and copy only, Role::Document. The only commands it can offer are
therefore Copy and Select All, nothing that would change the text. Its
right-click menu carries both rows; its touch toolbar shows whichever of the two
the live selection allows, see
Pointer input.
Builders
Shared by CodeEditor and PlainTextEditor:
| Builder | Effect |
|---|---|
wrap_mode(WrapMode) | CodeEditor defaults to None (a wrapped source line breaks the gutter's one-number-per-line correspondence); PlainTextEditor defaults to Word. |
min_lines / max_lines | Switch from greedy to intrinsic sizing, grow with content up to max_lines, then scroll (the composer pattern). |
font_family / font_size_scale / follow_text_scale | Typography. follow_text_scale (default on) grows text with the global accessibility scale; font_size_scale is a per-editor logical multiplier. |
background | Color, a theme role, or a Signal. |
on_change(Fn) | Fired once per drain batch that contained a real edit. |
context_menu(factory) / default_context_menu(bool) | Replace or suppress the built-in right-click menu, see The right-click menu. |
CodeEditor-only:
| Builder | Effect |
|---|---|
v_scroll_policy / h_scroll_policy | Auto (default) / AlwaysOn / AlwaysOff. |
text_color / caret_color / selection_color | Color, a theme role, or a Signal. |
window_to_clip(bool) | Cull the render to the visible clip band, only for an editor laid out at full document height inside an outer ScrollArea. |
gutter(bool) (default on) | The line-number gutter. |
current_line_highlight(bool) (default on) | A full-width band under the caret's line. |
indent_style / tab_width / use_soft_tabs | Spaces of a width, or tabs rendered a width wide. |
auto_indent(bool) (default on) | Enter carries the line's leading whitespace. |
line_comment(token) | Enables Ctrl+/. Unset leaves it a no-op rather than guessing. |
bracket_pairs(pairs) / auto_close_brackets / bracket_matching | Delimiter handling. Empty pairs (the default) disables both. |
completion_provider(Fn) / auto_complete(bool) | See Completion. |
Code semantics
Every command in keyboard.rs
is driven by injected configuration and is a single atomic undo step:
- Auto-indent on Enter carries the previous line's indentation (and splits a
{}pair onto its own indented line when the caret is between them). - Smart Tab / Shift+Tab, soft or hard tabs; with a selection, indent / dedent every touched line. Because Tab indents, Ctrl+Tab / Ctrl+Shift+Tab move focus out of the editor, forward and back, and write nothing (Control on macOS too, where ⌘⇥ is the application switcher). The editor's text node names both chords in its description, which AT-SPI, UIA and macOS all read. A read-only viewer does not take Tab, so Tab leaves it.
- Ctrl+/ toggles the configured line comment on the caret's line or selection.
- Ctrl+D duplicates the selection, or the caret's whole line when there is none; Alt+↑ / Alt+↓ move the line.
- Auto-close, type-over, and pair-backspace for configured brackets;
bracket matching publishes the caret's bracket and its partner as a
reactive
Signal<Option<(usize, usize)>>(viahandle.bracket_match()) and washes both cells behind the text. - Multiple carets,
Ctrl+Alt+↑/↓add a caret above/below, Alt-click adds one at the pointer; typing goes to every caret at once, in one undo step. The accessibility tree reports only the primary caret.
Caret motion follows the platform
Word-jump, the line edge and the document edge sit on different modifiers on
macOS than they do elsewhere, and the difference is not a simple substitution,
so the chords are read through
common::text_nav rather
than from an "is the accelerator held?" flag:
| motion | Windows / Linux | macOS |
|---|---|---|
| character | ← → | ← → |
| word | Ctrl+←/→ | ⌥←/→ |
| line edge | Home End | ⌘←/→, Home End |
| document edge | Ctrl+Home/End | ⌘↑/↓, ⌘Home/End |
| delete word | Ctrl+⌫ Ctrl+⌦ | ⌥⌫ ⌥⌦ |
Alt+↑/↓ stays on move-line here on every platform, macOS included, that is
the binding every code editor ships, and it takes precedence over the
paragraph motion the rich-text editor puts there. ⌘⌫ means delete-to-line-start
on macOS, which is not implemented; it falls through to a plain single-character
delete rather than removing more than was asked for.
Shift extends the selection over any of them, and the policy filter is asked
about the motion that actually runs, a MoveWordLeft veto bites on ⌥←
exactly as it bites on Ctrl+←.
Pointer input: two devices
A precise pointer edits exactly as it always has, and that is a property this
package tests rather than assumes: a press places the caret at the press, a drag
selects what it crosses, Alt-click adds a caret, and a double or triple click
selects the word or the line. Nothing a mouse does reaches any of the machinery
below.
A direct pointer, a finger, a pen, commits nothing on the press. The same
contact is the opening sample of a pan, and a panning finger has to leave the
caret and the selection exactly as it found them; no release-time test can rescue
a caret already written on PointerDown, so the write itself moved to the
release. Two conditions gate it, and both are needed:
- the press was not claimed by a scrollable above
(
release_completes_the_press), and - it travelled no further than a tap of its own kind may (18 dp for a finger).
The second exists because the framework's tap boundary for a coarse pointer is the pressed node's rectangle, the right rule for a control, where the whole node is one target and a finger covers it, and the wrong one for a text surface, whose target is a character. An editor is also both the press's owner and the pan's claimant, so nothing is ever "claimed elsewhere": without the radius check a finger could pan a tall editor from the first line to the last and land a caret wherever it stopped.
Two press-time commitments therefore have no direct-pointer form, each deliberately:
| commitment | why not, and what replaces it |
|---|---|
| drag-select | a finger's drag pans; the range is chosen with the selection handles below |
Alt-click's extra caret | there is no Alt on a touch screen. Ctrl+Alt+↑/↓ stays the route, and a hold collapses a multi-caret set, because a touch selection is one range |
(The rich text editor gives up two more, picking selected text up to drag it, and grabbing a picture's resize grip. A source document has neither.)
Selecting with a finger
A hold selects the word under the contact and raises the touch chrome: two 24 dp handles inside 44 dp targets, and a selection toolbar. Dragging a handle adjusts that end; dragging one past the other grows the range on the far side rather than collapsing it. Two details of the drag are worth knowing:
- The sample is translated onto the caret the handle marks, not taken raw. A handle's disc hangs off the line on purpose, so the fingertip does not cover the character it is pointing at, and hit-testing the fingertip would resolve onto the next line.
- Dragging into the viewport's edge band auto-scrolls, so a selection can
grow past the visible text. The band is the shared
common::drag_autoscrollone every dragging view uses, which for a finger is 64 dp. It scrolls as the contact moves through the band; a finger held perfectly still there does not keep scrolling.
The toolbar is the right-click menu's own rows, one set of rows, built from one list, so neither surface can offer a command the keyboard would refuse: both read the same policy the chords are filtered through.
What each surface does with the live state differs, by design. The menu greys
a row it cannot honour, the desktop convention, and gates it on the document
having any content (Paste aside: the clipboard it reads is not visible from here),
so it offers Cut with no selection. The toolbar omits what
it cannot offer, which is what every platform's touch toolbar does, and it is the
stricter of the two on state: Cut and Copy need a selection, Paste needs an
editable surface, and Select All is offered only while nothing is selected. So a
hold on an editable surface puts up Cut / Copy / Paste, and a hold on a read-only
one, a viewer, a LogView, puts up Copy alone. A tap on a read-only surface
raises no handle at all: the caret handle is the one that moves a caret, and
the controller offers it only on an editable surface.
The chrome is retired by a cursor's press anywhere in the surface, by focus leaving, and by a keystroke that moves the caret. The affordance band is exempt from the framework's outside-press dismissal, every caret-moving tap is outside a handle, so those are the surface's own job.
Full contract, including the magnifier and the accessibility of a handle: Touch text editing.
The right-click menu
Cut / Copy / Paste / Select All, built fresh on each right-click so every row's
enabled state reflects the live selection and the live policy. All three faces
gained it in the same package that gave them a finger: they had none before,
so the only route to the clipboard was a chord, and LogView, where copying is
the only thing a reader can do at all, had only Ctrl+C.
Each row asks the predicate the surface's own chord asks, so the menu can neither
offer a Cut the editor refuses nor refuse a Copy it allows. Cut is offered with no
selection, because it takes the caret's whole line, the convention Ctrl+X
already followed here. Replace the menu with context_menu(factory) or suppress
it with default_context_menu(false); neither touches the touch toolbar, which is
raised by a selection rather than by a button a finger does not have.
The on-screen keyboard
Two things follow a caret placed by a pointer, and neither did before:
- The IME candidate area is reported, as it already was for every caret move made by a key. Without it the candidate list, and the suggestion strip an on-screen keyboard draws, stays beside wherever the caret was last typed to.
- A viewport that gets smaller re-reveals the caret. The keyboard rising under
a focused editor is the case that makes this a correctness matter rather than a
nicety. Growing the viewport deliberately does not chase the caret: growth
reveals more text and never pushes it out, and a reader scrolled away would
otherwise be yanked back every time a window edge moved. A
LogViewis exempt: pulling its offset would fight its own follow-tail rule, which is derived from that very offset.
Completion
Supply candidates with completion_provider(Fn(&CompletionContext) -> Vec<CompletionItem>);
the editor filters them by the word before the caret, shows a caret-anchored
popup, and replaces the word on accept. Language-agnostic, the app knows the
candidates (keywords, in-scope names, an LSP reply), the editor knows the
mechanics. Without a provider there is no completion.
CompletionContextcarries theprefix,line,column, and documentposition.CompletionItemisnew(label).insert_text(..).detail(..).kind(CompletionKind).- The editor owns the keys while the popup is open (Up/Down/PageUp-Down/Enter/Tab/
Escape), the popup is a detached overlay, not an ancestor, so keys cannot
bubble to it. The ARIA listbox pattern (
HasPopup::Listbox+AutoComplete::Listactive_descendant) is on the editor node.
auto_complete(false)restricts opening toCtrl+Space.
Accessibility
Both the editor and the log present their text to assistive technology as
Role::TextRun children of their own node, one run per visual line, or
several linked runs where a line is split by syntax highlighting or by the
255-character cap, built by the shared walk in
a11y.rs on top of
teksilo_core::accessibility::text_runs. Each run carries the per-character
byte lengths, word starts, bounding box, reading direction and per-character
extents a screen reader needs to speak, navigate and route braille to, plus:
- Same-line run linking (
next_on_line/previous_on_line) so a reader navigating by line does not stop at each syntax-highlight colour boundary. - A trailing newline on each line's last run (AccessKit's line-break contract; the caret can never address it).
- Chunking of runs over 255 characters into linked ≤255-character runs,
word_startsare character indices stored asu8, so a long line would otherwise lose word navigation past character 255.
The runs hang directly off the editor's node. They used to hang off a
Role::Paragraph per line, and that node was doing two kinds of damage:
accesskit_consumer::common_filter does not exclude Paragraph, so every line
was an object-navigation stop of its own; and a run's update routes to its
filtered parent, which was the Paragraph, which supports no text ranges, so
macOS, Windows and AT-SPI all dropped every text-change event the editor
produced. Removing it cost the "line 42 of 200" ordinal, which needed
position_in_set on those nodes. Line position is available through line
navigation on every platform, and in the gutter, which is hidden from AT.
Editable surfaces report Role::MultilineTextInput and advertise SetValue /
ReplaceSelectedText / SetTextSelection; read-only ones report Role::Document
(not Role::Code, which accesskit_consumer excludes from text-range support, so
a caret could not be tracked through it) and advertise, of those three,
SetTextSelection alone. Both also advertise Focus and ScrollIntoView, which
are not text edits and are offered whatever the policy, the contrast above is
about the actions that write.
An AT-initiated SetTextSelection resolves back to a document cursor position
through a per-run synthetic-node map. The editor walks the whole bounded document
(cached); the log walks only its visible window, see Log view.
Rendering & scale
The body paints via the shared
rich_text::paint::paint_frame
over the RichTextEngine. For a
bounded document the standard full layout is right; for the unbounded streaming
case the LogView uses the windowed layout path, the text-stack additions that
make that possible (windowed layout_window, O(1) append, front-truncate) are
documented in Log view, which also carries the before/after
benchmark table.
Testing
The core is fully headless, no GPU, no display. Tests run against the private
engine's fixed metrics and verify the editor's own logic (viewport adoption,
caret bookkeeping, event classification, policy gating, the a11y walk), not
shaping, which is text-typeset's own suite's job. See
code_editor/tests.rs and,
for the pointer / menu / touch behaviour above, both devices, all three faces,
code_editor/touch_tests.rs.
Demos
cargo run -p code_editor # gutter, brackets, comment toggle, multi-caret,
# an injected highlighter and completion
cargo run -p log_view # the streaming face, see docs/log-view.md