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") + } +}