From 6e828c6acf129a1f84133003b1b20a166b1168da Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 11:45:34 +0200 Subject: [PATCH] =?UTF-8?q?toolkit:=20Native=20=E2=80=94=20a=20region=20th?= =?UTF-8?q?e=20toolkit=20lays=20out=20and=20a=20host=20fills=20with=20a=20?= =?UTF-8?q?real=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Native is a rectangle the toolkit lays out but does not paint: a host that can embed platform controls places one of its own where a Native sits, so a password box is the operating system's secure field, a slider is the system slider — the behaviour a drawn imitation cannot carry (secure text entry, the exact focus ring, native accessibility). This is deliberately NOT the old Foreign, retired in #428 for want of a consumer a bare rectangle could serve. A real control is not a picture: the person edits it, so its value must flow BACK to the model, and it survives across frames, so the host must find the same control again rather than rebuild it and lose focus. Native answers both — a mvvm.Observable per value for two-way binding, and a stable Key the host diffs on — which is what earns it the place Foreign could not hold. Its consumer is the go-widgets/window cocoa backend, landing alongside; it is not merged before that caller exists. WalkNative reuses Foreign's sound clip-aware descent (childOffsetter / childContainer / childClipper), returning each placement with the clip an enclosing viewport imposes. Draw/OnEvent stand down once a host claims the region; Children exposes the fallback (for a11y and layout) only while unclaimed, so the native control's own accessibility is never doubled. native.go is 100% covered by native_test.go through fake walk-interface implementations that pin the clip math without a ScrollView's gutter arithmetic. --- native.go | 284 +++++++++++++++++++++++++++++++++++++++++++++++++ native_test.go | 255 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 native.go create mode 100644 native_test.go diff --git a/native.go b/native.go new file mode 100644 index 0000000..76ec08e --- /dev/null +++ b/native.go @@ -0,0 +1,284 @@ +package toolkit + +import ( + "github.com/go-widgets/mvvm" + "github.com/go-widgets/painter" +) + +// Native is a rectangle the toolkit LAYS OUT but does not paint: a host that can +// embed real platform controls places one of its own where a Native sits, so a +// password box is the operating system's secure field and a slider is the +// system slider — behaviour a drawn imitation cannot carry (secure text entry, +// the exact focus ring, native accessibility). The toolkit still does the +// layout; the host does the control. +// +// # Why this is not the old Foreign +// +// An earlier Foreign region carried only a rect, a key and an opaque payload, +// and it was retired for want of a consumer that a rect could serve. A real +// control is not a picture: the person edits it, so its value must flow BACK to +// the model, and it survives across frames, so the host must find the same +// control again rather than rebuild it. Native answers both — a [mvvm.Observable] +// per value for two-way binding, and a stable [Native.Key] the host diffs on — +// which is what makes it worth its place where Foreign was not. +// +// # It degrades +// +// Where no host claims it — Linux and the browser today, or before the first +// frame — a Native paints its [Native.Fallback] (a portable drawn widget) so the +// tree stays usable everywhere. Give the fallback the SAME observables and the +// two renderings stay in step. With no fallback it paints nothing, which is the +// right answer for a region something else is about to fill. +type Native struct { + Base + + // Kind is which control this is. It is fixed at construction. + Kind NativeKind + + // Key is the caller's stable identity for this control. A host keys its + // live native object on it, so the control is found again — and its focus + // and selection kept — across the frames it is laid out in. Two Natives that + // are "the same control" over time must share a Key; two different controls + // must not. + Key string + + // Items are the entries of a [NativePopUp]. Ignored for other kinds. + Items []string + + // Min and Max bound a [NativeSlider]. Ignored for other kinds. + Min, Max float64 + + text *mvvm.Observable[string] + on *mvvm.Observable[bool] + number *mvvm.Observable[float64] + + onActivate func() + claimed *mvvm.Observable[bool] + + // Fallback renders in place while no host has claimed this region. + Fallback Widget +} + +// NativeKind is which platform control a [Native] stands for. +type NativeKind int + +const ( + // NativeButton is a momentary push button; its title is [Native.Text] and + // its click is the activation handler. + NativeButton NativeKind = iota + // NativeLabel is static, non-editable text ([Native.Text]). + NativeLabel + // NativeEntry is an editable single-line text field: [Native.Text] two-way, + // activation on commit (Return). + NativeEntry + // NativeSecureEntry is an editable field whose glyphs are bullets and whose + // contents the platform fills without the process seeing the keystrokes — + // the control a drawn toolkit must not imitate for a password. + NativeSecureEntry + // NativeCheckbox is a labelled on/off control ([Native.On] two-way, + // [Native.Text] label). + NativeCheckbox + // NativeRadio is one of a group of mutually exclusive controls; a host + // groups the Natives that share a container. + NativeRadio + // NativeSwitch is a sliding on/off control ([Native.On] two-way). + NativeSwitch + // NativeSlider is a continuous control over [Native.Min],[Native.Max] + // ([Native.Number] two-way). + NativeSlider + // NativePopUp is a drop-down of [Native.Items]; the selected title is + // [Native.Text]. + NativePopUp +) + +// NewNativeButton makes a push button. onClick runs when it is activated. +func NewNativeButton(title string, onClick func()) *Native { + return &Native{Kind: NativeButton, text: mvvm.NewObservable(title), onActivate: onClick} +} + +// NewNativeLabel makes a static text label. +func NewNativeLabel(text string) *Native { + return &Native{Kind: NativeLabel, text: mvvm.NewObservable(text)} +} + +// NewNativeEntry makes an editable text field. +func NewNativeEntry(text string) *Native { + return &Native{Kind: NativeEntry, text: mvvm.NewObservable(text)} +} + +// NewNativeSecureEntry makes a secure (bulleted) text field. +func NewNativeSecureEntry(text string) *Native { + return &Native{Kind: NativeSecureEntry, text: mvvm.NewObservable(text)} +} + +// NewNativeCheckbox makes a labelled checkbox. +func NewNativeCheckbox(title string, on bool) *Native { + return &Native{Kind: NativeCheckbox, text: mvvm.NewObservable(title), on: mvvm.NewObservable(on)} +} + +// NewNativeRadio makes a radio button. Natives that share a container form a +// group. +func NewNativeRadio(title string, on bool) *Native { + return &Native{Kind: NativeRadio, text: mvvm.NewObservable(title), on: mvvm.NewObservable(on)} +} + +// NewNativeSwitch makes an on/off switch. +func NewNativeSwitch(on bool) *Native { + return &Native{Kind: NativeSwitch, on: mvvm.NewObservable(on)} +} + +// NewNativeSlider makes a slider over [min,max] positioned at value. +func NewNativeSlider(min, max, value float64) *Native { + return &Native{Kind: NativeSlider, Min: min, Max: max, number: mvvm.NewObservable(value)} +} + +// NewNativePopUp makes a drop-down of items with the given selection. +func NewNativePopUp(items []string, selected string) *Native { + return &Native{Kind: NativePopUp, Items: items, text: mvvm.NewObservable(selected)} +} + +// Text is the two-way string value of a text control, and the title of a +// button, label, checkbox or radio. It is created on first use. +func (n *Native) Text() *mvvm.Observable[string] { + if n.text == nil { + n.text = mvvm.NewObservable("") + } + return n.text +} + +// On is the two-way on/off state of a checkbox, radio or switch. +func (n *Native) On() *mvvm.Observable[bool] { + if n.on == nil { + n.on = mvvm.NewObservable(false) + } + return n.on +} + +// Number is the two-way value of a slider. +func (n *Native) Number() *mvvm.Observable[float64] { + if n.number == nil { + n.number = mvvm.NewObservable[float64](0) + } + return n.number +} + +// SetOnActivate sets the handler run when the control is activated (a button +// clicked, a text field committed with Return, a selection made). A host calls +// [Native.Activate] to fire it. +func (n *Native) SetOnActivate(fn func()) { n.onActivate = fn } + +// Activate runs the activation handler if one is set. A host calls it when its +// live control fires its primary action. +func (n *Native) Activate() { + if n.onActivate != nil { + n.onActivate() + } +} + +// Claimed reports whether a host has taken over rendering this region. It is a +// cross-boundary observable: the host sets it true when it places its control +// and false when it takes it away, and the toolkit reads it to know whether to +// paint the fallback. Created on first use. +func (n *Native) Claimed() *mvvm.Observable[bool] { + if n.claimed == nil { + n.claimed = mvvm.NewObservable(false) + } + return n.claimed +} + +// Draw paints the fallback while unclaimed; once a host has claimed the region, +// its own control is above the canvas and the toolkit paints nothing. +func (n *Native) Draw(p painter.Painter, theme *Theme) { + if n.Claimed().Get() || n.Fallback == nil { + return + } + n.Fallback.SetBounds(n.Bounds()) + n.Fallback.Draw(p, theme) +} + +// OnEvent forwards to the fallback while unclaimed; a claimed region's events +// belong to the host's control. +func (n *Native) OnEvent(ev Event) { + if n.Claimed().Get() || n.Fallback == nil { + return + } + n.Fallback.OnEvent(ev) +} + +// Children exposes the fallback while unclaimed, so a11y and layout descend into +// it. Once claimed, there are none: the host's control carries its own +// accessibility, and exposing the fallback too would double it. +func (n *Native) Children() []Widget { + if n.Claimed().Get() { + return nil + } + return nonNil(n.Fallback) +} + +// A11y reports [RolePresentation]: this region's accessibility is the fallback's +// (walked as a child) while unclaimed, and the host control's own once claimed — +// never the Native's. +func (n *Native) A11y() A11yInfo { return A11yInfo{Role: RolePresentation} } + +// NativePlacement is one Native and where it ended up, in surface coordinates. +// The host reads Control for the kind, configuration and value observables it +// binds to, and Rect/Clip/Visible for where to put the live control. +type NativePlacement struct { + Control *Native + Rect Rect // where the control wants to be + Clip Rect // the part of Rect an enclosing viewport still shows + Visible bool // false when Clip is empty +} + +// WalkNative returns every [Native] in the tree rooted at w, each with its +// placement and the clip an enclosing viewport imposes, in visual order. It is +// the walk a host runs each frame to reconcile its live controls with the +// layout. +func WalkNative(w Widget) []NativePlacement { + var out []NativePlacement + var walk func(x Widget, dx, dy int, clip Rect, clipped bool) + walk = func(x Widget, dx, dy int, clip Rect, clipped bool) { + if x == nil { + return + } + if nv, ok := x.(*Native); ok { + r := nv.Bounds() + r.X, r.Y = r.X+dx, r.Y+dy + c := r + if clipped { + c = intersectRect(r, clip) + } + out = append(out, NativePlacement{ + Control: nv, + Rect: r, + Clip: c, + Visible: c.W > 0 && c.H > 0, + }) + } + if o, ok := x.(childOffsetter); ok { + vp := x.Bounds() + if cc, ok := x.(childClipper); ok { + vp = cc.ChildClip() + } + vp.X, vp.Y = vp.X+dx, vp.Y+dy + if clipped { + clip = intersectRect(clip, vp) + } else { + clip, clipped = vp, true + } + ox, oy := o.ChildOffset() + dx, dy = dx+ox, dy+oy + } + if c, ok := x.(childContainer); ok { + for _, child := range c.Children() { + walk(child, dx, dy, clip, clipped) + } + } + } + walk(w, 0, 0, Rect{}, false) + return out +} + +// childClipper is a widget that shows its children in a rectangle smaller than +// its own bounds — a viewport that reserves room for scrollbars. +type childClipper interface{ ChildClip() Rect } diff --git a/native_test.go b/native_test.go new file mode 100644 index 0000000..d3b97ae --- /dev/null +++ b/native_test.go @@ -0,0 +1,255 @@ +package toolkit + +import ( + "testing" + + "github.com/go-widgets/painter" +) + +// recordWidget is a fallback that records what the Native asked of it, so a test +// can tell whether Draw/OnEvent reached it. +type recordWidget struct { + Base + drawn int + events []Event +} + +func (r *recordWidget) Draw(p painter.Painter, theme *Theme) { r.drawn++ } +func (r *recordWidget) OnEvent(ev Event) { r.events = append(r.events, ev) } + +// fakeContainer is a childContainer only (no offset, no clip): the unclipped +// walk path. +type fakeContainer struct { + Base + kids []Widget +} + +func (f *fakeContainer) Children() []Widget { return f.kids } + +// fakeViewport implements all three walk interfaces, with clip and offset a test +// sets exactly — so WalkNative's clip math is asserted without a ScrollView's +// scrollbar-gutter arithmetic. +type fakeViewport struct { + Base + kids []Widget + clip Rect + offX, offY int +} + +func (f *fakeViewport) Children() []Widget { return f.kids } +func (f *fakeViewport) ChildOffset() (int, int) { return f.offX, f.offY } +func (f *fakeViewport) ChildClip() Rect { return f.clip } + +func TestNativeConstructors(t *testing.T) { + if b := NewNativeButton("Go", nil); b.Kind != NativeButton || b.Text().Get() != "Go" { + t.Errorf("button: kind=%v text=%q", b.Kind, b.Text().Get()) + } + if l := NewNativeLabel("L"); l.Kind != NativeLabel || l.Text().Get() != "L" { + t.Errorf("label wrong") + } + if e := NewNativeEntry("e"); e.Kind != NativeEntry || e.Text().Get() != "e" { + t.Errorf("entry wrong") + } + if s := NewNativeSecureEntry("s"); s.Kind != NativeSecureEntry || s.Text().Get() != "s" { + t.Errorf("secure wrong") + } + if c := NewNativeCheckbox("C", true); c.Kind != NativeCheckbox || c.Text().Get() != "C" || !c.On().Get() { + t.Errorf("checkbox wrong") + } + if r := NewNativeRadio("R", false); r.Kind != NativeRadio || r.Text().Get() != "R" || r.On().Get() { + t.Errorf("radio wrong") + } + if s := NewNativeSwitch(true); s.Kind != NativeSwitch || !s.On().Get() { + t.Errorf("switch wrong") + } + sl := NewNativeSlider(0, 10, 3) + if sl.Kind != NativeSlider || sl.Number().Get() != 3 || sl.Min != 0 || sl.Max != 10 { + t.Errorf("slider wrong") + } + pu := NewNativePopUp([]string{"a", "b"}, "b") + if pu.Kind != NativePopUp || pu.Text().Get() != "b" || len(pu.Items) != 2 { + t.Errorf("popup wrong") + } +} + +func TestNativeLazyAccessors(t *testing.T) { + // A button has no On/Number observable until asked; the accessors create them. + b := NewNativeButton("x", nil) + if b.On().Get() != false { + t.Errorf("lazy On default = true, want false") + } + if b.Number().Get() != 0 { + t.Errorf("lazy Number default = %v, want 0", b.Number().Get()) + } + if b.Claimed().Get() != false { + t.Errorf("lazy Claimed default = true, want false") + } + // A switch has no text until asked. + if s := NewNativeSwitch(false); s.Text().Get() != "" { + t.Errorf("lazy Text default = %q, want empty", s.Text().Get()) + } +} + +func TestNativeActivate(t *testing.T) { + fired := 0 + n := NewNativeButton("go", func() { fired++ }) + n.Activate() + if fired != 1 { + t.Fatalf("Activate ran handler %d times, want 1", fired) + } + n.SetOnActivate(func() { fired += 10 }) + n.Activate() + if fired != 11 { + t.Fatalf("after SetOnActivate, fired = %d, want 11", fired) + } + // A Native with no handler must not panic. + NewNativeLabel("l").Activate() +} + +func TestNativeDraw(t *testing.T) { + theme := DefaultLight() + p := newP(makeSurface(30, 30), 30) + + n := NewNativeEntry("x") + fb := &recordWidget{} + n.Fallback = fb + n.SetBounds(Rect{X: 1, Y: 2, W: 10, H: 8}) + + // Unclaimed: the fallback is drawn, at the Native's bounds. + n.Draw(p, theme) + if fb.drawn != 1 { + t.Fatalf("fallback drawn %d times, want 1", fb.drawn) + } + if fb.Bounds() != n.Bounds() { + t.Errorf("fallback bounds = %+v, want %+v", fb.Bounds(), n.Bounds()) + } + + // Claimed: the host's control is above the canvas; the toolkit paints nothing. + n.Claimed().Set(true) + n.Draw(p, theme) + if fb.drawn != 1 { + t.Errorf("fallback drawn while claimed (count %d)", fb.drawn) + } + + // No fallback: nothing to draw, no panic. + NewNativeButton("b", nil).Draw(p, theme) +} + +func TestNativeOnEvent(t *testing.T) { + n := NewNativeEntry("x") + fb := &recordWidget{} + n.Fallback = fb + + ev := Event{Kind: EventClick} + n.OnEvent(ev) + if len(fb.events) != 1 { + t.Fatalf("fallback got %d events, want 1", len(fb.events)) + } + + n.Claimed().Set(true) + n.OnEvent(ev) + if len(fb.events) != 1 { + t.Errorf("fallback got an event while claimed (count %d)", len(fb.events)) + } + + // No fallback: nothing happens, no panic. + NewNativeButton("b", nil).OnEvent(ev) +} + +func TestNativeChildren(t *testing.T) { + n := NewNativeEntry("x") + fb := &recordWidget{} + n.Fallback = fb + if kids := n.Children(); len(kids) != 1 || kids[0] != fb { + t.Errorf("unclaimed Children = %v, want [fallback]", kids) + } + n.Claimed().Set(true) + if kids := n.Children(); kids != nil { + t.Errorf("claimed Children = %v, want nil", kids) + } + // No fallback: empty, not a one-element slice of nil. + if kids := NewNativeButton("b", nil).Children(); len(kids) != 0 { + t.Errorf("no-fallback Children = %v, want empty", kids) + } +} + +func TestNativeA11y(t *testing.T) { + if got := NewNativeButton("b", nil).A11y().Role; got != RolePresentation { + t.Errorf("A11y role = %v, want RolePresentation", got) + } +} + +func TestWalkNativeNil(t *testing.T) { + if got := WalkNative(nil); len(got) != 0 { + t.Errorf("WalkNative(nil) = %v, want empty", got) + } +} + +func TestWalkNativeUnclipped(t *testing.T) { + n := NewNativeButton("b", nil) + n.SetBounds(Rect{X: 5, Y: 6, W: 20, H: 10}) + root := &fakeContainer{kids: []Widget{nil, n}} // nil child exercises the nil guard + got := WalkNative(root) + if len(got) != 1 { + t.Fatalf("got %d placements, want 1", len(got)) + } + pl := got[0] + if pl.Control != n { + t.Errorf("placement control mismatch") + } + if pl.Rect != (Rect{X: 5, Y: 6, W: 20, H: 10}) { + t.Errorf("unclipped Rect = %+v, want the bounds", pl.Rect) + } + if pl.Clip != pl.Rect || !pl.Visible { + t.Errorf("unclipped placement should be fully visible: clip=%+v visible=%v", pl.Clip, pl.Visible) + } +} + +func TestWalkNativeClipped(t *testing.T) { + inside := NewNativeEntry("in") + inside.SetBounds(Rect{X: 20, Y: 20, W: 30, H: 15}) + + outside := NewNativeEntry("out") + outside.SetBounds(Rect{X: 90, Y: 20, W: 30, H: 15}) // past the clip's right edge + + nested := NewNativeEntry("nested") + nested.SetBounds(Rect{X: 5, Y: 5, W: 10, H: 10}) + inner := &fakeViewport{clip: Rect{X: 0, Y: 0, W: 40, H: 50}, kids: []Widget{nested}} + inner.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 50}) + + outer := &fakeViewport{ + clip: Rect{X: 0, Y: 0, W: 80, H: 100}, + offX: -10, offY: -5, + kids: []Widget{inside, outside, inner}, + } + outer.SetBounds(Rect{X: 0, Y: 0, W: 100, H: 100}) + + got := WalkNative(outer) + if len(got) != 3 { + t.Fatalf("got %d placements, want 3", len(got)) + } + byControl := map[*Native]NativePlacement{} + for _, pl := range got { + byControl[pl.Control] = pl + } + + // inside: offset by (-10,-5), fully within the clip → visible. + in := byControl[inside] + if in.Rect != (Rect{X: 10, Y: 15, W: 30, H: 15}) { + t.Errorf("inside Rect = %+v, want {10,15,30,15}", in.Rect) + } + if !in.Visible || in.Clip != in.Rect { + t.Errorf("inside should be fully visible: clip=%+v visible=%v", in.Clip, in.Visible) + } + + // outside: begins at the clip's right edge → clipped to nothing. + out := byControl[outside] + if out.Visible || out.Clip.W != 0 { + t.Errorf("outside should be clipped away: clip=%+v visible=%v", out.Clip, out.Visible) + } + + // nested: reached through two viewports (the inner intersects the outer clip). + if _, ok := byControl[nested]; !ok { + t.Errorf("nested control was not walked through the inner viewport") + } +}