React-style layer hits a milestone: working app, lots of polish, new components#80
Merged
Conversation
RenderContext: add `focus(String id)` and `blur()` — delegate to the existing FocusManager.focus / blur but flip dirty so the change takes effect on the next render. This unblocks the standard "click a field to focus it", "focus the error field on validation fail", "focus the next list row when current selection changes" patterns; previously app code had to reach into FocusManager via package-private internals or a Scala bridge in `package jatatui.react`. TextInputComponent: when `focusId` is set, register an internal onClick(area, () -> ctx.focus(id)) by default. Opt-out via `withFocusOnClick(false)` for the rare case where the input participates in a different click pattern (parent drag-to-select, etc). Tests: - ImperativeFocusTest (3): focus(id) takes effect next render; blur clears focus; click handler can focus another fiber. - TextInputClickToFocusTest (2): click on the second of two titled text inputs transfers focus to it; withFocusOnClick(false) opts out. 1989 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r slot
Fixes a stack-router state-bleed bug. When a Router (or any conditional)
swaps two distinct components at the same fiber slot:
push(SourceEditor) → useState slot 0 stores SourceForm.State
pop, push(OutputEditor) → useState slot 0 still has SourceForm.State
computeIfAbsent skips the supplier
OutputEditor casts to FormState → CCE
Root cause: hooks are keyed by (fiber, hookIndex). The Provider intrinsic
in Router uses a fixed `renderChild("provider", child, area)` key, so
every screen lands on the same fiber. useState's initial supplier only
runs when no value exists — stale value from the previous screen wins.
Fix: in RenderContext.renderChild (both overloads), compare the new
Element's "type" against the type recorded for that fiber last frame.
If different, hooks.unmount(fiber) — runs cleanups for the subtree, drops
state / deps / memo cache / type tracking — before the new component
renders. Mirrors React's "different element type at same fiber → unmount
+ remount" rule.
Type identity:
- Element.Of: the Component reference (e.g. STABLE_COMPONENT for
apply(STABLE_COMPONENT, props))
- Element.Of with FUNCTION (component(c -> ...)): the body lambda's
Class — different `component(...)` call sites have different anon
lambda classes; same call site keeps the same class across renders
- Element.Sized: transparent — recurses to its child
- Element.Host: the Host class itself
Native-image safe: only Object.getClass() (intrinsic, not reflection)
and Class identity equality. No Method.invoke / Class.forName / etc.
ReconciliationTest covers: state resets when component changes; cleanups
run on unmount; state persists when same component re-renders;
distinct-stable-Component case via apply(...) form. 1916 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes for the "autoFocus invisible until second frame" bug: 1. FocusManager.register: when nothing is focused, the first autoFocus=true registration claims focus immediately. Same-frame useFocus returns true. Handles the canonical first-render and post-blur cases without needing a second render pass. 2. Renderer.render: after focus.commit(), if focused changed value, call requestRerender(). Handles the screen-change case (focused id from previous screen isn't re-registered, commit picks a new winner after render): the host's next loop tick paints with the new focus visible. No more "click anywhere to make focus appear" workaround. Updated ImperativeFocusTest.blur_clears_focus to use autoFocus=false (the previous version asserted blur survived the next render, which it never actually did — old behavior was just delayed by a frame). Added two new tests: auto_focus_visible_on_first_frame and screen_change_requests_rerender_so_new_focus_paints_next_tick. 1981 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eager-claim in FocusManager.register only fires when focused.isEmpty(). After router.push / pop / replace / reset, focused still pointed at the OUTGOING screen's id (just unregistered), so the new screen's useFocus(autoFocus=true) saw focused-not-empty and didn't claim. Commit re-picked, post-commit-rerender flipped dirty, but the user saw a one-frame flicker of "nothing focused" on every screen change. Fix: each RouterApi mutator (push, pop, replace, reset) calls ctx.blur() before mutating the stack. focused is empty going INTO the new screen's first render, eager-claim picks the new autoFocus SAME-frame, no flicker. Documented as the router's focus contract on the class. Test push_clears_focus_so_new_screen_focuses_first_frame walks A → B → A and asserts each transition's autoFocus is visible on the first frame after the transition. 1982 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three connected fixes: 1. Per-row click commits the selection. Each row in the option list now has its own click handler that calls onChange + closes the dropdown. Was: rows were bare text(...) with no handler — keyboard-only. 2. Up/Down/Enter/Esc registered on the dropdown's OWN fiber (was on the overlay portal's fiber). The overlay is a descendant of the dropdown via portal infrastructure; key dispatch bubbles UP from the focused fiber, so handlers attached to descendants of focused never fire. Moving them to ctx (= dropdown component fiber) puts them in the focused-bubble chain. 3. Trigger click also focuses the dropdown via ctx.focus(focusId), and the dropdown closes itself when focus moves away (open && !focused → openState.set(false)). This handles Tab moving to the next field — ReactApp's Tab handler updates focus before the dropdown's next render, the auto-close branch fires same-frame. Backdrop click outside the list still closes without committing. Tests: - click_on_option_row_commits_selection_and_closes - backdrop_click_closes_without_committing - keyboard_navigation_works_when_open (Down × 2 + Enter → blue selected) - losing_focus_closes_open_dropdown (focus moves to B → A's open list auto-closes) 1986 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The option list was painting on top of the existing buffer, so
underlying widgets' borders / dashes / text bled through any cell the
list didn't explicitly fill (gaps between rows, padding around row
text). Same fix as Modal — wrap the list in a stack with Clear
underneath:
return stack(
widget(Clear.instance()),
optionsList(...));
Now the overlay is fully opaque and the list shows cleanly over
whatever was painted in the main pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keyboard-driven UX expectation: navigate with Up/Down, then Tab to leave = "I picked this, move on". Previously Tab-while-open just closed and discarded the highlight. Now the auto-close-on-focus-loss branch fires onChange(currentHighlight) before clearing openState — but only when the highlight actually differs from the current selection, so opening and immediately Tab'ing away (no movement) is a no-op rather than a spurious onChange. Cancel paths stay non-committing because they bypass this branch: - Esc → onKey handler clears openState, no auto-close trigger - Backdrop click → onClick clears openState, same - Click on a row → onChange + clear openState (explicit commit) Tests: - tab_with_moved_highlight_commits_selection: open A, Down × 2, Tab → blue (idx 2) committed - tab_without_moving_highlight_does_not_fire_onchange: open A (start at idx 1), Tab → no onChange 1988 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picker (jatatui.components.picker): - PickerProps<T> record with named functional interfaces (Filter<T>, RowRenderer<T>) instead of raw Function/BiFunction; Size; configurable hint line via Optional<String> with withHint / withoutHint helpers. - Picker.of(props) — two-portal modal (backdrop + boxLayer with Clear underneath), owns query + selection state. Bubble-phase key handlers with stopPropagation so wrapping screens' Esc/Up/Down don't double-fire. - Esc / click-outside → onCancel; Enter / click-on-row → onSelect. Picker doesn't auto-close — host decides. SelectableList (jatatui.components.selectablelist): - SelectableListProps<T>: heterogeneous rows (List<T> + Predicate<T> isActivatable + RowRenderer<T>). Selection controlled. - Up/Down skip non-activatable rows. Enter only fires onActivate when the selected row is activatable. Click on activatable row selects (or activates if already selected). Click on non-activatable is a no-op. Mouse wheel scrolls without moving selection. - Auto-scroll gated on selection-change (via useRef<Integer> tracking prevSel) so wheel scrolls aren't reverted on the next render. Components factory: picker(props), selectableList(props). Tests: - PickerTest (9): enter commits, down-then-enter, up clamps at 0, down clamps at size-1, esc cancels, click-outside cancels, click-on-row commits, empty results render "no matches", hint can be dropped. - SelectableListTest (7): down skips non-activatable, up skips non-activatable, enter activates only when selected is activatable, click on unselected selects (then click again activates), click on non-activatable is noop, wheel scrolls without moving selection, selection-change auto-scrolls into view. 2004 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The internal TextInput uses autoFocus, but FocusManager's eager-claim only fires when nothing else is focused. If the host has another autoFocus'd element rendered first (typical: a list underneath the picker overlay), the input never gets focus and the user's keystrokes leak to whatever's focused on the host. Fix: ctx.useEffect(() -> ctx.focus(QUERY_FOCUS_ID)) on the picker's component fiber. Empty deps → runs once per mount; reconciliation handles unmount/remount cycles (close + reopen) so it re-fires when the picker comes back. The focus id is now exposed as Picker.QUERY_FOCUS_ID (was inline string) — hosts that need to imperatively focus / blur the picker's input can target it without hardcoding the string. Test picker_steals_focus_on_mount_even_with_competing_focusable verifies that with a host useFocus(autoFocus=true) registered first, mounting the picker still moves focus to QUERY_FOCUS_ID. 2005 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `showScrollbar` prop (default true) and overlays a vertical-right scrollbar via `stack(...)` when the item count exceeds the visible viewport. Stack-overlay rather than reserving a column with `row(fill, length)` so short / non-overflowing lists keep full width and there's no layout jitter at the overflow threshold. Tests cover the three cases: scrollbar present on overflow, absent when the prop is false, absent when content fits the viewport. Adds a runnable selectablelist demo with 66 mixed header/item rows so the scrollbar is exercised visibly.
When content overflows the viewport and the new showScrollbar prop is true (default), reserve the rightmost column for a vertical-right scrollbar via row(fill(1, rowsCol), length(1, bar)). Avoids the overpaint bleed seen when stacking the scrollbar on top: paragraphs (`text(content, style)`) call buf.setStyle(area, style) which paints the row's fg onto every cell — including the rightmost. A stacked scrollbar then writes its thumb with Style.empty(), and Cell.setStyle only patches what the new style has set, so the thumb inherits the row's fg (visible as magenta-bleed in styled lists). Reserving the column avoids the overpaint; the trade-off — content reflows 1 column when overflow toggles — matches every GUI scrollbar. No scrollbar rendered when content fits (n <= visibleH) so short lists keep their original column-width behavior. withShowScrollbar(false) opts out entirely. Tests: - scrollbar_renders_in_rightmost_column_when_content_overflows - scrollbar_absent_when_show_scrollbar_disabled - scrollbar_absent_when_content_fits_viewport - scrollbar_does_not_inherit_row_fg_when_rows_are_styled (regression for the magenta-bleed bug) 2009 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useTimeout(delayMs, callback, deps...) on RenderContext: fires once on the render thread after delayMs. Backed by a single shared daemon ScheduledExecutorService (one thread per process, regardless of how many hooks are armed) that only triggers requestRerender at the right time — the callback itself runs synchronously inside the next render's useTimeout call, so state.set / router.push / etc. from inside the callback are safe (always on the loop thread, never on the executor's thread). useEffect's deps convention: omit/empty = once on mount; changed deps cancel the in-flight timer and re-arm. Cleaned up on unmount via the existing useEffect-cleanup machinery. Replaces the daemon Thread.sleep pattern (leaks if the component unmounts before the timer fires; re-enters render from off-thread). SelectableList: new activateOnDoubleClick prop, default true. Click semantics: - true (default): single-click selects only, double-click within 500ms on the same row selects and activates. - false (legacy): single-click on an unselected row selects; single click on the already-selected row activates. Enter on the focused list activates the selected row regardless. Click-tracking state (last row, last timestamp) is a single useRef on the SelectableList's own fiber, captured into each renderRow so clicking different rows in quick succession doesn't false-trigger. Tests: - UseTimeoutTest (3): fires_once_after_delay_on_render_thread, unmount_before_delay_cancels_callback, deps_change_resets_the_timer - SelectableListTest additions (4): single_click_activate_legacy_behavior, single_click_selects_double_click_activates, clicks_on_different_rows_do_not_double_activate, second_click_after_double_click_window_does_not_activate 2021 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dropdown was the last component still requiring stringly-typed options.
Brought it in line with Picker / SelectableList / ListProps / TableProps:
public record DropdownProps<T>(
String label,
List<T> items,
Function<T, String> labelFn,
...
DropdownProps.of(...) is generic; ofStrings(...) is the quick-path with
labelFn = identity. Items now go through List.copyOf for defensive
immutability (matches every other component's items field). Components
.dropdown(...) keeps its String quick-path signature so existing callers
don't need to change; for typed options, build a DropdownProps via
DropdownProps.of and pass it to dropdown(DropdownProps).
Modal: replaced (int width, int height) with Size, matching Picker.
Source-compat via withSize(int, int) overload. ModalProps.DEFAULT_SIZE
exposes the 40x12 default.
Typed-options Dropdown test (color enum) verifies the generic path
end-to-end: open via click, click a row, onChange fires with the right
index.
1953 tests green (run-to-run variance with TestBackendTest's test
enumeration; the Dropdown/Modal/Picker/SelectableList suites all pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, Scrollable from typr Pulled into jatatui-components from typr.cli.app.components. All translated to Java, lightly cleaned up, registered in Components factory: - button/Button: bordered Tab-focusable button with primary/secondary variants. Enter + click activate. - chrome/BackButton: "← label" chip. Clickable, not Tab-focusable (host owns the Esc / b binding). Blurs focus before invoking back so the destination screen's autoFocus claims same-frame. - chrome/ScreenFrame: BackButton + gutter + content. Plus a withTitle variant for branded headers. - modal/ConfirmDialog: Yes/No confirmation modal, danger=true paints the box red. Built on Modal. - link/Link: focusable + clickable + Enter-activatable. Router- agnostic via Runnable onActivate (typr's version hardcoded router.push). focusable(autoFocus, onActivate, content) and focusable(focusId, autoFocus, onActivate, content) variants; click variant for non-focusable mouse-only links. - search/FuzzyMatch: IntelliJ-style fuzzy scoring with word-boundary bonus. Algorithm only, plugs into PickerProps.Filter. Includes Indexed<A> for precomputed corpora and rank() for filter+sort. - scrollable/Scrollable: wheel-scrollable column. Improves on the typr original by clamping max offset against the assigned area height (typr noted this as a TODO). CaretCell skipped — useful only with a Tree component, which doesn't exist yet. Components factory entries: button, backButton, screenFrame (2 overloads), confirmDialog, link (2 overloads), scrollable. Tests: 28 new (Button 3, BackButton 2, ScreenFrame 2, ConfirmDialog 3, Link 4, FuzzyMatch 10, Scrollable 4). 2044 total green. Readme: added a PoC warning on jatatui-react / jatatui-components — sweeping API changes likely as the layer matures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
So what's this
The React-ish layer in
jatatui-reactis now working well enough to drive a real app end-to-end — a multi-screen TUI config editor. Components, hooks, focus, event bubbling, portals, reconciliation: all behaving. We're not just shipping a prototype anymore — there's a real consumer driving real bug reports, and the layer is responding to them gracefully.That's the good news. The honest caveat: this is still proof-of-concept code. The patterns that have proven out (Renderer as host-friendly engine, controlled selection, generic
Teverywhere, fiber-keyed hook reconciliation) are stable enough to merge. But the surface is small and expect more sweeping changes as the layer keeps shaking out real-world wrinkles. There's now a callout to that effect at the top of the React section in the readme.What this looks like in code
A two-screen app with a router, a focusable button, an autofocused field, and a confirmation modal — covering most of the new layer at once:
Reads roughly like a React/Ink app.
ctx.useState, focusablebutton(...),titledTextInput(...)with controlled value + onChange,confirmDialog(...)driven by a state flag. The router is a Context-provided service descendants pull viaRouterApi.useRouter(ctx). Tab cycles focus across the focusables; Enter activates buttons; Esc on the back chip pops the stack.What changed
jatatui-reactcoreuseStatewould return a previous screen's value when the router swapped a different screen into the same fiber slot. Uses lambda class identity so it works even for inlinecomponent(c -> ...)functions.FocusManager.registerclaims focus same-frame when nothing else holds it;Renderer.renderrequests a rerender ifcommit()chose a new winner. No more two-frame flicker on first render or after blur.ctx.focus(id)/ctx.blur().TextInputComponentregisters click-to-focus by default.useTimeout(delayMs, callback, deps...)— fires once on the render thread; single shared daemon scheduler; auto-cleanup on unmount; deps followuseEffectconvention.globalKeysphase honorsPredicatematchers (was identity-equals, silently droppingANY_KEY).jatatui-componentsrevisionsClearunder the list), Tab-while-open commits the current highlight, now generic over option type with alabelFn(was stringly-typed; matches Picker/SelectableList/ListProps/TableProps).(int width, int height)withSize, matching Picker. Backwards-compat helper preserves the old signature.New components in
jatatui-componentspicker.Picker— Quick-Pick / Go-To-Symbol search modal. Host-suppliedFilter<T>andRowRenderer<T>. Forcibly claims focus on mount so keystrokes don't leak to host handlers.selectablelist.SelectableList— Heterogeneous-row list.Predicate<T> isActivatablelets headers / dividers / decorative rows coexist with selectable rows; Up/Down skips non-activatable, click activates only activatable. Auto-scroll gated on selection change so wheel scrolls aren't reverted. Default double-click to activate (legacy single-click-activate viawithActivateOnDoubleClick(false)). Vertical scrollbar in the rightmost column on overflow.button.Button— Bordered, Tab-focusable, Enter + click activate, primary/secondary variants.chrome.BackButton+chrome.ScreenFrame— Standard "← label" chip + the top-of-screen layout that uses it. Blurs focus on back so the destination's autoFocus claims same-frame.modal.ConfirmDialog— Yes/No modal withdangervariant, built onModal.link.Link— Focusable + clickable + Enter-activatable. Router-agnostic viaRunnable onActivate.search.FuzzyMatch— IntelliJ-style fuzzy scoring with word-boundary bonus. Algorithm only, plugs intoPickerProps.Filter. IncludesIndexed<A>for precomputed corpora andrank()for filter+sort.scrollable.Scrollable— Wheel-scrollable column with proper max-offset clamping against the assigned area height.Test plan
bleep test jatatui-tests— 2044 tests greenbleep publish local-ivy—0.30.0+15-e582d175consumed by the downstream app without errorsModal.withSize(int, int),DropdownProps.ofStrings)🤖 Generated with Claude Code