From 65b3ff167c089f9f704a8c34b99e6eb3e8846236 Mon Sep 17 00:00:00 2001 From: Joe Stump Date: Sat, 18 Jul 2026 20:28:01 +0100 Subject: [PATCH] render: expand ChildList templates from the data model A ChildList's dynamic template form now renders instead of drawing the "[a2tea: dynamic children not yet supported]" placeholder. The template's Path resolves in the surface data model to a list, and the template component renders once per element with bindings scoped to that element: lookupBinding consults the template scope stack innermost-first (an empty path or "/" is the element itself; nested segments traverse maps and numeric list indices) before falling back to the surface data model, so instances can mix per-element and shared values. updateDataModel on the bound list grows or shrinks the children on the next render. An unresolved path or empty list expands to no children; a non-list value renders a caption diagnostic. Cycle safety rides the existing seen-set guard in renderComponent, so a template component referencing its own container reports a cycle per instance instead of recursing. childIDs now counts the template component as referenced, keeping root derivation on the container and letting the focus walk reach the template subtree. Covers static, template, grow/shrink, empty/absent-list, scalar-element, nested-path, scope-fallback, non-list, root-derivation, and cycle cases in render/template_test.go. Closes #44 Co-Authored-By: Claude Opus 4.8 --- docs/wire-format.md | 8 +- render/render.go | 33 +++--- render/template.go | 135 ++++++++++++++++++++++ render/template_test.go | 245 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 16 deletions(-) create mode 100644 render/template.go create mode 100644 render/template_test.go diff --git a/docs/wire-format.md b/docs/wire-format.md index 1cfb634..bedb992 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -62,10 +62,14 @@ 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. **Not yet** (tracked as follow-ups) -- `ChildList` templates: children resolve from explicit ID lists only; the - dynamic template form is not expanded. - `createSurface` theming/catalog: the message is ignored — a surface is established by its first `updateComponents`, and theme/catalog payloads are not applied. diff --git a/render/render.go b/render/render.go index e2adf24..96f46a9 100644 --- a/render/render.go +++ b/render/render.go @@ -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. @@ -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 { @@ -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: @@ -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: diff --git a/render/template.go b/render/template.go new file mode 100644 index 0000000..5c96cda --- /dev/null +++ b/render/template.go @@ -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 +} diff --git a/render/template_test.go b/render/template_test.go new file mode 100644 index 0000000..0c15c9e --- /dev/null +++ b/render/template_test.go @@ -0,0 +1,245 @@ +package render_test + +import ( + "strings" + "testing" + + a2ui "github.com/tmc/a2ui" + + "github.com/joestump-agent/a2tea/render" +) + +// templateSurface builds a Column whose children come from a ChildList +// template: one "item" Text per element of the /items data-model list, with +// the item's text bound to the element's "name" field. +func templateSurface() *render.Surface { + return render.NewSurface("s1", []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/items"}, + }}}, + {ID: "item", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/name")}}, + }) +} + +// itemsUpdate builds an updateDataModel message setting /items to the given +// list of element names. +func itemsUpdate(names ...string) a2ui.ServerMessage { + items := make([]any, len(names)) + for i, n := range names { + items[i] = map[string]any{"name": n} + } + return a2ui.ServerMessage{UpdateDataModel: &a2ui.UpdateDataModel{ + SurfaceID: "s1", Path: "/items", Value: items, + }} +} + +// TestTemplateChildrenRender verifies that a Column with a template child +// list renders one template-component instance per data-model element, with +// bindings resolved against each element. +func TestTemplateChildrenRender(t *testing.T) { + s := templateSurface() + s.Apply([]a2ui.ServerMessage{itemsUpdate("Ada", "Grace", "Katherine")}) + + view := s.View().Content + for _, want := range []string{"Ada", "Grace", "Katherine"} { + if !strings.Contains(view, want) { + t.Errorf("missing template instance %q; view = %q", want, view) + } + } + if strings.Contains(view, "{binding}") { + t.Errorf("unresolved binding placeholder in template instance; view = %q", view) + } + // Instances stack vertically in the Column: Ada above Grace. + if strings.Index(view, "Ada") > strings.Index(view, "Grace") { + t.Errorf("template instances out of list order; view = %q", view) + } +} + +// TestTemplateChildrenGrowShrink verifies that updateDataModel on the bound +// list re-renders with added and removed children. +func TestTemplateChildrenGrowShrink(t *testing.T) { + s := templateSurface() + s.Apply([]a2ui.ServerMessage{itemsUpdate("Ada", "Grace")}) + view := s.View().Content + if !strings.Contains(view, "Ada") || !strings.Contains(view, "Grace") { + t.Fatalf("initial instances missing; view = %q", view) + } + + // Grow: a third element appears. + s.Apply([]a2ui.ServerMessage{itemsUpdate("Ada", "Grace", "Katherine")}) + view = s.View().Content + if !strings.Contains(view, "Katherine") { + t.Errorf("added element did not render; view = %q", view) + } + + // Shrink: only one element remains. + s.Apply([]a2ui.ServerMessage{itemsUpdate("Grace")}) + view = s.View().Content + if strings.Contains(view, "Ada") || strings.Contains(view, "Katherine") { + t.Errorf("removed elements still render; view = %q", view) + } + if !strings.Contains(view, "Grace") { + t.Errorf("surviving element missing; view = %q", view) + } +} + +// TestTemplateChildrenEmptyList verifies that an empty bound list — and a +// list whose data hasn't arrived at all — renders no children and no +// placeholder chrome. +func TestTemplateChildrenEmptyList(t *testing.T) { + s := templateSurface() + + // No data model yet: the template expands to nothing. + if view := s.View().Content; strings.Contains(view, "a2tea:") || strings.Contains(view, "{binding}") { + t.Errorf("template with absent data should render nothing; view = %q", view) + } + + // Explicitly empty list: still nothing. + s.Apply([]a2ui.ServerMessage{itemsUpdate()}) + if view := s.View().Content; strings.Contains(view, "a2tea:") || strings.Contains(view, "{binding}") { + t.Errorf("template with empty list should render nothing; view = %q", view) + } +} + +// TestTemplateChildrenCycle verifies that a template component referencing +// its own container trips the cycle guard instead of recursing forever. +func TestTemplateChildrenCycle(t *testing.T) { + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/items"}, + }}}, + // The template component wraps its own container: root -> item -> root. + {ID: "item", Card: &a2ui.CardComponent{Child: "root"}}, + }) + s.Apply([]a2ui.ServerMessage{{UpdateDataModel: &a2ui.UpdateDataModel{ + SurfaceID: "s1", Path: "/items", Value: []any{"a", "b"}, + }}}) + + view := s.View().Content + if !strings.Contains(view, "cycle") { + t.Fatalf("template cycle through the container not caught; view = %q", view) + } +} + +// TestTemplateScalarElements verifies that a list of plain strings works: a +// binding path of "/" inside the template resolves to the element itself. +func TestTemplateScalarElements(t *testing.T) { + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "root", List: &a2ui.ListComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/tags"}, + }}}, + {ID: "item", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/")}}, + }) + // []string exercises the reflection path in asList — a Go host can feed + // typed slices, not just JSON-decoded []any. + s.Apply([]a2ui.ServerMessage{{UpdateDataModel: &a2ui.UpdateDataModel{ + SurfaceID: "s1", Path: "/tags", Value: []string{"alpha", "beta"}, + }}}) + + view := s.View().Content + if !strings.Contains(view, "alpha") || !strings.Contains(view, "beta") { + t.Errorf("scalar elements did not render; view = %q", view) + } + // Vertical List chrome still applies to template instances. + if !strings.Contains(view, "• alpha") { + t.Errorf("template instance missing list bullet; view = %q", view) + } +} + +// TestTemplateScopeFallsBackToDataModel verifies that a binding that does not +// resolve against the current element falls back to the surface data model, +// so instances can mix per-element and shared values. +func TestTemplateScopeFallsBackToDataModel(t *testing.T) { + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/items"}, + }}}, + {ID: "item", Row: &a2ui.RowComponent{Children: a2ui.ChildList{IDs: []string{"name", "shared"}}}}, + {ID: "name", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/name")}}, + {ID: "shared", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/unit")}}, + }) + s.Apply([]a2ui.ServerMessage{ + {UpdateDataModel: &a2ui.UpdateDataModel{SurfaceID: "s1", Path: "/unit", Value: "kg"}}, + itemsUpdate("Ada"), + }) + + view := s.View().Content + if !strings.Contains(view, "Ada") { + t.Errorf("element-scoped binding missing; view = %q", view) + } + if !strings.Contains(view, "kg") { + t.Errorf("surface data model fallback missing; view = %q", view) + } +} + +// TestTemplateNestedPath verifies that a binding path with multiple segments +// traverses nested maps inside the element. +func TestTemplateNestedPath(t *testing.T) { + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/items"}, + }}}, + {ID: "item", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/meta/label")}}, + }) + s.Apply([]a2ui.ServerMessage{{UpdateDataModel: &a2ui.UpdateDataModel{ + SurfaceID: "s1", Path: "/items", Value: []any{ + map[string]any{"meta": map[string]any{"label": "deep"}}, + }, + }}}) + + if view := s.View().Content; !strings.Contains(view, "deep") { + t.Errorf("nested element path did not resolve; view = %q", view) + } +} + +// TestTemplateNonListValue verifies that a template path resolving to a +// non-list value renders the diagnostic placeholder instead of children. +func TestTemplateNonListValue(t *testing.T) { + s := templateSurface() + s.Apply([]a2ui.ServerMessage{{UpdateDataModel: &a2ui.UpdateDataModel{ + SurfaceID: "s1", Path: "/items", Value: "not-a-list", + }}}) + + if view := s.View().Content; !strings.Contains(view, "is not a list") { + t.Errorf("non-list template value should render a diagnostic; view = %q", view) + } +} + +// TestTemplateRootDerivation verifies that the template component counts as +// referenced when deriving the surface root, so the container — not the +// template component — is the root even though no explicit ID references the +// template. +func TestTemplateRootDerivation(t *testing.T) { + // Declare the template component first so a naive "first component" root + // fallback would pick it over the container. + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "item", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/name")}}, + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{ + Template: &a2ui.ChildTemplate{ComponentID: "item", Path: "/items"}, + }}}, + }) + s.Apply([]a2ui.ServerMessage{itemsUpdate("Ada", "Grace")}) + + view := s.View().Content + // If "item" were the root, the view would be a single unscoped "{binding}". + if !strings.Contains(view, "Ada") || !strings.Contains(view, "Grace") { + t.Errorf("column with template children should be the root; view = %q", view) + } +} + +// TestStaticChildrenUnaffected verifies the static ChildList form still +// renders explicit IDs in order with no data model present. +func TestStaticChildrenUnaffected(t *testing.T) { + s := render.NewSurface("s1", []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{IDs: []string{"a", "b"}}}}, + {ID: "a", Text: &a2ui.TextComponent{Text: a2ui.StringLiteral("Alpha")}}, + {ID: "b", Text: &a2ui.TextComponent{Text: a2ui.StringLiteral("Beta")}}, + }) + view := s.View().Content + if !strings.Contains(view, "Alpha") || !strings.Contains(view, "Beta") { + t.Errorf("static children missing; view = %q", view) + } + if strings.Index(view, "Alpha") > strings.Index(view, "Beta") { + t.Errorf("static children out of order; view = %q", view) + } +}