From a914437625a2a7c8b8f6cae2606eef7d7d7598fa Mon Sep 17 00:00:00 2001 From: Joe Stump Date: Sat, 18 Jul 2026 20:34:04 +0100 Subject: [PATCH] render: make CheckBox, ChoicePicker, Slider, and DateTimeInput editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- event/event.go | 5 +- render/composite.go | 4 + render/context.go | 51 +++- render/context_test.go | 26 +- render/fields.go | 119 +++++++--- render/inputs.go | 178 ++++++++++++++ render/inputs_test.go | 523 +++++++++++++++++++++++++++++++++++++++++ render/render.go | 198 ++++++++++++---- 8 files changed, 1005 insertions(+), 99 deletions(-) create mode 100644 render/inputs.go create mode 100644 render/inputs_test.go diff --git a/event/event.go b/event/event.go index c874789..e721ba1 100644 --- a/event/event.go +++ b/event/event.go @@ -7,8 +7,9 @@ // button's resolved *a2ui.EventAction (nil for buttons with no server event). // Alongside it the renderer emits a native a2ui.ClientMessage whose // ActionEvent.Context is populated from the surface's input component values -// (TextField → string, ChoicePicker → []string, CheckBox → bool, keyed by -// component ID) merged with the action's own declared context bindings. +// (TextField/DateTimeInput → string, ChoicePicker → []string, CheckBox → +// bool, Slider → float64, keyed by component ID) merged with the action's +// own declared context bindings. // // FormSubmitted is deprecated: A2UI v0.9 has no Form component, so a "form // submit" is just a Button Action whose ActionEvent.Context carries the diff --git a/render/composite.go b/render/composite.go index c9aa764..306d8a9 100644 --- a/render/composite.go +++ b/render/composite.go @@ -46,6 +46,10 @@ func (s *Surface) Apply(msgs []a2ui.ServerMessage) bool { s.focusIdx = 0 s.data = nil s.fieldValues = nil + s.checkValues = nil + s.choiceValues = nil + s.sliderValues = nil + s.choiceCursor = nil } } } diff --git a/render/context.go b/render/context.go index 9448141..308bba0 100644 --- a/render/context.go +++ b/render/context.go @@ -8,12 +8,12 @@ import a2ui "github.com/tmc/a2ui" // references them explicitly in Action.Event.Context. // // Value types match the component kind so the host and agent see a consistent -// shape: TextField → string, ChoicePicker → []string, CheckBox → bool. -// Slider and DateTimeInput are omitted — they are not yet editable and -// their readout is speculative. +// shape: TextField → string, DateTimeInput → string, ChoicePicker → []string, +// CheckBox → bool, Slider → float64. // -// For TextFields, an edited value (from fieldValues) shadows the component's -// static literal. Only literal values are resolved for other component types; +// For every component kind, an edited value (from the fieldValues / +// checkValues / choiceValues / sliderValues edit state) shadows the +// component's static literal. Only literal values are resolved otherwise; // binding and FunctionCall DynamicX values are skipped because the data model // is not applied yet; the host should resolve these from its own state. func (s *Surface) gatherFieldValues() map[string]any { @@ -34,14 +34,48 @@ func (s *Surface) gatherFieldValues() map[string]any { ctx[c.ID] = *v } } + case c.DateTimeInput != nil: + // DateTimeInput shares the TextField rune-edit path, so its + // edits live in fieldValues too. + if s.fieldValues != nil { + if v, ok := s.fieldValues[c.ID]; ok { + ctx[c.ID] = v + continue + } + } + if v := resolveDynamicString(c.DateTimeInput.Value); v != nil { + ctx[c.ID] = *v + } case c.ChoicePicker != nil: + if s.choiceValues != nil { + if v, ok := s.choiceValues[c.ID]; ok { + ctx[c.ID] = v + continue + } + } if v := resolveDynamicStringList(c.ChoicePicker.Value); v != nil { ctx[c.ID] = v } case c.CheckBox != nil: + if s.checkValues != nil { + if v, ok := s.checkValues[c.ID]; ok { + ctx[c.ID] = v + continue + } + } if v := resolveDynamicBool(c.CheckBox.Value); v != nil { ctx[c.ID] = *v } + case c.Slider != nil: + if s.sliderValues != nil { + if v, ok := s.sliderValues[c.ID]; ok { + ctx[c.ID] = v + continue + } + } + if v := c.Slider.Value.Literal; v != nil { + ctx[c.ID] = *v + } } } return ctx @@ -52,9 +86,10 @@ func (s *Surface) gatherFieldValues() map[string]any { // a button click (or at any time), the host calls FieldValues to read what // the user typed. // -// The returned map has the same structure as gatherFieldValues: TextField → -// string (edited value or literal), ChoicePicker → []string, CheckBox → bool. -// The returned map is a copy; mutating it does not affect the surface. +// The returned map has the same structure as gatherFieldValues: TextField and +// DateTimeInput → string, ChoicePicker → []string, CheckBox → bool, Slider → +// float64 — the edited value when one exists, else the static literal. The +// returned map is a copy; mutating it does not affect the surface. func (s *Surface) FieldValues() map[string]any { return s.gatherFieldValues() } diff --git a/render/context_test.go b/render/context_test.go index 3dc3521..0e1949b 100644 --- a/render/context_test.go +++ b/render/context_test.go @@ -27,14 +27,28 @@ func activateButtonCtx(t *testing.T, comps []a2ui.Component) map[string]any { } s := render.NewSurface("surf1", append([]a2ui.Component{root}, comps...)) s.Focus() - // Tab to the first button in the focus ring. The ring now includes - // TextFields, so the first focusable may not be the button. - for range s.Focusables() { - if _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); cmd != nil { + // Tab to the first button in the focus ring, pressing Enter only once + // focus lands on it. The ring includes every input component, and Enter + // is no longer inert on all of them (it toggles a focused CheckBox), so + // blindly pressing Enter at each stop would corrupt the values under + // test. + isButton := make(map[string]bool, len(comps)) + for _, c := range comps { + if c.Button != nil { + isButton[c.ID] = true + } + } + for _, id := range s.Focusables() { + if isButton[id] { + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("enter on button %q produced no cmd", id) + } cm := findMsg[a2ui.ClientMessage](t, collectMsgs(t, cmd)) - if cm.Action != nil { - return cm.Action.Context + if cm.Action == nil { + t.Fatalf("button %q ClientMessage has nil Action", id) } + return cm.Action.Context } s.Update(tea.KeyPressMsg{Code: tea.KeyTab}) } diff --git a/render/fields.go b/render/fields.go index 5150036..14c4064 100644 --- a/render/fields.go +++ b/render/fields.go @@ -1,11 +1,13 @@ package render // The field renderers in this file draw each input component's current -// value. TextField is editable — a focused field accepts typed edits, which -// shadow its static literal (see renderTextField and the Update loop in -// render.go). The rest (CheckBox, ChoicePicker, Slider, DateTimeInput) are -// still read-only visuals: they never mutate field state or emit input -// events. +// value. Every input component is editable while focused: TextField and +// DateTimeInput accept typed edits via the rune-edit path, CheckBox toggles, +// ChoicePicker moves a highlight and toggles options, and Slider steps its +// value (see the Update loop in render.go and the edit paths in inputs.go). +// Edited state shadows each component's static literal for both rendering and +// value readout. A focused component marks its value line with the "▎" cue — +// the same monochrome chrome language as the TextField cursor. import ( "strconv" @@ -72,21 +74,33 @@ func (s *Surface) renderTextField(c a2ui.Component) string { return wrapTo(s.labeled(s.dynString(tf.Label), cue+value), s.width) } -// renderCheckBox renders a CheckBox as "[x] label" when the value's literal -// is true and "[ ] label" otherwise. A non-literal value (binding/function) -// is treated as unchecked, with its placeholder appended after the label. +// renderCheckBox renders a CheckBox as "[x] label" when its current value is +// true and "[ ] label" otherwise. A toggled value (from checkValues) shadows +// the static literal. An unedited non-literal value (binding/function) is +// treated as unchecked, with its placeholder appended after the label; once +// the user toggles, the edited value replaces the placeholder. A focused +// CheckBox is prefixed with the "▎" cue. func (s *Surface) renderCheckBox(c a2ui.Component) string { cb := c.CheckBox + edited := false + if s.checkValues != nil { + _, edited = s.checkValues[c.ID] + } box := "[ ]" - if cb.Value.Literal != nil && *cb.Value.Literal { + if s.checkBoxValue(c) { box = "[x]" } line := box + " " + s.dynString(cb.Label) - switch { - case cb.Value.Binding != nil: - line += " " + s.styles.Caption.Render("{binding}") - case cb.Value.FunctionCall != nil: - line += " " + s.styles.Caption.Render("{fn}") + if !edited { + switch { + case cb.Value.Binding != nil: + line += " " + s.styles.Caption.Render("{binding}") + case cb.Value.FunctionCall != nil: + line += " " + s.styles.Caption.Render("{fn}") + } + } + if s.isFocused(c.ID) { + line = "▎" + line } return wrapTo(line, s.width) } @@ -94,23 +108,25 @@ func (s *Surface) renderCheckBox(c a2ui.Component) string { // renderChoicePicker renders a ChoicePicker: an optional caption label line, // then one line per option marked "(•)"/"( )" for single-select or // "[x]"/"[ ]" for the multipleSelection variant. An option is selected when -// its value appears in the picker's Value list literal; a non-literal Value -// (binding/function) renders every option unselected. Option display text is -// the option's label, falling back to its value. +// its value appears in the current selection — the edited selection (from +// choiceValues) when present, else the Value list literal; an unedited +// non-literal Value (binding/function) renders every option unselected. +// Option display text is the option's label, falling back to its value. When +// the picker holds focus every option line gains a "▏" gutter, with "▎" on +// the highlighted option Up/Down moves and Space toggles. func (s *Surface) renderChoicePicker(c a2ui.Component) string { cp := c.ChoicePicker multi := cp.Variant == a2ui.ChoicePickerVariantMultipleSelection - selected := make(map[string]bool, len(cp.Value.Literal)) - for _, v := range cp.Value.Literal { - selected[v] = true - } + selected := s.pickerSelection(c.ID, cp) + focused := s.isFocused(c.ID) + cursor := s.pickerCursor(c.ID, cp) lines := make([]string, 0, len(cp.Options)+1) if cp.Label != nil { if label := s.dynString(*cp.Label); label != "" { lines = append(lines, s.styles.Caption.Render(label)) } } - for _, opt := range cp.Options { + for i, opt := range cp.Options { mark := "( )" switch { case multi && selected[opt.Value]: @@ -124,16 +140,26 @@ func (s *Surface) renderChoicePicker(c a2ui.Component) string { if text == "" { text = opt.Value } - lines = append(lines, mark+" "+text) + line := mark + " " + text + if focused { + cue := "▏" + if i == cursor { + cue = "▎" + } + line = cue + line + } + lines = append(lines, line) } return wrapTo(strings.Join(lines, "\n"), s.width) } // renderSlider renders a Slider: an optional caption label line, then a // sliderCells-wide bar of filled "█" and empty "─" cells proportional to -// (value-min)/(max-min), followed by the numeric value. A missing value -// literal or a degenerate range (max <= min, which would divide by zero) -// renders a faint placeholder bar instead. +// (value-min)/(max-min), followed by the numeric value. A stepped value (from +// sliderValues) shadows the static literal. A missing value literal or a +// degenerate range (max <= min, which would divide by zero) renders a faint +// placeholder bar instead. A focused Slider's bar is prefixed with the "▎" +// cue. func (s *Surface) renderSlider(c a2ui.Component) string { sl := c.Slider lo := 0.0 @@ -141,9 +167,15 @@ func (s *Surface) renderSlider(c a2ui.Component) string { lo = *sl.Min } span := sl.Max - lo + value := sl.Value.Literal + if s.sliderValues != nil { + if v, ok := s.sliderValues[c.ID]; ok { + value = &v + } + } bar := s.styles.Caption.Render(strings.Repeat("─", sliderCells)) - if sl.Value.Literal != nil && span > 0 { - ratio := (*sl.Value.Literal - lo) / span + if value != nil && span > 0 { + ratio := (*value - lo) / span if ratio < 0 { ratio = 0 } @@ -154,8 +186,15 @@ func (s *Surface) renderSlider(c a2ui.Component) string { bar = strings.Repeat("█", filled) + strings.Repeat("─", sliderCells-filled) } body := bar - if v := dynNumString(sl.Value); v != "" { - body += " " + v + readout := dynNumString(sl.Value) + if value != nil { + readout = strconv.FormatFloat(*value, 'f', -1, 64) + } + if readout != "" { + body += " " + readout + } + if s.isFocused(c.ID) { + body = "▎" + body } label := "" if sl.Label != nil { @@ -165,14 +204,30 @@ func (s *Surface) renderSlider(c a2ui.Component) string { } // renderDateTimeInput renders a DateTimeInput: an optional caption label -// line, then the value string; an absent value renders a faint "(unset)". +// line, then the value string; an absent (or cleared) value renders a faint +// "(unset)". An edited value (from fieldValues — DateTimeInput shares the +// TextField rune-edit path) shadows the static literal, including an edit to +// the empty string, which must render as "(unset)" rather than fall back to +// the literal. A focused DateTimeInput's value is prefixed with the "▎" cue. // EnableDate/EnableTime and the Min/Max bounds are not rendered. func (s *Surface) renderDateTimeInput(c a2ui.Component) string { dt := c.DateTimeInput - value := s.dynString(dt.Value) + value := "" + edited := false + if s.fieldValues != nil { + if v, ok := s.fieldValues[c.ID]; ok { + value, edited = v, true + } + } + if !edited { + value = s.dynString(dt.Value) + } if value == "" { value = s.styles.Caption.Render("(unset)") } + if s.isFocused(c.ID) { + value = "▎" + value + } label := "" if dt.Label != nil { label = s.dynString(*dt.Label) diff --git a/render/inputs.go b/render/inputs.go new file mode 100644 index 0000000..22025f2 --- /dev/null +++ b/render/inputs.go @@ -0,0 +1,178 @@ +package render + +import a2ui "github.com/tmc/a2ui" + +// This file holds the edit paths for the non-text input components: CheckBox +// (Space/Enter toggles), ChoicePicker (Up/Down moves the highlight, Space +// toggles the highlighted option), and Slider (Left/Right steps within +// min/max). Each mirrors the hand-rolled TextField pattern in render.go: +// edits live in a lazily initialized map keyed by component ID and shadow the +// component's static literal for both rendering and value readout +// (gatherFieldValues / FieldValues). DateTimeInput needs no code here — it +// reuses the TextField rune-edit path (appendText / deleteRune) against its +// string value. + +// checkBoxValue returns the CheckBox's current value: the toggled value when +// edited, else the literal, else false (a bound/function value has no local +// state to start from — the data model is not applied to booleans yet). +func (s *Surface) checkBoxValue(c a2ui.Component) bool { + if s.checkValues != nil { + if v, ok := s.checkValues[c.ID]; ok { + return v + } + } + return c.CheckBox.Value.Literal != nil && *c.CheckBox.Value.Literal +} + +// toggleCheckBox flips the focused CheckBox's value, lazily initializing the +// checkValues map on first toggle. +func (s *Surface) toggleCheckBox() { + id := s.focusables[s.focusIdx] + c, ok := s.byID[id] + if !ok || c.CheckBox == nil { + return + } + if s.checkValues == nil { + s.checkValues = make(map[string]bool) + } + s.checkValues[id] = !s.checkBoxValue(c) +} + +// pickerSelection returns the ChoicePicker's current selection as a set: the +// edited selection when present, else the literal value list. A bound or +// function value starts empty — placeholders are chrome, not selection state. +func (s *Surface) pickerSelection(id string, cp *a2ui.ChoicePickerComponent) map[string]bool { + var vals []string + edited := false + if s.choiceValues != nil { + if v, ok := s.choiceValues[id]; ok { + vals, edited = v, true + } + } + if !edited { + vals = cp.Value.Literal + } + sel := make(map[string]bool, len(vals)) + for _, v := range vals { + sel[v] = true + } + return sel +} + +// pickerCursor returns the picker's highlighted option index, clamped to its +// options list (options can shrink across Apply merges). +func (s *Surface) pickerCursor(id string, cp *a2ui.ChoicePickerComponent) int { + cur := s.choiceCursor[id] + if max := len(cp.Options) - 1; cur > max { + cur = max + } + if cur < 0 { + cur = 0 + } + return cur +} + +// movePickerCursor moves the focused ChoicePicker's highlight by delta, +// clamping at the ends of the options list (no wrap-around). +func (s *Surface) movePickerCursor(delta int) { + id := s.focusables[s.focusIdx] + c, ok := s.byID[id] + if !ok || c.ChoicePicker == nil || len(c.ChoicePicker.Options) == 0 { + return + } + next := s.pickerCursor(id, c.ChoicePicker) + delta + if next < 0 { + next = 0 + } + if max := len(c.ChoicePicker.Options) - 1; next > max { + next = max + } + if s.choiceCursor == nil { + s.choiceCursor = make(map[string]int) + } + s.choiceCursor[id] = next +} + +// togglePickerOption toggles the highlighted option of the focused +// ChoicePicker. The multipleSelection variant toggles membership in the +// selection; the single-select variants replace the selection with the +// highlighted option (radio semantics — pressing Space on the already +// selected option keeps it selected). The stored value is normalized to +// option-declaration order, so literal values that name no declared option +// are dropped once the user edits. +func (s *Surface) togglePickerOption() { + id := s.focusables[s.focusIdx] + c, ok := s.byID[id] + if !ok || c.ChoicePicker == nil || len(c.ChoicePicker.Options) == 0 { + return + } + cp := c.ChoicePicker + optVal := cp.Options[s.pickerCursor(id, cp)].Value + sel := s.pickerSelection(id, cp) + if cp.Variant == a2ui.ChoicePickerVariantMultipleSelection { + sel[optVal] = !sel[optVal] + } else { + sel = map[string]bool{optVal: true} + } + out := make([]string, 0, len(sel)) + for _, opt := range cp.Options { + if sel[opt.Value] { + out = append(out, opt.Value) + } + } + if s.choiceValues == nil { + s.choiceValues = make(map[string][]string) + } + s.choiceValues[id] = out +} + +// sliderStep is the per-keypress increment for a slider spanning span. The +// v0.9 schema has no step field, so use 1 for spans of at least 1 (integer +// sliders step predictably) and span/sliderCells for smaller spans, so a +// fractional slider like 0..1 stays adjustable — one press moves the bar +// roughly one cell. +func sliderStep(span float64) float64 { + if span >= 1 { + return 1 + } + return span / sliderCells +} + +// stepSlider adjusts the focused Slider by dir (±1) steps, clamped into +// [min, max]. The first step seeds from the value literal, falling back to +// min when the value is bound/function (the data model is not applied to +// numbers yet). A degenerate range (max <= min) has nothing to step. +func (s *Surface) stepSlider(dir float64) { + id := s.focusables[s.focusIdx] + c, ok := s.byID[id] + if !ok || c.Slider == nil { + return + } + sl := c.Slider + lo := 0.0 + if sl.Min != nil { + lo = *sl.Min + } + span := sl.Max - lo + if span <= 0 { + return + } + cur, edited := s.sliderValues[id] + if !edited { + cur = lo + if sl.Value.Literal != nil { + cur = *sl.Value.Literal + } + } + next := cur + dir*sliderStep(span) + if next < lo { + next = lo + } + if next > sl.Max { + next = sl.Max + } + if s.sliderValues == nil { + s.sliderValues = make(map[string]float64) + } + s.sliderValues[id] = next +} diff --git a/render/inputs_test.go b/render/inputs_test.go new file mode 100644 index 0000000..4ef262c --- /dev/null +++ b/render/inputs_test.go @@ -0,0 +1,523 @@ +package render_test + +import ( + "math" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + a2ui "github.com/tmc/a2ui" + + "github.com/joestump-agent/a2tea/render" +) + +// Key press builders for the input-editing keys. Space carries its text the +// way a real terminal delivers it (Code AND Text set); the renderer matches +// on Key.String(), which reports "space". +func pressSpace() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} } +func pressKey(code rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code} +} + +// checkBoxComp builds a CheckBox with a literal value. +func checkBoxComp(id string, checked bool) a2ui.Component { + return a2ui.Component{ + ID: id, + CheckBox: &a2ui.CheckBoxComponent{ + Label: a2ui.StringLiteral("Agree"), + Value: a2ui.BoolLiteral(checked), + }, + } +} + +// pickerComp builds a ChoicePicker with options a/b/c and the given variant +// and literal selection. +func pickerComp(id string, variant a2ui.ChoicePickerVariant, selected []string) a2ui.Component { + return a2ui.Component{ + ID: id, + ChoicePicker: &a2ui.ChoicePickerComponent{ + Variant: variant, + Value: a2ui.StringListLiteral(selected), + Options: []a2ui.ChoiceOption{ + {Label: a2ui.StringLiteral("Alpha"), Value: "a"}, + {Label: a2ui.StringLiteral("Beta"), Value: "b"}, + {Label: a2ui.StringLiteral("Gamma"), Value: "c"}, + }, + }, + } +} + +// sliderComp builds a Slider with the given bounds and literal value. +func sliderComp(id string, min, max, value float64) a2ui.Component { + return a2ui.Component{ + ID: id, + Slider: &a2ui.SliderComponent{ + Min: &min, + Max: max, + Value: a2ui.NumberLiteral(value), + }, + } +} + +// dateTimeComp builds a DateTimeInput with a literal value. +func dateTimeComp(id, value string) a2ui.Component { + return a2ui.Component{ + ID: id, + DateTimeInput: &a2ui.DateTimeInputComponent{Value: a2ui.StringLiteral(value)}, + } +} + +// inputSurface wraps comps in a Column root, builds the surface, and focuses +// it (focus starts on the first focusable). +func inputSurface(t *testing.T, comps ...a2ui.Component) *render.Surface { + t.Helper() + ids := make([]string, len(comps)) + for i, c := range comps { + ids[i] = c.ID + } + root := a2ui.Component{ + ID: "root", + Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{IDs: ids}}, + } + s := render.NewSurface("s", append([]a2ui.Component{root}, comps...)) + s.Focus() + return s +} + +// TestAllInputsCollectedAsFocusables verifies that CheckBox, ChoicePicker, +// Slider, and DateTimeInput join Buttons and TextFields in the focus ring, in +// depth-first order. +func TestAllInputsCollectedAsFocusables(t *testing.T) { + s := inputSurface(t, + textFieldInput("tf", "x"), + checkBoxComp("cb", false), + pickerComp("cp", "", nil), + sliderComp("sl", 0, 10, 5), + dateTimeComp("dt", "2026-07-18"), + actionButton("btn", "lbl", "go", nil), + textLabel("lbl", "Go"), + ) + want := []string{"tf", "cb", "cp", "sl", "dt", "btn"} + if got := s.Focusables(); !reflect.DeepEqual(got, want) { + t.Fatalf("Focusables() = %v, want %v", got, want) + } +} + +// TestCheckBoxSpaceToggles verifies Space flips a focused CheckBox's value, +// re-rendering live and updating FieldValues. +func TestCheckBoxSpaceToggles(t *testing.T) { + s := inputSurface(t, checkBoxComp("cb", false)) + + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "[ ] Agree") { + t.Fatalf("initial render = %q, want unchecked box", out) + } + + s.Update(pressSpace()) + out = ansi.Strip(s.View().Content) + if !strings.Contains(out, "[x] Agree") { + t.Fatalf("after space, render = %q, want checked box", out) + } + if v := s.FieldValues()["cb"]; v != true { + t.Fatalf("FieldValues['cb'] = %v, want true", v) + } + + // Toggle back. + s.Update(pressSpace()) + out = ansi.Strip(s.View().Content) + if !strings.Contains(out, "[ ] Agree") { + t.Fatalf("after second space, render = %q, want unchecked box", out) + } + if v := s.FieldValues()["cb"]; v != false { + t.Fatalf("FieldValues['cb'] = %v, want false", v) + } +} + +// TestCheckBoxEnterToggles verifies Enter also toggles a focused CheckBox and +// does not emit any activation command. +func TestCheckBoxEnterToggles(t *testing.T) { + s := inputSurface(t, checkBoxComp("cb", true)) + + _, cmd := s.Update(pressKey(tea.KeyEnter)) + if cmd != nil { + t.Fatalf("enter on a CheckBox should not produce a cmd, got %#v", cmd()) + } + if v := s.FieldValues()["cb"]; v != false { + t.Fatalf("FieldValues['cb'] = %v, want false after enter toggle", v) + } +} + +// TestCheckBoxToggleShadowsBindingPlaceholder verifies that toggling a +// CheckBox whose value is an unresolved binding replaces the "{binding}" +// placeholder with real local state: render drops the placeholder and +// FieldValues reports the toggled bool (bound values are otherwise skipped). +func TestCheckBoxToggleShadowsBindingPlaceholder(t *testing.T) { + s := inputSurface(t, a2ui.Component{ + ID: "cb", + CheckBox: &a2ui.CheckBoxComponent{ + Label: a2ui.StringLiteral("Agree"), + Value: a2ui.BoolBinding("/flags/on"), + }, + }) + + if _, ok := s.FieldValues()["cb"]; ok { + t.Fatal("unedited bound CheckBox should be absent from FieldValues") + } + + s.Update(pressSpace()) + out := ansi.Strip(s.View().Content) + if strings.Contains(out, "{binding}") { + t.Fatalf("toggled bound CheckBox should drop the placeholder: %q", out) + } + if !strings.Contains(out, "[x] Agree") { + t.Fatalf("bound CheckBox starts unchecked, so first toggle checks it: %q", out) + } + if v := s.FieldValues()["cb"]; v != true { + t.Fatalf("FieldValues['cb'] = %v, want true", v) + } +} + +// TestChoicePickerSingleSelectSpaceSelects verifies Up/Down move the +// highlight and Space replaces the selection in a single-select picker. +func TestChoicePickerSingleSelectSpaceSelects(t *testing.T) { + s := inputSurface(t, pickerComp("cp", "", []string{"a"})) + + // Move the highlight to Beta and select it. + s.Update(pressKey(tea.KeyDown)) + s.Update(pressSpace()) + + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "(•) Beta") { + t.Fatalf("Beta should be selected: %q", out) + } + if strings.Contains(out, "(•) Alpha") { + t.Fatalf("single-select must replace the previous selection: %q", out) + } + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"b"}) { + t.Fatalf("FieldValues['cp'] = %v, want [b]", got) + } + + // Space again on the selected option keeps it selected (radio semantics). + s.Update(pressSpace()) + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"b"}) { + t.Fatalf("re-pressing space deselected: FieldValues['cp'] = %v, want [b]", got) + } +} + +// TestChoicePickerMultiSelectToggles verifies Space toggles membership in a +// multipleSelection picker and the stored value keeps option-declaration +// order regardless of toggle order. +func TestChoicePickerMultiSelectToggles(t *testing.T) { + s := inputSurface(t, pickerComp("cp", a2ui.ChoicePickerVariantMultipleSelection, nil)) + + // Select Gamma first, then Alpha: value must still come out [a c]. + s.Update(pressKey(tea.KeyDown)) + s.Update(pressKey(tea.KeyDown)) + s.Update(pressSpace()) + s.Update(pressKey(tea.KeyUp)) + s.Update(pressKey(tea.KeyUp)) + s.Update(pressSpace()) + + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"a", "c"}) { + t.Fatalf("FieldValues['cp'] = %v, want [a c] in option order", got) + } + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "[x] Alpha") || !strings.Contains(out, "[ ] Beta") || !strings.Contains(out, "[x] Gamma") { + t.Fatalf("render = %q, want Alpha and Gamma checked", out) + } + + // Toggle Alpha back off. + s.Update(pressSpace()) + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"c"}) { + t.Fatalf("FieldValues['cp'] = %v, want [c] after untoggling Alpha", got) + } +} + +// TestChoicePickerCursorClampsAtEnds verifies the highlight does not wrap: +// Up at the top and Down past the bottom stay put. +func TestChoicePickerCursorClampsAtEnds(t *testing.T) { + s := inputSurface(t, pickerComp("cp", "", nil)) + + // Up at the top: still highlights Alpha; Space selects it. + s.Update(pressKey(tea.KeyUp)) + s.Update(pressSpace()) + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"a"}) { + t.Fatalf("after up at top, FieldValues['cp'] = %v, want [a]", got) + } + + // Down past the last option clamps to Gamma. + for range 5 { + s.Update(pressKey(tea.KeyDown)) + } + s.Update(pressSpace()) + if got := s.FieldValues()["cp"]; !reflect.DeepEqual(got, []string{"c"}) { + t.Fatalf("after down past end, FieldValues['cp'] = %v, want [c]", got) + } +} + +// TestChoicePickerFocusHighlightCue verifies a focused picker draws the "▎" +// cue on the highlighted option and the "▏" gutter on the rest, and that an +// unfocused picker draws no gutter. +func TestChoicePickerFocusHighlightCue(t *testing.T) { + s := inputSurface(t, + pickerComp("cp", "", nil), + actionButton("btn", "lbl", "go", nil), + textLabel("lbl", "Go"), + ) + + s.Update(pressKey(tea.KeyDown)) + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "▎( ) Beta") { + t.Fatalf("focused picker should mark the highlighted option: %q", out) + } + if !strings.Contains(out, "▏( ) Alpha") { + t.Fatalf("focused picker should gutter the other options: %q", out) + } + + // Tab away: the gutter disappears. + s.Update(pressKey(tea.KeyTab)) + out = ansi.Strip(s.View().Content) + if strings.Contains(out, "▎") || strings.Contains(out, "▏") { + t.Fatalf("unfocused picker should draw no gutter: %q", out) + } +} + +// TestSliderStepsWithinRange verifies Left/Right step a focused Slider by 1 +// and the rendered readout tracks the edited value. +func TestSliderStepsWithinRange(t *testing.T) { + s := inputSurface(t, sliderComp("sl", 0, 100, 50)) + + s.Update(pressKey(tea.KeyRight)) + if v := s.FieldValues()["sl"]; v != 51.0 { + t.Fatalf("FieldValues['sl'] = %v, want 51", v) + } + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "51") { + t.Fatalf("render should show the stepped value: %q", out) + } + + s.Update(pressKey(tea.KeyLeft)) + s.Update(pressKey(tea.KeyLeft)) + if v := s.FieldValues()["sl"]; v != 49.0 { + t.Fatalf("FieldValues['sl'] = %v, want 49", v) + } +} + +// TestSliderClampsAtBounds verifies stepping never leaves [min, max]. +func TestSliderClampsAtBounds(t *testing.T) { + s := inputSurface(t, sliderComp("sl", 0, 3, 2)) + + for range 5 { + s.Update(pressKey(tea.KeyRight)) + } + if v := s.FieldValues()["sl"]; v != 3.0 { + t.Fatalf("FieldValues['sl'] = %v, want clamped max 3", v) + } + for range 10 { + s.Update(pressKey(tea.KeyLeft)) + } + if v := s.FieldValues()["sl"]; v != 0.0 { + t.Fatalf("FieldValues['sl'] = %v, want clamped min 0", v) + } +} + +// TestSliderFractionalSpanSteps verifies a slider whose span is below 1 steps +// by span/16 (one bar cell) instead of jumping the whole range. +func TestSliderFractionalSpanSteps(t *testing.T) { + s := inputSurface(t, sliderComp("sl", 0, 0.5, 0)) + + s.Update(pressKey(tea.KeyRight)) + v, ok := s.FieldValues()["sl"].(float64) + if !ok { + t.Fatalf("FieldValues['sl'] = %T, want float64", s.FieldValues()["sl"]) + } + if want := 0.5 / 16; math.Abs(v-want) > 1e-12 { + t.Fatalf("FieldValues['sl'] = %v, want %v", v, want) + } +} + +// TestSliderBoundValueSeedsFromMin verifies stepping a slider whose value is +// an unresolved binding starts from min — the placeholder is chrome, not a +// number — and that the unedited bound slider is absent from FieldValues. +func TestSliderBoundValueSeedsFromMin(t *testing.T) { + min := 10.0 + s := inputSurface(t, a2ui.Component{ + ID: "sl", + Slider: &a2ui.SliderComponent{ + Min: &min, + Max: 20, + Value: a2ui.NumberBinding("/volume"), + }, + }) + + if _, ok := s.FieldValues()["sl"]; ok { + t.Fatal("unedited bound Slider should be absent from FieldValues") + } + + s.Update(pressKey(tea.KeyRight)) + if v := s.FieldValues()["sl"]; v != 11.0 { + t.Fatalf("FieldValues['sl'] = %v, want 11 (min + one step)", v) + } + out := ansi.Strip(s.View().Content) + if strings.Contains(out, "{binding}") { + t.Fatalf("stepped bound slider should drop the placeholder readout: %q", out) + } +} + +// TestSliderDegenerateRangeIgnoresSteps verifies a slider whose max <= min +// cannot be stepped (nothing to adjust, and no divide-by-zero). +func TestSliderDegenerateRangeIgnoresSteps(t *testing.T) { + s := inputSurface(t, sliderComp("sl", 5, 5, 5)) + + s.Update(pressKey(tea.KeyRight)) + if v := s.FieldValues()["sl"]; v != 5.0 { + t.Fatalf("FieldValues['sl'] = %v, want untouched literal 5", v) + } +} + +// TestSliderUneditedLiteralInFieldValues verifies an unedited Slider reports +// its literal in FieldValues, matching the other input kinds. +func TestSliderUneditedLiteralInFieldValues(t *testing.T) { + s := inputSurface(t, sliderComp("sl", 0, 100, 42)) + if v := s.FieldValues()["sl"]; v != 42.0 { + t.Fatalf("FieldValues['sl'] = %v, want literal 42", v) + } +} + +// TestDateTimeTypingEditsValue verifies a focused DateTimeInput accepts the +// TextField rune-edit path: typing appends, backspace deletes, and the +// rendered value tracks the edit. +func TestDateTimeTypingEditsValue(t *testing.T) { + s := inputSurface(t, dateTimeComp("dt", "2026-07-1")) + + s.Update(typeKey('8')) + if v := s.FieldValues()["dt"]; v != "2026-07-18" { + t.Fatalf("FieldValues['dt'] = %v, want %q", v, "2026-07-18") + } + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "2026-07-18") { + t.Fatalf("render should show the edited value: %q", out) + } + + s.Update(pressKey(tea.KeyBackspace)) + s.Update(pressKey(tea.KeyBackspace)) + if v := s.FieldValues()["dt"]; v != "2026-07-" { + t.Fatalf("FieldValues['dt'] = %v, want %q", v, "2026-07-") + } +} + +// TestDateTimeClearedRendersUnset verifies backspacing a DateTimeInput to +// empty renders "(unset)" and reports the cleared empty string, not the stale +// literal. +func TestDateTimeClearedRendersUnset(t *testing.T) { + s := inputSurface(t, dateTimeComp("dt", "07")) + + s.Update(pressKey(tea.KeyBackspace)) + s.Update(pressKey(tea.KeyBackspace)) + + out := ansi.Strip(s.View().Content) + if !strings.Contains(out, "(unset)") { + t.Fatalf("cleared DateTimeInput should render (unset): %q", out) + } + if v := s.FieldValues()["dt"]; v != "" { + t.Fatalf("FieldValues['dt'] = %v, want cleared empty string", v) + } +} + +// TestSpaceStillInsertsIntoTextField guards the space key's dual role: on a +// focused TextField it is text input, not a toggle command. +func TestSpaceStillInsertsIntoTextField(t *testing.T) { + s := inputSurface(t, textFieldInput("tf", "a")) + + s.Update(pressSpace()) + s.Update(typeKey('b')) + if v := s.FieldValues()["tf"]; v != "a b" { + t.Fatalf("FieldValues['tf'] = %q, want %q", v, "a b") + } +} + +// TestButtonContextCarriesEditedInputValues is the end-to-end dispatch check: +// edit every input kind, activate the button, and verify the emitted +// ClientMessage's ActionEvent.Context carries the edited bool, []string, +// float64, and string values. +func TestButtonContextCarriesEditedInputValues(t *testing.T) { + s := inputSurface(t, + checkBoxComp("cb", false), + pickerComp("cp", a2ui.ChoicePickerVariantMultipleSelection, nil), + sliderComp("sl", 0, 10, 5), + dateTimeComp("dt", "2026-07-1"), + actionButton("btn", "lbl", "submit", nil), + textLabel("lbl", "Submit"), + ) + + // CheckBox: toggle on. + s.Update(pressSpace()) + // ChoicePicker: select Beta. + s.Update(pressKey(tea.KeyTab)) + s.Update(pressKey(tea.KeyDown)) + s.Update(pressSpace()) + // Slider: two steps right. + s.Update(pressKey(tea.KeyTab)) + s.Update(pressKey(tea.KeyRight)) + s.Update(pressKey(tea.KeyRight)) + // DateTimeInput: complete the date. + s.Update(pressKey(tea.KeyTab)) + s.Update(typeKey('8')) + // Button: activate. + s.Update(pressKey(tea.KeyTab)) + _, cmd := s.Update(pressKey(tea.KeyEnter)) + if cmd == nil { + t.Fatal("enter on the button produced no cmd") + } + + cm := findMsg[a2ui.ClientMessage](t, collectMsgs(t, cmd)) + if cm.Action == nil { + t.Fatal("ClientMessage.Action is nil") + } + ctx := cm.Action.Context + + if v := ctx["cb"]; v != true { + t.Fatalf("Context['cb'] = %v, want true", v) + } + if v := ctx["cp"]; !reflect.DeepEqual(v, []string{"b"}) { + t.Fatalf("Context['cp'] = %v, want [b]", v) + } + if v := ctx["sl"]; v != 7.0 { + t.Fatalf("Context['sl'] = %v, want 7", v) + } + if v := ctx["dt"]; v != "2026-07-18" { + t.Fatalf("Context['dt'] = %v, want %q", v, "2026-07-18") + } +} + +// TestInputEditsSurviveApplyMerge verifies edited input values survive an +// updateComponents merge that touches a sibling (edit state is keyed by ID +// and merges do not clear it). +func TestInputEditsSurviveApplyMerge(t *testing.T) { + s := inputSurface(t, + checkBoxComp("cb", false), + sliderComp("sl", 0, 10, 5), + ) + + s.Update(pressSpace()) // toggle cb on + s.Update(pressKey(tea.KeyTab)) + s.Update(pressKey(tea.KeyRight)) // sl -> 6 + + s.Apply([]a2ui.ServerMessage{ + {UpdateComponents: &a2ui.UpdateComponents{ + SurfaceID: "s", + Components: []a2ui.Component{textLabel("note", "updated")}, + }}, + }) + + vals := s.FieldValues() + if v := vals["cb"]; v != true { + t.Fatalf("FieldValues['cb'] = %v after merge, want true", v) + } + if v := vals["sl"]; v != 6.0 { + t.Fatalf("FieldValues['sl'] = %v after merge, want 6", v) + } +} diff --git a/render/render.go b/render/render.go index e2adf24..00ecdc7 100644 --- a/render/render.go +++ b/render/render.go @@ -3,7 +3,7 @@ // component tree with lipgloss. // // Rendering is real for the core catalog: Text (with variants), Card, Column, -// Row, List, Divider, Button, and read-only visuals for the input components +// Row, List, Divider, Button, and editable visuals for the input components // (TextField, CheckBox, ChoicePicker, Slider, DateTimeInput). Media components // (Image, Icon, Video, AudioPlayer) draw compact placeholders, as do Tabs and // Modal. DynamicString data bindings render as placeholders until the data @@ -16,11 +16,13 @@ // a2ui.ClientMessage whose ActionEvent carries Name, SurfaceID, and // SourceComponentID. FunctionCall-only buttons emit no ClientMessage. // -// TextFields are editable: they join Buttons in the focus ring, and when one -// holds focus printable keys append to its value and backspace deletes. Typed -// values are read back via FieldValues and flow into a button's ActionEvent -// Context. Other input components (CheckBox, ChoicePicker, Slider, -// DateTimeInput) remain read-only visuals for now. +// Input components are editable and join Buttons in the focus ring. A focused +// TextField (and DateTimeInput, which shares the same rune-edit path against +// its string value) accepts printable keys and backspace. A focused CheckBox +// toggles with Space or Enter. A focused ChoicePicker moves its highlight with +// Up/Down and toggles the highlighted option with Space. A focused Slider +// steps with Left/Right within its min/max bounds. Edited values are read back +// via FieldValues and flow into a button's ActionEvent Context. // // Composition contract. A renderer is designed to be embedded as a child of a // larger TUI (crush), not to be the root of its own program: @@ -121,18 +123,37 @@ type Surface struct { // bound DynamicString/Value components at render time. data map[string]any - // focusables are the IDs of interactive components (buttons and text - // fields) in depth-first tree order; focusIdx points at the one holding - // focus. + // focusables are the IDs of interactive components (buttons and input + // components) in depth-first tree order; focusIdx points at the one + // holding focus. focusables []string focusIdx int - // fieldValues holds the edited text of TextField components, keyed by - // component ID. It is lazily initialized on first edit. An entry here - // shadows the component's static literal value for both rendering and - // value readout (gatherFieldValues / FieldValues). + // fieldValues holds the edited text of TextField and DateTimeInput + // components, keyed by component ID. It is lazily initialized on first + // edit. An entry here shadows the component's static literal value for + // both rendering and value readout (gatherFieldValues / FieldValues). fieldValues map[string]string + // checkValues holds the toggled state of CheckBox components, keyed by + // component ID. Lazily initialized on first toggle; an entry shadows the + // component's static literal, mirroring fieldValues. + checkValues map[string]bool + + // choiceValues holds the edited selection of ChoicePicker components, + // keyed by component ID. The value is the selected option values in + // option-declaration order. An entry shadows the static literal. + choiceValues map[string][]string + + // sliderValues holds the adjusted value of Slider components, keyed by + // component ID. An entry shadows the static literal. + sliderValues map[string]float64 + + // choiceCursor holds each ChoicePicker's highlighted option index, keyed + // by component ID. It is presentation state, not a value: it never flows + // into FieldValues or ActionEvent.Context. + choiceCursor map[string]int + // compactOverride controls whether compact rendering is forced on, // forced off, or decided automatically from the surface width. compactOverride compactOverride @@ -190,10 +211,13 @@ func NewSurface(surfaceID string, components []a2ui.Component, opts ...Option) * func (s *Surface) Init() tea.Cmd { return nil } // Update implements tea.Model. When the surface holds focus, Tab / Shift+Tab -// cycle through buttons and text fields. Enter activates the focused button. -// When a text field holds focus, rune key presses append to its value and -// backspace deletes the last rune; Enter on a text field is a no-op (there is -// no form-submit concept). +// cycle through buttons and input components. Enter activates the focused +// button. When a text-editable component (TextField or DateTimeInput) holds +// focus, rune key presses append to its value and backspace deletes the last +// rune; Enter there is a no-op (there is no form-submit concept). A focused +// CheckBox toggles on Space or Enter; a focused ChoicePicker moves its +// highlight with Up/Down and toggles the highlighted option with Space; a +// focused Slider steps with Left/Right. // // Button activation emits two messages via tea.Batch: // - event.ButtonClicked — the host-facing convenience event, carrying @@ -224,21 +248,54 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "shift+tab": s.focusIdx = (s.focusIdx - 1 + len(s.focusables)) % len(s.focusables) case "enter": - // Enter only activates buttons; on a text field it is a no-op. + // Enter activates buttons and toggles checkboxes; on a text-editable + // component it is a no-op. if s.focusedIsButton() { return s, s.activate() } + if s.focusedIsCheckBox() { + s.toggleCheckBox() + } + case "space": + // Space is a command on toggleable components and text input on + // text-editable ones. Key.String() reports "space" (never " "), so + // the text-input branch cannot be reached from the default case and + // must be handled here. + switch { + case s.focusedIsCheckBox(): + s.toggleCheckBox() + case s.focusedIsChoicePicker(): + s.togglePickerOption() + case s.focusedIsTextEditable(): + s.appendText(" ") + } + case "up": + if s.focusedIsChoicePicker() { + s.movePickerCursor(-1) + } + case "down": + if s.focusedIsChoicePicker() { + s.movePickerCursor(1) + } + case "left": + if s.focusedIsSlider() { + s.stepSlider(-1) + } + case "right": + if s.focusedIsSlider() { + s.stepSlider(1) + } case "backspace": - if s.focusedIsTextField() { + if s.focusedIsTextEditable() { s.deleteRune() } default: - // Printable key presses edit the focused text field. key.Text is the - // actual characters produced by the key — it already accounts for - // Shift (so "A" and "!" arrive as text) and is empty for navigation - // and control keys (arrows, Home/End, F-keys), whose Code is a - // sentinel above unicode.MaxRune that must never be inserted. - if s.focusedIsTextField() && key.Text != "" { + // Printable key presses edit the focused text-editable component. + // key.Text is the actual characters produced by the key — it already + // accounts for Shift (so "A" and "!" arrive as text) and is empty for + // navigation and control keys (arrows, Home/End, F-keys), whose Code + // is a sentinel above unicode.MaxRune that must never be inserted. + if s.focusedIsTextEditable() && key.Text != "" { s.appendText(key.Text) } } @@ -325,9 +382,9 @@ func (s *Surface) isFocused(id string) bool { return s.Focused() && len(s.focusables) > 0 && s.focusables[s.focusIdx] == id } -// Focusables returns the IDs of interactive components (buttons and text -// fields) in depth-first focus-ring order. The host can use this to inspect -// the focus ring; it is mainly intended for testing. +// Focusables returns the IDs of interactive components (buttons and input +// components) in depth-first focus-ring order. The host can use this to +// inspect the focus ring; it is mainly intended for testing. func (s *Surface) Focusables() []string { return s.focusables } @@ -347,32 +404,62 @@ func (s *Surface) focusedIsButton() bool { return c.Button != nil } -// focusedIsTextField reports whether the currently focused component is a -// TextField. -func (s *Surface) focusedIsTextField() bool { +// focusedIsTextEditable reports whether the currently focused component takes +// the rune-edit path: a TextField, or a DateTimeInput (whose string value is +// edited the same way). +func (s *Surface) focusedIsTextEditable() bool { c := s.focusedComponent() - return c.TextField != nil + return c.TextField != nil || c.DateTimeInput != nil } -// EditingText reports whether the surface holds focus on an editable text -// field, i.e. printable key presses are currently text input rather than -// commands. Hosts (and a2tea.Standalone) use this to decide whether keys -// like "q" should quit or be typed. +// focusedIsCheckBox reports whether the currently focused component is a +// CheckBox. +func (s *Surface) focusedIsCheckBox() bool { + c := s.focusedComponent() + return c.CheckBox != nil +} + +// focusedIsChoicePicker reports whether the currently focused component is a +// ChoicePicker. +func (s *Surface) focusedIsChoicePicker() bool { + c := s.focusedComponent() + return c.ChoicePicker != nil +} + +// focusedIsSlider reports whether the currently focused component is a Slider. +func (s *Surface) focusedIsSlider() bool { + c := s.focusedComponent() + return c.Slider != nil +} + +// EditingText reports whether the surface holds focus on a text-editable +// component (TextField or DateTimeInput), i.e. printable key presses are +// currently text input rather than commands. Hosts (and a2tea.Standalone) use +// this to decide whether keys like "q" should quit or be typed. func (s *Surface) EditingText() bool { - return s.Focused() && s.focusedIsTextField() + return s.Focused() && s.focusedIsTextEditable() } -// editSeed returns the text an edit of the given TextField starts from: its -// literal value, or its binding's resolved data-model value, or "" when the -// value is absent or unresolved. A display placeholder like "{binding}" or -// "{fn}" is rendering chrome, not field content — it must never leak into -// edits, FieldValues, or the ActionEvent.Context round-tripped to the agent. +// editSeed returns the text an edit of the given TextField or DateTimeInput +// starts from: its literal value, or its binding's resolved data-model value, +// or "" when the value is absent or unresolved. A display placeholder like +// "{binding}" or "{fn}" is rendering chrome, not field content — it must never +// leak into edits, FieldValues, or the ActionEvent.Context round-tripped to +// the agent. func (s *Surface) editSeed(id string) string { c, ok := s.byID[id] - if !ok || c.TextField == nil || c.TextField.Value == nil { + if !ok { + return "" + } + var d a2ui.DynamicString + switch { + case c.TextField != nil && c.TextField.Value != nil: + d = *c.TextField.Value + case c.DateTimeInput != nil: + d = c.DateTimeInput.Value + default: return "" } - d := *c.TextField.Value switch { case d.Literal != nil: return *d.Literal @@ -389,8 +476,8 @@ func (s *Surface) editSeed(id string) string { return "" } -// appendText appends printable text to the focused text field's edited value, -// lazily initializing the fieldValues map on first edit. On the first edit, +// appendText appends printable text to the focused text-editable component's +// edited value, lazily initializing the fieldValues map on first edit. On the first edit, // the field's current content (literal or resolved binding, via editSeed) is // used as the starting point so typed characters extend the existing text. // The argument is key.Text — the characters the key produced — which may be @@ -406,7 +493,8 @@ func (s *Surface) appendText(text string) { s.fieldValues[id] += text } -// deleteRune removes the last rune from the focused text field's edited value. +// deleteRune removes the last rune from the focused text-editable component's +// edited value. // On the first edit of a pristine field it seeds from the literal, so backspace // can shorten (and ultimately clear) a pre-filled default — not just text the // user typed this session. If the value drops back to exactly the original @@ -453,10 +541,18 @@ func (s *Surface) compact() bool { } } +// isInteractive reports whether c joins the focus ring: Buttons and every +// editable input component (TextField, CheckBox, ChoicePicker, Slider, +// DateTimeInput). +func isInteractive(c a2ui.Component) bool { + return c.Button != nil || c.TextField != nil || c.CheckBox != nil || + c.ChoicePicker != nil || c.Slider != nil || c.DateTimeInput != nil +} + // collectFocusables walks the tree from the root and returns the IDs of -// interactive components (buttons and text fields) in depth-first order. A -// component referenced by more than one parent (legal adjacency-list reuse) is -// collected once — it is one interactive element however many times it is +// interactive components (buttons and input components) in depth-first order. +// A component referenced by more than one parent (legal adjacency-list reuse) +// is collected once — it is one interactive element however many times it is // drawn. func (s *Surface) collectFocusables() []string { var out []string @@ -472,7 +568,7 @@ func (s *Surface) collectFocusables() []string { if !ok { return } - if (c.Button != nil || c.TextField != nil) && !collected[c.ID] { + if isInteractive(c) && !collected[c.ID] { collected[c.ID] = true out = append(out, c.ID) }