Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions render/composite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
51 changes: 43 additions & 8 deletions render/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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()
}
Expand Down
26 changes: 20 additions & 6 deletions render/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
119 changes: 87 additions & 32 deletions render/fields.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -72,45 +74,59 @@ 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)
}

// 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]:
Expand All @@ -124,26 +140,42 @@ 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
if sl.Min != nil {
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
}
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
Loading
Loading