feat: TextArea/Input editing API, element resize setters, and real-cursor protocol (#106)
* feat: add programmatic editing API to TextArea and Input
Add CursorPos(), SetCursorPos(pos int), and InsertText(s string) to both
TextArea and Input. Positions are grapheme-cluster indices (count of whole
glyphs before the cursor), translated to/from the internal rune index via new
cluster<->rune index helpers in grapheme.go.
InsertText routes through a shared internal insertString path so the bound
State[string] and the cursor stay cluster-consistent: combining marks glue to
the preceding base and the cursor lands after the final whole cluster.
SetCursorPos clamps to [0, ClusterCount] and snaps to a cluster boundary.
* feat: add Element.SetHeight/SetWidth for post-creation resize
Add SetHeight(Value) and SetWidth(Value) to Element, each setting the layout
style field and calling MarkDirty() so layout recomputes on the next frame.
Intended for retained elements held across renders (e.g. a list that grows row
count each frame), removing the need to write style.Height through unsafe.
Scoped to SetHeight/SetWidth only; min/max are held until a concrete need.
* feat: framework-driven real terminal cursor for text widgets
Add a cursor reporting protocol so the framework places the real terminal
cursor at the focused text widget at the end of every frame.
- CursorReporter interface and Element.ReportCursor; *Element implements it.
- Content-local cursor source on Element (WithCursorSource / SetCursorSource);
ReportCursor converts content-local (col,row) to an absolute cell, applying
each scrollable ancestor's scroll offset and clip (out-of-view hides it),
mirroring the renderer.
- TextArea and Input each install a cluster-aware content-local source that
shares the drawn-glyph geometry (cursorRowCol / runeIndexToDisplayCol +
scrollPos). Input uses scrollPos; TextArea relies on the enclosing scrollable.
- App.placeCursor runs after Flush and postRenderHook (the final terminal op),
offsetting by the inline start row in inline mode.
Behavior change: the real cursor is now the default. The drawn glyph is opt-in
via WithTextAreaVirtualCursor() / WithInputVirtualCursor() (presence = on), with
the glyph rune set by WithTextAreaCursorRune / WithInputCursorRune. Add
WithManualCursor() to disable framework cursor management; WithCursor() is now a
deprecated no-op alias. Docs: bless StringWidth for string/cursor width math and
note RuneWidth is low-level per-rune.
* refactor: make Input.cursorColRow a pure read and document cursorVisible
* fix(cursor): mirror renderer outermost-scrollable transform and scrollbar gutter
ReportCursor walked every scrollable ancestor and applied each one's
content-origin/scroll transform, accumulating them. The renderer applies
only the outermost scrollable's transform to the entire clipped subtree
(renderClippedElement recurses into descendants without re-basing at
nested scrollables), so a cursor inside two or more nested scrollables
was placed off by the inner scrollable's content origin.
Find the outermost scrollable ancestor, apply its transform once, and
clip against its viewport. Also shrink the clip by one when that
scrollable reserves a column for the vertical scrollbar, so a cursor in
the gutter column is reported hidden to match the renderer.
* refactor: remove dead cursorVisible field and its guards
WithCursor is a no-op, so cursorVisible was permanently false and the
cursor is driven each frame by placeCursor. Every `if \!a.cursorVisible`
guard had no else branch, so its body always ran; unwrapping them is
behavior-preserving.
Remove the field, its two struct-literal inits, and all guards across
app.go, app_lifecycle.go, and app_suspend_unix.go. Drop the obsolete
cursorVisible assertion from the WithCursor no-op test and delete
TestResumeSequence_CursorVisible, which exercised the now-impossible
cursorVisible=true branch; the default hide-on-resume path stays covered
by TestResumeSequence_DynamicAltScreen.
* docs: correct cursor design doc to match implementation
Two inaccuracies surfaced during implementation. The Part C timing note
claimed postRenderHook runs before Flush and that a cursor set there is
clobbered by cell writes; in fact placeCursor runs after Flush and after
postRenderHook as the final terminal op, so the rationale was wrong.
The element-side ReportCursor sketch showed a bare cr.X + col. The
implemented version mirrors the renderer: it walks to the outermost
scrollable ancestor, applies that single transform, and clips against its
viewport with the vertical-scrollbar gutter accounted for. Rewrite the
sketch to match.
* test: cover scrollbar-hidden gutter case in cursor reporting
Complements the gutter-hidden test by pinning the other branch of the
ReportCursor scrollbar check: when the scrollbar is hidden the gutter
column is reclaimed, needsVerticalScrollbar is false, and a cursor in the
last content column stays visible.
* fix(cursor): capture cursor position during render as the single source of truth
ReportCursor re-derived the focused widget's screen cell by walking parent
pointers and mirroring the renderer's transform and clip. That duplication
was the root cause of a class of drift bugs: it only knew about scrollable
ancestors, so it missed overflow-hidden clipping and a scrollable element's
own viewport, and it had to re-learn every renderer nuance (nested
scrollables, the scrollbar gutter) or diverge.
Make the renderer the single source of truth. The renderer already computes
the exact screen position and clip for everything it draws, so capture the
cursor cell there (captureCursor) at the element's own draw site, in both the
normal and clipped paths, using the same content origin and clip applied to
its content. ReportCursor becomes a getter of that captured value, and the
focused element's capture is reset before each render (both renderFrame and
RenderFull) so an element that stops drawing reports no cursor.
This retires the parent-walk added earlier in this PR and fixes the two gaps
it could not reach: a cursor clipped out by an overflow-hidden ancestor is now
hidden, and a cursor source on a scrollable element is clipped to that
element's own viewport.
* fix(dispatch): error when an OnFocused binding loses its focus gate
A focus-gated stop binding (OnFocused) gates on focus only when the
component that owns it implements IsFocused() and is mounted; the dispatch
builder captures that component's IsFocused as the gate. When a host
aggregates several widgets' KeyMaps but has no IsFocused (or a widget is
rendered directly instead of mounted), the gate is dropped: the binding
keeps Stop:true but fires unconditionally, so the first widget captures
every key and arrow keys never reach a focused scrollable.
validate() exempted all focus-gated entries from conflict detection on the
assumption that only one can be focused at a time. That assumption breaks
precisely when the gate was dropped, so two such bindings for the same key
slipped through. Treat a focus-gated entry as an ordinary stop handler
when its focusCheck is nil, so the conflict surfaces with an error that
names the fix (mount each focusable widget as its own component). Document
the rule in CLAUDE.md.
* test: add two-widget focus-routing regression test
Pin the behavior that broke when widgets were hand-aggregated instead of
mounted: with two mounted focusable inputs, a typed key reaches only the
focused one. After Tab to the first input, 'a' lands there and the second
stays empty; after Tab to the second, 'b' lands there and the first is
unchanged.
The existing single-input integration test cannot catch a regression here
because it has no second widget for a key to leak into, and the new
dispatch guard only rejects the broken wiring at build time rather than
proving the correct wiring routes at runtime.
Joel Holsteen · 22d · 64b8b59 · parent 2a4aa99 · 30 files +1658 -35