Skip to content

render: editable CheckBox, ChoicePicker, Slider, and DateTimeInput (finish #15) - #52

Merged
joestump merged 2 commits into
mainfrom
feat/42-editable-inputs
Jul 18, 2026
Merged

render: editable CheckBox, ChoicePicker, Slider, and DateTimeInput (finish #15)#52
joestump merged 2 commits into
mainfrom
feat/42-editable-inputs

Conversation

@joestump

Copy link
Copy Markdown
Collaborator

What

Finishes the editing work #15 started: the four remaining input components are now focusable and editable, and their edited values round-trip to the agent in a Button's ActionEvent.Context.

  • CheckBox — joins the focus ring; Space/Enter toggles. Toggling a bound value replaces the {binding} placeholder with real local state.
  • ChoicePicker — Up/Down move a highlight (clamped, no wrap), Space toggles the highlighted option. multipleSelection toggles membership; single-select replaces the selection (radio semantics). The edited value is a []string normalized to option-declaration order, honoring the native DynamicStringList shape.
  • Slider — Left/Right step within [min, max]. The v0.9 schema has no step field, so spans >= 1 step by 1 and smaller spans step by span/16 (one bar cell), keeping fractional sliders adjustable. Bound values seed from min.
  • DateTimeInput — reuses the TextField rune-edit path against its string value (appendText/deleteRune/editSeed), including cleared-to-empty "(unset)" semantics. No separate editor was needed.

How

Each component mirrors the existing hand-rolled TextField pattern in render/render.go rather than importing bubbles models: edits live in lazily initialized maps on Surface (checkValues, choiceValues, sliderValues, plus choiceCursor for the picker highlight; DateTimeInput shares fieldValues) and shadow the static literal for both rendering and value readout. The new edit paths live in render/inputs.go; collectFocusables now admits all input kinds via an isInteractive helper; gatherFieldValues reads edited state first so dispatch-time context gathering carries bool/[]string/float64/string values (Slider and DateTimeInput literals are now also reported when unedited, matching the other kinds). deleteSurface clears the new state; edits survive Apply merges like TextField edits do.

Chrome stays monochrome: focused inputs mark their value line with the same "▎" cue as the TextField cursor, and a focused picker draws a "▏" gutter with "▎" on the highlighted option. Idle rendering is byte-identical to before. The stale comment at render/fields.go:4 and the package/event docs are updated.

The activateButtonCtx test helper previously pressed Enter on every focusable while hunting for the button; Enter now toggles a focused CheckBox, so it tabs until focus is on an actual Button before activating.

Tests

go build ./... && go vet ./... && go test ./... green. New render/inputs_test.go (17 tests) follows the textfield_test.go patterns: focus-ring membership and order, toggle/highlight/step behavior with live re-render assertions, clamping at bounds and degenerate ranges, binding-placeholder shadowing for CheckBox and Slider, fractional-span stepping, DateTimeInput rune editing and clear-to-"(unset)", Space's dual role (toggle vs. text input), an end-to-end check that Button activation emits a ClientMessage whose Context carries all four edited value types, and edit survival across Apply merges.

Notes

  • The website/docs/wire-format.md "Not yet" item ("Editing beyond TextField") from the Done-when is deliberately not touched here — website/ docs are handled in the dedicated post-merge docs pass.

Fixes #42

🤖 Posted on behalf of @joestump by Claude.

Finish what #15 started: every input component now joins the focus ring
and edits live, with edited state shadowing the static literal for both
rendering and value readout, mirroring the hand-rolled TextField path.

- CheckBox: Space/Enter toggles; toggling a bound value replaces the
  {binding} placeholder with real local state.
- ChoicePicker: Up/Down move a highlight (clamped, no wrap), Space
  toggles the highlighted option; multipleSelection toggles membership
  while single-select replaces the selection (radio semantics). The
  edited value is a []string normalized to option-declaration order,
  honoring the native DynamicStringList shape.
- Slider: Left/Right step within [min, max]; the v0.9 schema has no
  step field, so spans >= 1 step by 1 and smaller spans step by
  span/16 (one bar cell). Bound values seed from min.
- DateTimeInput: reuses the TextField rune-edit path against its
  string value (appendText/deleteRune/editSeed), including the
  cleared-to-empty "(unset)" semantics.

Focus chrome stays monochrome: focused inputs mark their value line
with the same "▎" cue as the TextField cursor, and a focused picker
draws a "▏" gutter with "▎" on the highlighted option. Idle rendering
is unchanged.

Edited values flow through the existing dispatch-time context
gathering, so a Button's ActionEvent.Context now carries the edited
bool/[]string/float64/string values; gatherFieldValues also reports
Slider and DateTimeInput literals when unedited, matching the other
kinds. deleteSurface clears the new edit state alongside fieldValues.

The activateButtonCtx test helper no longer presses Enter on every
focusable while hunting for the button — Enter now toggles a focused
CheckBox, so it tabs until focus lands on an actual Button.

Fixes #42.

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 against the #42 Done-when. Verified locally at a914437: go build ./... && go vet ./... && go test ./... green, worktree clean.

Verdict: no blockers

What I actively hunted and confirmed correct:

  • Key routing — traced Key.String() through bubbletea v2 → ultraviolet: space genuinely reports "space" (never " "), so the explicit case "space" in render/render.go:259 is required and correct, and its dual role holds: toggle on CheckBox/ChoicePicker, literal " " insertion on TextField/DateTimeInput (TestSpaceStillInsertsIntoTextField guards this). Enter still activates buttons first (render/render.go:253) and only then falls to the CheckBox toggle; arrows only act on the matching focused component, so nothing steals keys from text editing.
  • State across Apply mergesapplyComponents (render/composite.go:71) never touches the edit maps and re-anchors focus by ID; DeleteSurface (render/composite.go:49-52) clears all four new maps. Ran extra probes beyond the PR's tests: options shrinking under a live cursor (cursor clamps at read time in pickerCursor, no panic, next Space toggles a valid option), zero-option picker (guarded, no panic), slider literal outside [min,max] (first step clamps into range).
  • ChoicePicker multi-value — edited value is a real []string normalized to option-declaration order (togglePickerOption, render/inputs.go:64-88); single-select is radio semantics; TestButtonContextCarriesEditedInputValues asserts the ClientMessage.Context types end-to-end (bool / []string / float64 / string).
  • Nil/missing guards — every edit path re-resolves s.byID[id] and checks the component kind; focusedComponent returns a zero Component on an empty ring; degenerate slider range (max <= min) refuses to step.
  • Idle-render stability — unedited/unfocused output is byte-identical: the new slider readout (strconv.FormatFloat(_, 'f', -1, 64), render/fields.go:191) matches dynNumString's literal branch exactly, and all focus cues are gated on isFocused.
  • Test quality — the new tests assert rendered content, FieldValues, and dispatched Context, not mere execution; the activateButtonCtx rewrite (render/context_test.go:30-56) correctly stops blind-Enter from corrupting CheckBox state.
  • Ticket compliance — all Done-when items met except website/docs/wire-format.md, which is correctly deferred to the post-merge docs pass per repo policy and noted in the PR body. render/fields.go:4 comment updated.

Nits (non-blocking)

  1. Stale picker selection can round-trip after a merge — verified by probe: select "a", then an updateComponents replaces the picker's options with only "x"; FieldValues/Context still report ["a"], a value naming no current option. This mirrors the established TextField edit-shadowing semantics and self-heals on the next toggle (normalization drops unknown values), but a sentence in the choiceValues doc comment (render/render.go:1129) acknowledging it would help the next reader.
  2. render/render.go:480 — the appendText doc comment wraps awkwardly after the edit: "…on first edit. On the first edit," reads as an accidental duplication; reflow the paragraph.
  3. render/context.go:76 — the Slider case reads c.Slider.Value.Literal inline while every sibling kind goes through a resolveDynamic* helper; a two-line resolveDynamicNumber would keep the pattern uniform.

🤖 Posted on behalf of @joestump by Claude.

Resolves conflicts with the modal PR (#41 train car): Surface keeps both
the editable-input state maps (checkValues, choiceValues, sliderValues,
choiceCursor) and openModals; Update handles Enter for modals, buttons,
and checkboxes plus Esc for modals; isInteractive now includes Modal so
the focus ring covers buttons, input components, and modals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joestump
joestump merged commit 449ed3c into main Jul 18, 2026
1 check passed
joestump added a commit that referenced this pull request Jul 18, 2026
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
joestump deleted the feat/42-editable-inputs 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.

Editable CheckBox / ChoicePicker / Slider / DateTimeInput (finish #15)

1 participant