Skip to content

render: interactive tab switching with per-surface active-tab state - #50

Merged
joestump merged 2 commits into
mainfrom
feat/45-tabs-switching
Jul 18, 2026
Merged

render: interactive tab switching with per-surface active-tab state#50
joestump merged 2 commits into
mainfrom
feat/45-tabs-switching

Conversation

@joestump

Copy link
Copy Markdown
Collaborator

What

Tabs are now interactive. The tab bar joins the focus ring; when it holds focus, left/right (or vi-style h/l) switch the active tab and its content renders in place of the old always-first-tab behavior.

How

  • Per-surface state: Surface.activeTabs maps Tabs component ID → active index, lazily initialized. A missing entry means the first tab.
  • Focus ring: collectFocusables collects the tab bar itself (when it has tabs) and recurses only into the ACTIVE tab's subtree, so focus can never land on a component hidden inside an inactive tab. switchTab re-collects the ring after each switch and keeps focus on the bar.
  • Chrome: the active title renders bold (Heading) normally and reverse-video (ButtonFocused) while the bar holds focus — same monochrome discipline as button focus chrome, so host themes win.
  • Preservation: applyComponents leaves activeTabs untouched (like focus, it's user state the server must not clobber); deleteSurface wipes it. Out-of-range indices left behind by a merge that shrank the tab list clamp back to the first tab at read time (activeTab), so rendering never indexes past the end.
  • Text fields keep h/l: the new key cases fall through to text-field editing when the focused component isn't a tab bar.
  • New exported accessor Surface.ActiveTab(id) mirrors Focusables() for hosts/tests.

Tests

render/tabs_test.go (all green with go build ./... && go vet ./... && go test -count=1 ./...):

  • tab bar joins the focus ring; empty Tabs stays out of it
  • left/right and h/l switching, wrap-around at both ends, no switching while blurred
  • active-title chrome: reverse-video only while the bar holds focus, bold otherwise
  • active tab survives Apply merges (updated active-tab content renders, no reset to first)
  • out-of-range index after a tab-list-shrinking update clamps to the first tab
  • focusables inside inactive tabs leave the ring and swap in on switch
  • h/l still type into a focused text field; arrows remain no-ops there
  • deleteSurface + re-create resets to the first tab

Docs: updated README.md and docs/wire-format.md "Not yet" lists. The website/ copy of the docs is deliberately untouched — the website docs "Not yet" update is deferred to the post-merge docs pass.

Fixes #45

🤖 Posted on behalf of @joestump by Claude.

Tabs previously rendered every title but only the first tab's content, with
no way to switch. Now the tab bar joins the focus ring: when it holds focus,
left/right (or vi-style h/l) switch the active tab, wrapping at either end.
The active title renders bold normally and reverse-video while the bar holds
focus, consistent with button focus chrome, keeping the monochrome discipline.

Active-tab state lives on the Surface keyed by component ID, and — like
focus — survives Apply/applyComponents merges; deleteSurface wipes it. An
index left out-of-range by an update that shrank the tab list clamps back to
the first tab at read time. The focus ring includes only the active tab's
descendants, so focus can never land on a component hidden in an inactive
tab; switching re-collects the ring and keeps focus on the bar. h/l still
type into a focused text field.

Fixes #45

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@joestump joestump left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the tab-switching implementation against #45. Verified at head e61d02f with go build ./... && go vet ./... && go test -count=1 ./... all green locally.

Blockers

None. Specifically hunted and cleared:

  • Key-routing regressions (render/render.go:246-260): the new "left","h" / "right","l" cases reproduce the old default-case behavior byte-for-byte when the focused component is not a tab bar — h/l still type into a focused TextField via key.Text, arrows stay no-ops there (key.Text is empty), and buttons are unaffected. Standalone only intercepts ctrl+c/esc/q, so nothing upstream swallows the new keys.
  • Wrap arithmetic (render/render.go:408): ((activeTab+delta)%n + n) % n is correct for delta ±1 from a clamped base in [0,n).
  • Panic safety in switchTab (render/render.go:395-417): the focused tab bar is always reachable from the root via a path that does not traverse its own tabs, so the post-switch collectFocusables always re-collects it and the focusIdx restore loop always finds it — no out-of-range focusIdx.
  • State preservation (render/composite.go:71-94): applyComponents leaves activeTabs untouched (matching the focus-preservation precedent), deleteSurface wipes it, and the read-time clamp in both renderTabs and collectFocusables prevents indexing past a shrunken tab list. Focus preservation composes correctly with clamping (a focused button inside a removed tab falls back to focusIdx = 0).
  • Focus-ring hygiene (render/render.go:569-578): only the ACTIVE tab's subtree is walked, so focusables inside hidden tabs leave the ring; empty Tabs stay out of it.
  • Chrome discipline: Heading (bold) / ButtonFocused (bold+reverse) only — monochrome, host themes win.
  • Test quality: render/tabs_test.go asserts observable behavior (rendered content via ansi.Strip, ring membership, exact chrome bytes), not mere execution, and covers every Done-when item: switching (arrows + h/l), wrap-around, blur guard, Apply survival, out-of-range clamp, delete/re-create reset, and the h/l-into-TextField non-regression.

Ticket compliance: all #45 Done-when items are met. The website/ "Not yet" copy is correctly deferred to the post-merge docs pass per repo policy, and the PR body says so.

Nits (non-blocking)

  1. Stale active-tab "resurrection"render/render.go:423-429 (activeTab). The clamp is read-time only and never rewrites s.activeTabs, so this sequence: user selects tab index 2 → a merge shrinks the list to 2 tabs (renders tab 0) → a later merge grows it back to 3+ tabs → the view silently jumps back to index 2 with no user input. Arguably a feature (selection survives a transient shrink), but it contradicts the "falls back to the first tab" phrasing in the doc comments. Either clear/rewrite the stored entry when the clamp fires, or document the resurrection as intended.
  2. Stale test namerender/placeholders_test.go:16 still names the test TestTabsRenderTitleBarAndFirstChild while its updated comment describes "the default Tabs rendering … the ACTIVE tab". TestTabsRenderTitleBarAndActiveChild (or similar) would match.

Approving-quality change: no verified correctness defects, ticket contract satisfied.

🤖 Posted on behalf of @joestump by Claude.

Union merge of interactive tab switching (#45) with the five feature PRs
that landed on main since the branch forked (#48 createSurface no-op,
#49 ChildList templates, #51 Modal open/close, #52 editable inputs,
#53 InputSubmitted/ChoiceSelected dispatch). Nothing from either side
was dropped:

- render/render.go: Surface keeps ALL state — activeTabs (branch) plus
  fieldValues/checkValues/choiceValues/sliderValues/choiceCursor/
  openModals/scope (main). isInteractive now also covers non-empty Tabs
  bars. collectFocusables walks only the ACTIVE tab's subtree, so
  inputs and modals inside inactive tabs are excluded from the focus
  ring, and only an open modal's content joins it. Key routing:
  left/right step a focused slider and switch tabs on a focused tab
  bar; h/l switch tabs only on the tab bar and stay literal runes on a
  focused TextField/DateTimeInput; enter/space/esc keep main's
  button/checkbox/choice/modal/InputSubmitted behavior.
- render/composite.go: deleteSurface clears every state map including
  activeTabs; applyComponents preserves active-tab, focus, modal, and
  edited-value state across merges, with out-of-range tabs clamping to
  the first at read time.
- render/tabs_test.go: two new tests pin the union — slider-focused vs
  tab-bar-focused left/right disambiguation, and inputs/modals inside
  an inactive tab excluded from the focus ring.
- README.md / docs/wire-format.md: merged feature descriptions; the
  'not yet' lists are now empty (issues #42#47 all landed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joestump

Copy link
Copy Markdown
Collaborator Author

Merged origin/main into this branch (370c53f) so it merges cleanly again. Main had landed five PRs touching the same files since the branch forked — #48 (createSurface no-op), #49 (ChildList templates), #51 (Modal), #52 (editable inputs), #53 (InputSubmitted/ChoiceSelected dispatch) — and the resolution is a union of features: nothing from either side was dropped.

Conflicted files and how each was resolved

  • render/render.go — the big one:
    • Surface keeps all state fields: this branch's activeTabs alongside main's fieldValues/checkValues/choiceValues/sliderValues/choiceCursor/openModals/scope.
    • isInteractive now covers Buttons, the five inputs, Modals, and non-empty Tabs bars; collectFocusables walks only the active tab's subtree, so inputs and modals inside inactive tabs are excluded from the focus ring (and only an open modal's content joins it).
    • Key routing precedence: left/right step a focused Slider (main) and switch tabs on a focused tab bar (branch), keyed off the focused component type; h/l switch tabs only when the tab bar holds focus and remain literal runes on a focused TextField/DateTimeInput (main's rune-edit path wins — the auto-merge had left duplicate case "left"/case "right" arms that would not compile, now unified); enter/space/esc keep main's button/checkbox/choice/modal/InputSubmitted behavior unchanged.
  • render/composite.godeleteSurface clears every state map including activeTabs; applyComponents preserves active-tab state across merges alongside focus/modal/edited-value preservation, with out-of-range indices clamping to the first tab at read time.
  • README.md / docs/wire-format.md — merged the feature descriptions from both sides; the "not yet" lists are now empty since Editable CheckBox / ChoicePicker / Slider / DateTimeInput (finish #15) #42createSurface: honor or explicitly document ignoring (theming/catalog hints dropped silently) #47 have all landed.

Tests added (render/tabs_test.go) pinning the new cross-feature interactions:

  • TestTabsLeftRightDisambiguateSliderAndTabBar — left/right step a focused slider without switching tabs, and switch tabs on the focused tab bar without stepping the slider.
  • TestTabsInactiveTabExcludesInputsAndModals — a TextField and a Modal in inactive tabs are excluded from the focus ring, swapping as the active tab changes.

Gate: go build ./..., go vet ./..., gofmt -l . (clean), go test -race ./... all green locally; CI on 370c53f passed (https://github.com/joestump-agent/a2tea/actions/runs/29659231142). #53's event dispatch paths are untouched and their tests still pass.

🤖 Posted on behalf of @joestump by Claude.

@joestump
joestump merged commit b54ba1b into main Jul 18, 2026
1 check passed
joestump added a commit that referenced this pull request Jul 18, 2026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joestump
joestump deleted the feat/45-tabs-switching branch July 18, 2026 20:15
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.

Tabs: interactive tab switching (only the first tab renders today)

1 participant