Text entry widgets

ManyUI._TI_CARETConstant

The caret's own style: REVERSE video, MERGED onto whatever cell the caret rests on so the glyph under it survives. Internal.

source
ManyUI.TextInputType
mutable struct TextInput{F1, F2} <: Widget

Single-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 in 0:n. PAINT-reactive.

  • focused::Reactive{Bool}: True while focused; drives the caret cell. PAINT-reactive.

  • placeholder::String: Shown dimmed while text is empty.

  • disabled::Reactive{Bool}: True if the input is disabled.

  • is_password::Bool

  • on_submit::Any: Called as on_submit(input) on ENTER.

  • on_change::Any: Called as on_change(input) when text changes.

source
ManyUI.TextInputMethod

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.

source
ManyUI._byte_afterMethod
_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.

source
ManyUI._cluster_width_atMethod
_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.

source
ManyUI._col_at_cellMethod
_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.

source
ManyUI._gindex_atMethod
_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.

source
ManyUI._ngraphemesMethod
_ngraphemes(s::AbstractString) -> Int64

Number of grapheme clusters in s. Pure. Internal.

source
ManyUI._ti_caretMethod
_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.

source
ManyUI._ti_caret_cellsMethod
_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.

source
ManyUI._ti_editsMethod
_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.

source
ManyUI._ti_headMethod
_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.

source
ManyUI._ti_strip_controlsMethod
_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.

source
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.

source
ManyUI._ti_tailMethod
_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.

source
ManyUI._ti_windowMethod
_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.

source
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.

source
ManyUI.content_extentMethod
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.

source
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.

source
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.

source
ManyUI.measureMethod
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.

source
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.

source
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.

source
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.

source
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.

source
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.

source
ManyUI.visible_scrollMethod
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.

source
ManyUI._TA_CARETConstant

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.

source
ManyUI.TextAreaType
mutable struct TextArea{F} <: Widget

Multi-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 within lines[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 as on_change(area) after every edit.

  • highlight::Any: source -> Vector{RichText}, or nothing for plain text. See codeeditor.jl; a CodeEditor is a TextArea with 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 for hl_version.

  • hl_version::Int64: The version hl_lines was built at; -1 when there are none.

source
ManyUI.TextAreaMethod

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.

source
ManyUI._ta_actsMethod
_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.

source
ManyUI._ta_cells_beforeMethod
_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.

source
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.

source
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.

source
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.

source
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.

source
ManyUI._ta_pageMethod
_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.

source
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.

source
ManyUI._ta_splitMethod
_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.

source
ManyUI._ta_split_atMethod
_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.

source
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.

source
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.

source
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.

source
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).

source
ManyUI.content_extentMethod
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.

source
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.

source
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.

source
ManyUI.measureMethod
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.

source
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.

source
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.

source
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.

source
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.

source
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.

source
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.

source
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.

source
ManyUI.textMethod
text(w::TextArea) -> String

The document as one string, lines joined by \n.

source