Skip to content

React-style layer hits a milestone: working app, lots of polish, new components#80

Merged
oyvindberg merged 14 commits into
mainfrom
react-component-revisions
May 17, 2026
Merged

React-style layer hits a milestone: working app, lots of polish, new components#80
oyvindberg merged 14 commits into
mainfrom
react-component-revisions

Conversation

@oyvindberg
Copy link
Copy Markdown
Owner

@oyvindberg oyvindberg commented May 17, 2026

So what's this

The React-ish layer in jatatui-react is 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 T everywhere, 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:

import static jatatui.components.Components.*;
import static jatatui.react.Components.*;

import jatatui.components.router.RouterApi;
import jatatui.components.router.Screen;
import jatatui.react.Element;
import jatatui.react.ReactApp;
import java.util.Optional;

public final class Demo {
  public static void main(String[] args) throws java.io.IOException {
    ReactApp.run(router(Screen.of("home", home())));
  }

  static Element home() {
    return component(ctx -> {
      var router = RouterApi.useRouter(ctx);
      return screenFrame("quit", () -> System.exit(0),
          column(
              length(1, text(" Welcome ")),
              length(3, button("Add source", "add", true,
                  () -> router.push("add-source", addSource()))),
              fill(1, empty())));
    });
  }

  static Element addSource() {
    return component(ctx -> {
      var name           = ctx.useState(() -> "");
      var confirmCancel  = ctx.useState(() -> false);
      var router         = RouterApi.useRouter(ctx);

      return screenFrame("back", () -> confirmCancel.set(true),
          column(
              length(1, text(" Add source ")),
              length(3, titledTextInput("Name", name.get(), name::set, "my-db", "name")),
              fill(1, empty()),
              length(3, button("Save", "save", true, () -> {
                  // ... persist name.get() ...
                  router.pop();
              })),

              confirmDialog(
                  confirmCancel.get(),
                  " Discard? ",
                  "Throw away unsaved changes?",
                  "Discard", "Keep editing", true,
                  () -> { confirmCancel.set(false); router.pop(); },
                  () -> confirmCancel.set(false))));
    });
  }
}

Reads roughly like a React/Ink app. ctx.useState, focusable button(...), titledTextInput(...) with controlled value + onChange, confirmDialog(...) driven by a state flag. The router is a Context-provided service descendants pull via RouterApi.useRouter(ctx). Tab cycles focus across the focusables; Enter activates buttons; Esc on the back chip pops the stack.

What changed

jatatui-react core

  • Reconciliation — drop hook state when the component type at a fiber slot changes. Fixes the canonical router-state-bleed bug where useState would 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 inline component(c -> ...) functions.
  • Eager autoFocus + post-commit rerenderFocusManager.register claims focus same-frame when nothing else holds it; Renderer.render requests a rerender if commit() chose a new winner. No more two-frame flicker on first render or after blur.
  • Imperative focusctx.focus(id) / ctx.blur(). TextInputComponent registers click-to-focus by default.
  • useTimeout(delayMs, callback, deps...) — fires once on the render thread; single shared daemon scheduler; auto-cleanup on unmount; deps follow useEffect convention.
  • EventRegistry: globalKeys phase honors Predicate matchers (was identity-equals, silently dropping ANY_KEY).

jatatui-components revisions

  • Router clears focus on every stack transition — new screen's autoFocus claims same-frame.
  • Dropdown got the full treatment: clickable rows, working keys (handlers moved to the dropdown's own fiber so they sit in the focused bubble chain), auto-close on focus loss, opaque overlay (Clear under the list), Tab-while-open commits the current highlight, now generic over option type with a labelFn (was stringly-typed; matches Picker/SelectableList/ListProps/TableProps).
  • Modal: replaced (int width, int height) with Size, matching Picker. Backwards-compat helper preserves the old signature.

New components in jatatui-components

  • picker.Picker — Quick-Pick / Go-To-Symbol search modal. Host-supplied Filter<T> and RowRenderer<T>. Forcibly claims focus on mount so keystrokes don't leak to host handlers.
  • selectablelist.SelectableList — Heterogeneous-row list. Predicate<T> isActivatable lets 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 via withActivateOnDoubleClick(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 with danger variant, built on Modal.
  • link.Link — Focusable + clickable + Enter-activatable. Router-agnostic via Runnable onActivate.
  • 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 with proper max-offset clamping against the assigned area height.

Test plan

  • bleep test jatatui-tests — 2044 tests green
  • bleep publish local-ivy0.30.0+15-e582d175 consumed by the downstream app without errors
  • All public-API renames have source-compat helpers (Modal.withSize(int, int), DropdownProps.ofStrings)
  • Readme PoC warning callout added on the React section

🤖 Generated with Claude Code

oyvindberg and others added 14 commits May 16, 2026 00:01
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>
@oyvindberg oyvindberg changed the title React layer + components: reconciliation, focus model, new components, polish React-style layer hits a milestone: working app, lots of polish, new components May 17, 2026
@oyvindberg oyvindberg merged commit 6badd6c into main May 17, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant