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
8 changes: 6 additions & 2 deletions docs/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ Also implemented since earlier revisions of this doc:
surface.
- `ActionEvent.Context` is populated from the surface's input component
values, so typed `TextField` edits round-trip to the agent.
- `ChildList` templates: the dynamic template form expands one instance of
the template component per element of the bound data-model list, with
bindings inside each instance resolving against that element first (an
empty path or `/` is the element itself) before falling back to the
surface data model. An `updateDataModel` on the list grows or shrinks the
children on the next render.
- `createSurface` is an explicit, documented no-op (a2tea issue #47): a
surface is established by its first `updateComponents`, so the message
carries nothing a2tea needs. Its `theme` hints (`primaryColor`, `iconUrl`,
Expand All @@ -72,8 +78,6 @@ Also implemented since earlier revisions of this doc:
rather than silently falling through.

**Not yet** (tracked as follow-ups)
- `ChildList` templates: children resolve from explicit ID lists only; the
dynamic template form is not expanded.
- Tab switching: tabs are not focusable, so the first tab is always active.
- Modal content: a modal renders only its trigger; its content stays hidden.
- Editing beyond `TextField`: `CheckBox`, `ChoicePicker`, `Slider`, and
Expand Down
33 changes: 19 additions & 14 deletions render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ type Surface struct {
// bound DynamicString/Value components at render time.
data map[string]any

// scope is the stack of data-model elements for the ChildList template
// instances currently being rendered: renderTemplateChildren pushes each
// list element before rendering the template component and pops it after,
// so bindings inside the instance resolve against that element first (see
// lookupBinding). Empty outside template expansion. Rendering is a
// 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.
Expand Down Expand Up @@ -558,12 +566,12 @@ func (s *Surface) withWidth(w int, f func() string) string {
return f()
}

// renderChildren renders each child ID in a ChildList in order. The
// dynamic-template form of ChildList is not yet supported (no data model);
// it renders a single placeholder.
// renderChildren renders a ChildList: each explicit child ID in order for the
// static form, or one template-component instance per data-model list element
// for the dynamic form (see renderTemplateChildren).
func (s *Surface) renderChildren(cl a2ui.ChildList, seen map[string]bool) []string {
if cl.Template != nil {
return []string{s.styles.Caption.Render("[a2tea: dynamic children not yet supported]")}
return s.renderTemplateChildren(cl.Template, seen)
}
parts := make([]string, 0, len(cl.IDs))
for _, id := range cl.IDs {
Expand All @@ -580,14 +588,11 @@ func (s *Surface) dynString(d a2ui.DynamicString) string {
case d.Literal != nil:
return *d.Literal
case d.Binding != nil:
if s.data != nil {
key := strings.TrimPrefix(d.Binding.Path, "/")
if v, ok := s.data[key]; ok {
if str, ok := v.(string); ok {
return str
}
return fmt.Sprintf("%v", v)
if v, ok := s.lookupBinding(d.Binding.Path); ok {
if str, ok := v.(string); ok {
return str
}
return fmt.Sprintf("%v", v)
}
return "{binding}"
case d.FunctionCall != nil:
Expand Down Expand Up @@ -626,11 +631,11 @@ func childIDs(c a2ui.Component) []string {
case c.Button != nil:
return []string{c.Button.Child}
case c.Column != nil:
return c.Column.Children.IDs
return childListIDs(c.Column.Children)
case c.Row != nil:
return c.Row.Children.IDs
return childListIDs(c.Row.Children)
case c.List != nil:
return c.List.Children.IDs
return childListIDs(c.List.Children)
case c.Modal != nil:
return []string{c.Modal.Content, c.Modal.Trigger}
case c.Tabs != nil:
Expand Down
135 changes: 135 additions & 0 deletions render/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package render

import (
"fmt"
"reflect"
"strconv"
"strings"

a2ui "github.com/tmc/a2ui"
)

// renderTemplateChildren expands the dynamic-template form of a ChildList:
// the template's Path resolves in the data model to a list, and the template
// component renders once per element with bindings scoped to that element.
//
// An unresolved path (the data model hasn't delivered the list yet) or an
// empty list expands to no children — the list grows when the data arrives,
// exactly as a later updateDataModel grows or shrinks it. A resolved value
// that is not a list is a producer error and renders a caption placeholder,
// matching the surface's other "[a2tea: ...]" diagnostics.
//
// Cycle safety comes from renderComponent's seen set: each instance renders
// through the same ancestor-path guard, so a template component that
// references its own container reports a cycle instead of recursing forever.
func (s *Surface) renderTemplateChildren(t *a2ui.ChildTemplate, seen map[string]bool) []string {
v, ok := s.lookupBinding(t.Path)
if !ok {
return nil
}
list, ok := asList(v)
if !ok {
return []string{s.styles.Caption.Render(fmt.Sprintf("[a2tea: template path %q is not a list]", t.Path))}
}
parts := make([]string, 0, len(list))
for _, el := range list {
s.scope = append(s.scope, el)
parts = append(parts, s.renderComponent(t.ComponentID, seen))
s.scope = s.scope[:len(s.scope)-1]
}
return parts
}

// lookupBinding resolves a data-binding path. Template element scopes are
// consulted innermost-first — inside a template instance, a path resolves
// against the current list element (an empty path or "/" is the element
// itself) — before falling back to the surface data model, whose entries are
// keyed by the exact path an updateDataModel carried. Outside template
// expansion the scope stack is empty and only the data model is consulted,
// preserving the pre-template lookup behavior.
func (s *Surface) lookupBinding(path string) (any, bool) {
key := strings.TrimPrefix(path, "/")
for i := len(s.scope) - 1; i >= 0; i-- {
if v, ok := resolveElementPath(s.scope[i], key); ok {
return v, true
}
}
if s.data != nil {
if v, ok := s.data[key]; ok {
return v, true
}
}
return nil, false
}

// resolveElementPath resolves a slash-separated path (leading "/" already
// trimmed) against a data-model element. An empty path is the element itself.
// Map segments index string-keyed maps; numeric segments index lists.
func resolveElementPath(el any, key string) (any, bool) {
if key == "" {
return el, true
}
cur := el
for _, seg := range strings.Split(key, "/") {
switch node := cur.(type) {
case map[string]any:
v, ok := node[seg]
if !ok {
return nil, false
}
cur = v
default:
rv := reflect.ValueOf(cur)
switch rv.Kind() {
case reflect.Map:
if rv.Type().Key().Kind() != reflect.String {
return nil, false
}
v := rv.MapIndex(reflect.ValueOf(seg))
if !v.IsValid() {
return nil, false
}
cur = v.Interface()
case reflect.Slice, reflect.Array:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= rv.Len() {
return nil, false
}
cur = rv.Index(i).Interface()
default:
return nil, false
}
}
}
return cur, true
}

// asList converts a data-model value to a []any list. JSON-decoded lists
// arrive as []any (the fast path); typed slices built in Go (e.g. []string)
// are converted via reflection. Non-list values report false.
func asList(v any) ([]any, bool) {
if l, ok := v.([]any); ok {
return l, true
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil, false
}
out := make([]any, rv.Len())
for i := range out {
out[i] = rv.Index(i).Interface()
}
return out, true
}

// childListIDs returns the component IDs a ChildList references: the explicit
// IDs of the static form, or the template component of the dynamic form. The
// template component counts as referenced so root derivation never mistakes it
// for the surface root and the focus walk reaches interactive components
// inside the template subtree.
func childListIDs(cl a2ui.ChildList) []string {
if cl.Template != nil {
return []string{cl.Template.ComponentID}
}
return cl.IDs
}
Loading
Loading