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 1f1493d..96b2a4e 100644 --- a/render/composite.go +++ b/render/composite.go @@ -58,6 +58,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 s.openModals = 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 10eb514..96f6973 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 does Tabs. // 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. // // Modals open and close: a Modal joins the focus ring as a single element // drawn as its trigger, Enter toggles it open, and Esc closes the most @@ -136,18 +138,37 @@ type Surface struct { // single-goroutine depth-first pass, so the push/pop is safe. scope []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 + // openModals holds the component IDs of Modal components currently // open, in the order they were opened (a stack — Esc closes the most // recently opened first). Like fieldValues it is user-interaction state: @@ -211,13 +232,16 @@ 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, text fields, and modals. Enter activates the focused -// button and toggles the focused modal open/closed; Esc closes the most -// recently opened modal (and is otherwise ignored, so the host keeps its -// Esc semantics when no modal is open — see HasOpenModal). -// 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, input components, and modals. Enter activates the +// focused button and toggles the focused modal open/closed; Esc closes the +// most recently opened modal (and is otherwise ignored, so the host keeps its +// Esc semantics when no modal is open — see HasOpenModal). 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 @@ -248,8 +272,9 @@ 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 toggles a focused modal open/closed and activates a focused - // button; on a text field it is a no-op. + // Enter toggles a focused modal open/closed, activates a focused + // button, and toggles a focused checkbox; on a text-editable + // component it is a no-op. if s.focusedIsModal() { s.toggleModal(s.focusables[s.focusIdx]) return s, nil @@ -257,6 +282,38 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 "esc": // Esc closes the most recently opened modal, returning focus to it // (its trigger). With no modal open the key falls through untouched — @@ -265,16 +322,16 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.refreshFocusables(id) } 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) } } @@ -361,9 +418,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, text -// fields, and modals) 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, input +// components, and modals) 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 } @@ -383,11 +440,32 @@ 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 +} + +// 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 } // focusedIsModal reports whether the currently focused component is a Modal. @@ -396,25 +474,34 @@ func (s *Surface) focusedIsModal() bool { return c.Modal != 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. +// 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 @@ -431,8 +518,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 @@ -448,7 +535,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 @@ -495,11 +583,20 @@ func (s *Surface) compact() bool { } } +// isInteractive reports whether c joins the focus ring: Buttons, Modals, 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 || + c.Modal != nil +} + // collectFocusables walks the tree from the root and returns the IDs of -// interactive components (buttons, text fields, and modals) 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. +// interactive components (buttons, input components, and modals) 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. // // A Modal is its own focusable: the trigger child is the modal's chrome (like // a button's label), so the trigger subtree never joins the ring separately — @@ -519,7 +616,7 @@ func (s *Surface) collectFocusables() []string { if !ok { return } - if (c.Button != nil || c.TextField != nil || c.Modal != nil) && !collected[c.ID] { + if isInteractive(c) && !collected[c.ID] { collected[c.ID] = true out = append(out, c.ID) }