Text entry widgets
ManyUI._TI_CARET — Constant
The caret's own style: REVERSE video, MERGED onto whatever cell the caret rests on so the glyph under it survives. Internal.
ManyUI.TextInput — Type
mutable struct TextInput{F1, F2} <: WidgetSingle-line text entry.
text and cursor are Dirty.PAINT-reactive, and that is a design commitment rather than an optimisation: measure returns Size(avail.width, 1), so a TextInput NEVER resizes to its content – that is what the horizontal scroll is for. A keystroke provably cannot move a single box, so a keystroke costs ZERO layout.
Reactive's default is the conservative Dirty.LAYOUT precisely because choosing PAINT for state that can change size is a correctness bug. The text-independent measure above is what licenses PAINT here; do not copy the choice to a widget without that property.
The horizontal scroll lives in node(w).scroll.x. The core never applies it (a TextInput has no children) – render! reads it and slices. Storing it there anyway is what lets a Scrollbar observe a TextInput with no new code.
Parametric on the submit handler, the Button{F} pattern.
Fields
node::WidgetNode: Per-widget state.text::Reactive{String}: The content. PAINT-reactive: the box cannot move.cursor::Reactive{Int64}: Caret, a 0-based GRAPHEME index in0:n. PAINT-reactive.focused::Reactive{Bool}: True while focused; drives the caret cell. PAINT-reactive.placeholder::String: Shown dimmed whiletextis empty.disabled::Reactive{Bool}: True if the input is disabled.is_password::Boolon_submit::Any: Called ason_submit(input)on ENTER.on_change::Any: Called ason_change(input)when text changes.
ManyUI.TextInput — Method
An input holding text, calling on_submit(input) on ENTER, and optionally on_change(input) on edits.
Focusable by construction, so it appears in focusable_widgets and is reachable by TAB with no further wiring.
The caret starts at the END of text, where a user who is handed a pre-filled field expects to carry on typing.
ManyUI._byte_after — Method
_byte_after(s::AbstractString, k::Int64) -> Int64
Code units in the first k graphemes of s; 0 for k <= 0 and ncodeunits(s) past the end. O(k). Pure.
The sum of whole clusters' ncodeunits, so the result is always a whole number of CLUSTERS and can never split one. This is THE one place byte and cluster indices meet. Internal.
It is a code-unit COUNT and NOT a character index, so SubString(s, 1, b) is WRONG: that form wants the INDEX of the last character and throws a StringIndexError the moment the prefix ends inside a multi-byte cluster – which is every CJK and every emoji. Split with _ti_head and _ti_tail, which take the same thisind precaution truncate_width already takes.
ManyUI._cluster_width_at — Method
_cluster_width_at(s::AbstractString, b::Int64) -> Int64
Cells of the cluster starting just after byte b; 1 at the end of s, so the caret always has a cell to sit in. Pure. Internal.
ManyUI._col_at_cell — Method
_col_at_cell(s::AbstractString, cell::Int64) -> Int64
The largest grapheme index of s whose clusters END at or before cell column cell. The inverse of "cells before the caret", and the reason an UP/DOWN move can never land INSIDE a wide cluster. Pure. Internal.
ManyUI._gindex_at — Method
_gindex_at(s::AbstractString, b::Int64) -> Int64
Clusters of s that END at or before byte b – the caret index for a caret sitting at byte b. Rounds DOWN inside a cluster. Pure. Internal.
ManyUI._ngraphemes — Method
_ngraphemes(s::AbstractString) -> Int64
Number of grapheme clusters in s. Pure. Internal.
ManyUI._ti_caret — Method
_ti_caret(w::TextInput) -> Int64
The caret, clamped into 0:n. Internal.
text and cursor are two independent Reactives, so an application that assigns w.text[] behind the editing ops' back can leave the caret past the end. Clamping on every READ makes every op below total, instead of scattering the same guard through each of them.
ManyUI._ti_caret_cells — Method
_ti_caret_cells(w::TextInput) -> Tuple{Int64, Int64}
(cells before the caret, cells of the cluster AT the caret). Pure with respect to the tree. Internal.
The second element is at least 1 even over a zero-width cluster, so the caret always has a cell of its own to reverse.
ONE pass over the graphemes, and this is the frame path: the obvious spelling – _byte_after, then text_width of the head, then _cluster_width_at – walks the clusters THREE times and each Unicode.graphemes costs an iterator per call. Walking once and stopping AT the caret subsumes all three, and it clamps cursor into 0:n on the way for free: a want below the range returns on the first cluster and one past it falls out of the loop.
ManyUI._ti_edits — Method
_ti_edits(d::Dispatch) -> Bool
True while d is live and at or past its target – everywhere except the capture phase.
A TextInput edits on the way UP, exactly as a Button activates: capture belongs to ancestors that want to intercept, and an input is never an interceptor. AT_TARGET counts because a childless input IS the target, and the bubble phase excludes the target, so bubble alone would never visit it. Internal.
ManyUI._ti_head — Method
_ti_head(s::String, b::Int64) -> SubString{String}
s's first b CODE UNITS, where b came from _byte_after. Pure. Internal.
thisind is the whole point and is not defensive programming: SubString(s, 1, b) reads b as the INDEX of the last CHARACTER, so it throws a StringIndexError for any prefix ending inside a multi-byte cluster – SubString("a世", 1, 4) throws, verified. thisind maps the count onto the index of the character it lands on, which for a cluster boundary is that character's start, so the split is exact and never loses a byte. truncate_width takes the same precaution.
ManyUI._ti_noop — Method
_ti_noop(_::Widget)
The default on_submit: do nothing. Internal.
ManyUI._ti_strip_controls — Method
_ti_strip_controls(t::AbstractString) -> String
t with every control character removed. Pure. Internal.
Per CODEPOINT rather than per cluster, which is safe precisely because a control is never part of a printable cluster: ZWJ, the variation selectors and the skin-tone modifiers are format and symbol codepoints, not Cc, so no cluster this strips through is one it can break. CRLF is a single cluster of two controls and goes whole.
Returns t itself when there is nothing to strip, so the ordinary paste costs one scan and no copy.
ManyUI._ti_sync_scroll! — Method
_ti_sync_scroll!(w::TextInput)
Store the window visible_scroll computes, so the caret is visible on the next frame. Internal.
A no-op before the first layout: with no content box there is no window to be inside, and render! recomputes the offset from the width it is actually given in any case.
ManyUI._ti_tail — Method
_ti_tail(s::String, b::Int64) -> SubString{String}
s from byte b + 1 on, where b came from _byte_after. Pure. Internal.
Legal at BOTH ends without a special case: b is a cluster boundary, so b + 1 starts a character, and one past the last byte yields the empty string rather than a BoundsError.
ManyUI._ti_window — Method
_ti_window(
w::TextInput,
width::Int64,
lo::Int64,
cw::Int64
) -> Int64
The window for a caret whose cluster spans cells lo:(lo + cw - 1), 0-based, in a width-cell content box. Pure. Internal.
Split out of visible_scroll so render! can pay for the caret's column ONCE and reuse it, rather than walking the prefix twice per frame.
ManyUI.backspace! — Method
backspace!(w::TextInput) -> Bool
Delete the cluster BEFORE the caret. False at the start of the text.
The WHOLE cluster goes: one backspace over a ZWJ family takes all 25 of its bytes, not the last codepoint of it.
ManyUI.content_extent — Method
content_extent(w::TextInput) -> Size
The content extent for the scrollable seam: Size(text_width(text) + 1, 1).
The + 1 is load-bearing: the caret must be able to rest ONE cell past the last glyph, and without it End scrolls one cell short.
ManyUI.delete_forward! — Method
delete_forward!(w::TextInput) -> Bool
Delete the cluster AT the caret. False at the end of the text.
The caret does not move: it is the same count of clusters from the start as it was, which is why it is recomputed from the same byte offset.
ManyUI.insert_text! — Method
insert_text!(w::TextInput, t::AbstractString)
Insert t at the caret. The caret is RECOMPUTED from the new prefix, never advanced by the inserted cluster count.
Recomputing is the only rule that is right for every input: a combining mark MERGES into the preceding cluster, so the cluster count may not increase at all, and an advance-by-n caret would then sit past the end of a string that never grew.
ManyUI.measure — Method
measure(w::TextInput, avail::Size) -> Size
Size(avail.width, 1). A TextInput takes the width it is offered and scrolls the rest; it never sizes to its content. Give it width: 20 for a narrow box. Pure with respect to the tree.
ManyUI.move_by! — Method
move_by!(w::TextInput, n::Int64) -> Int64
Move the caret by n CLUSTERS, clamped to 0:n. Returns the new caret.
n is a count of CLUSTERS and never of codepoints or cells, so LEFT over a ZWJ family is one call with n = -1 and lands before all seven of its codepoints.
Saturates instead of overflowing: the caret is non-negative, so only the rightward sum can leave Int, and a request that far right is an End by any reading.
ManyUI.move_to! — Method
move_to!(w::TextInput, i::Int64) -> Int64
Move the caret to cluster i, clamped to 0:n: 0 is Home and typemax(Int) is End. Returns the new caret.
ManyUI.on_blur! — Method
on_blur!(w::TextInput)
Hide the caret.
ManyUI.on_event! — Method
on_event!(w::TextInput, d::Dispatch{KeyEvent})
CHAR/SPACE insert; BACKSPACE/DELETE/LEFT/RIGHT/HOME/END edit and move; ENTER calls on_submit. Each consumes.
Unmodified keys only. ctrl+a and friends belong to an application binding and consuming them here would silently shadow it. TAB and ESCAPE FALL THROUGH UNCONSUMED, which is what keeps the tab order alive.
HOME and END are move_to! with the extremes of Int and let the clamp decide – "go as far as you can" IS the implementation.
ManyUI.on_event! — Method
on_event!(w::TextInput, d::Dispatch{PasteEvent})
Insert a paste at the caret with newlines and other controls stripped: a single-line input has nowhere to put a line break, and silently accepting one would make text unrenderable.
Consumes either way. A paste that strips to nothing was still a paste into this input, and letting the husk bubble to an ancestor would be a second delivery of the same event.
ManyUI.on_focus! — Method
on_focus!(w::TextInput)
Show the caret, and scroll every ancestor pane until this input is visible. reveal! is called EXPLICITLY because overriding on_focus! REPLACES the default that would have called it.
ManyUI.visible_scroll — Method
visible_scroll(w::TextInput, width::Int64) -> Int64
The first visible CELL column, 0-based, for a width-cell content box.
THE single definition of "scrolled so the caret is visible", called by render! (which MUST NOT mutate) AND by every editing op (which stores the result). One function, so the painted scroll and the stored one cannot drift. Pure.
MINIMAL MOVEMENT falls out of scroll_into_view: content already inside the window does not move, and the clamp at content_extent is what lets End rest on the caret's own cell instead of one short of it.
ManyUI._TA_CARET — Constant
The caret's cell: REVERSE video, merged onto whatever is under it.
A caret is not a hardware cursor – the tree paints into a Buffer and the Driver seam has no cursor-placement method. Internal.
ManyUI.TextArea — Type
mutable struct TextArea{F} <: WidgetMulti-line text entry.
Scrolls by INDEXING lines, never by shifting an origin: render! touches scroll.y + 1 : scroll.y + height and nothing else, so paint is O(viewport) and a 100k-line buffer costs the same frame as a 10-line one.
lines is a PLAIN field, deliberately NOT a Reactive: Reactive's == guard would be an O(n) elementwise compare of a Vector{String} on every keystroke, AND an in-place edit would never trip it. version is the reactive cell instead – an Int compare is O(1) for the O(1) edit that actually happened. THE PRICE, stated so nobody discovers it: lines mutated behind version's back will not repaint, so every edit goes through the ops below.
The cursor is (line, col): line 1-based into lines, col a 0-based GRAPHEME index within lines[line]. Same unit discipline as TextInput.
Fields
node::WidgetNode: Per-widget state.lines::Vector{String}version::Reactive{Int64}: Bumped by every edit. THE reactive cell. LAYOUT-reactive: a new line genuinely changes the extent.
line::Int64: Cursor line, 1-based.col::Int64: Cursor column, a 0-based GRAPHEME index withinlines[line].goal::Int64: The CELL column UP/DOWN aim for. See_ta_set_goal!.widest::Int64: Monotone high-water mark of the widest line, in cells.focused::Reactive{Bool}: True while focused.disabled::Reactive{Bool}: True if the text area is disabled.on_change::Any: Called ason_change(area)after every edit.highlight::Any:source -> Vector{RichText}, ornothingfor plain text. Seecodeeditor.jl; aCodeEditoris aTextAreawith this set, not a second widget type.Untyped on purpose: it runs once per EDIT, not once per frame, so the dynamic call is off the hot path and a type parameter would buy nothing but a wider signature.
hl_lines::Vector{RichText}: Highlighted lines, valid forhl_version.hl_version::Int64: Theversionhl_lineswas built at;-1when there are none.
ManyUI.TextArea — Method
An area holding text, calling on_change(area) after every edit.
Focusable by construction, so it appears in focusable_widgets and is reachable by TAB with no further wiring.
ManyUI._ta_acts — Method
_ta_acts(d::Dispatch) -> Bool
True while d is live and at or past its target – everywhere except the capture phase, which belongs to ancestors that want to intercept. Internal.
ManyUI._ta_cells_before — Method
_ta_cells_before(s::String, k::Int64) -> Int64
Cells of the first k clusters of s – the CELL column of a caret at grapheme k, 0-based. Pure. Internal.
ManyUI._ta_edited! — Method
_ta_edited!(w::TextArea)
Bump version, re-pin goal, fire on_change. THE single exit of every edit, so none of the three can be forgotten at a call site. Internal.
ManyUI._ta_follow_caret! — Method
_ta_follow_caret!(w::TextArea)
Scroll the MINIMUM needed to bring the caret inside the content box, on both axes, via scroll_into_view. Internal.
set_scroll! and NOT scroll_to!: content_extent is widest cells wide, and the caret must be able to rest ONE cell PAST the last glyph of the widest line. scroll_to! would clamp that last cell away and hide the caret at the right edge – the over-scroll is one blank column, and the contract licenses it explicitly ("a UX bug, never corruption").
A no-op before the first layout, when the content box is still empty: there is no window to move yet, and set_text!/render! are correct at offset zero regardless.
ManyUI._ta_move_col! — Method
_ta_move_col!(w::TextArea, col::Int64)
Move the caret to cluster col of the CURRENT line, clamped: 0 is Home and typemax(Int) is End. A horizontal move, so it re-pins goal. Internal.
ManyUI._ta_moved! — Method
_ta_moved!(w::TextArea)
Re-pin goal, mark PAINT and follow the caret. THE single exit of every HORIZONTAL move, so none of the three can be forgotten. Internal.
ManyUI._ta_noop — Method
_ta_noop(_::Widget)
The default on_change: do nothing. Internal.
ManyUI._ta_page — Method
_ta_page(w::TextArea) -> Int64
Lines one PAGEUP/PAGEDOWN travels: one viewport LESS ONE ROW of overlap, so the reader keeps a landmark. At least one, so an unlaid-out area still moves. Internal.
ManyUI._ta_set_goal! — Method
_ta_set_goal!(w::TextArea)
Pin goal to the caret's current CELL column. Called by every HORIZONTAL move and every edit – and by nothing else.
goal is in CELLS, not graphemes: cells are the unit the user can see, and a grapheme goal lands UP/DOWN in a visually different column across wide clusters. Internal.
ManyUI._ta_split — Method
_ta_split(s::AbstractString) -> Vector{String}
s split on newlines into a document. NEVER empty: an empty string is [""], which is exactly what makes lines[line] total. Pure. Internal.
ManyUI._ta_split_at — Method
_ta_split_at(
s::String,
k::Int64
) -> Tuple{SubString{String}, SubString{String}}
s cut at grapheme k: the prefix of the first k clusters, and the rest. Neither half can split a codepoint, let alone a cluster. Pure. Internal.
_byte_after returns a COUNT of code units, and a code-unit count is NOT a Julia string index: SubString("世界", 1, 3) THROWS, because 3 is a continuation byte, while SubString("世界", 1, 1) is the whole first cluster. thisind maps the count back onto the character it lands in, which is what makes the prefix legal; the SUFFIX takes the count + 1 directly, because that always IS a character start. This is the one place in the file where a byte and a cluster index meet, and it meets them through the shared helpers alone.
ManyUI._ta_step_left! — Method
_ta_step_left!(w::TextArea) -> Bool
One cluster left, crossing into the previous line at column 0. False at the start of the DOCUMENT. Internal.
ManyUI._ta_step_right! — Method
_ta_step_right!(w::TextArea) -> Bool
One cluster right, crossing into the next line at the end of this one. False at the end of the DOCUMENT. Internal.
ManyUI._ta_widen! — Method
_ta_widen!(w::TextArea, s::AbstractString)
Raise the high-water mark to fit s. O(text_width(s)), never O(lines). Internal.
ManyUI.backspace! — Method
backspace!(w::TextArea) -> Bool
Delete the cluster BEFORE the caret; at column 0 of line n > 1 this JOINS with the previous line. False at (1, 0).
ManyUI.content_extent — Method
content_extent(w::TextArea) -> Size
Size(widest, length(lines)). OVERRIDES the container default: a TextArea's content is data, not children, so the bounding box of its (nonexistent) children is not its extent. This override IS the whole integration with Scrollbar.
ManyUI.delete_forward! — Method
delete_forward!(w::TextArea) -> Bool
Delete the cluster AT the caret; at the end of line n < end this JOINS the next line. False at the end of the document.
ManyUI.insert_newline! — Method
insert_newline!(w::TextArea)
Split the current line at the caret.
ManyUI.insert_text! — Method
insert_text!(w::TextArea, t::AbstractString)
Insert t at the caret; a '\n' splits the line.
The caret is RECOMPUTED from the new prefix, never advanced by the inserted cluster count: a combining mark MERGES into the preceding cluster and the count may not increase at all.
ManyUI.measure — Method
measure(w::TextArea, avail::Size) -> Size
avail. A TextArea takes the space it is offered and scrolls its content: an auto-HEIGHT TextArea would be as tall as its text and would never scroll at all. Give it height: 10 or a grow: 1 parent. Pure with respect to the tree.
ManyUI.move_by! — Method
move_by!(w::TextArea, n::Int64)
Move the caret by n CLUSTERS, wrapping across lines. Resets goal.
Clamped at both ends of the document: move_by!(w, typemax(Int) ÷ 2) is End-of-document and cannot run off it.
ManyUI.move_line! — Method
move_line!(w::TextArea, n::Int64) -> Int64
Move the caret by n LINES, landing on the cluster whose prefix width is the largest NOT EXCEEDING goal. Does NOT reset goal. Returns the new line.
_col_at_cell is what makes the landing safe: an UP/DOWN move can never come to rest INSIDE a wide cluster, and a run down through a SHORT line and back up returns to the column the user started in.
ManyUI.on_blur! — Method
on_blur!(w::TextArea)
Hide the caret.
ManyUI.on_event! — Method
on_event!(w::TextArea, d::Dispatch{KeyEvent})
CHAR/SPACE insert; ENTER splits; BACKSPACE/DELETE edit; arrows, PAGEUP/PAGEDOWN and HOME/END move. Each consumes.
Unmodified keys only: ctrl+a and friends belong to an application binding and consuming them here would silently shadow it. TAB and ESCAPE FALL THROUGH UNCONSUMED, which is what keeps the tab order alive.
HOME and END are per LINE, not per document – that is what a caret in a text editor means, and Scrollpane's document-wide HOME/END still reach an ancestor pane whenever this area is not focused.
ManyUI.on_event! — Method
on_event!(w::TextArea, d::Dispatch{PasteEvent})
Insert a paste at the caret, splitting it on newlines into real lines.
Unlike TextInput, which has nowhere to put a line break and strips them, a TextArea is exactly the widget a multi-line paste belongs in.
ManyUI.on_focus! — Method
on_focus!(w::TextArea)
Show the caret, and scroll every ancestor pane until this area is visible. reveal! is called EXPLICITLY because overriding on_focus! REPLACES the default that would have called it.
ManyUI.refresh_extent! — Method
refresh_extent!(w::TextArea) -> Size
Recompute widest from scratch. O(lines).
widest is a MONOTONE HIGH-WATER MARK: every edit raises it to max(widest, text_width(changed_line)) in O(1) and nothing lowers it. THE TRADEOFF, stated so nobody discovers it: after the longest line is deleted the horizontal range stays too wide – the thumb is slightly too small and there is slack to the right – until this is called. set_text! calls it. The alternative, an O(lines) rescan per keystroke, costs the editor to save a scrollbar thumb one cell.
ManyUI.set_text! — Method
set_text!(w::TextArea, s::AbstractString)
Replace the document with s, split on newlines. Resets the caret, the scroll and the extent.
ManyUI.text — Method
text(w::TextArea) -> String
The document as one string, lines joined by \n.