diff --git a/docs/wire-format.md b/docs/wire-format.md index 2300a45..604c076 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -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`, @@ -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 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) + } +}