diff --git a/README.md b/README.md index e634750..783608e 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ every native target Go ships. | Action | `Button`, `ToggleButton`, `CheckButton`, `RadioButton` + `Group` | | Input | `Entry`, `TextView` + `Selection` + IME preview, `SpinButton`, `Scale`, `RangeSlider` | | Selection | `ListBox`, `TreeView`, `DropDown` | -| Layout | `HBox`, `VBox`, `Grid`, `Frame`, `Stack`, `Paned`, `Expander` | +| Layout | `HBox`, `VBox`, `Grid`, `Frame`, `Stack`, `Overlay`, `Paned`, `Expander` | | Tabs | `Notebook` | | Scroll | `ScrollView` | | Feedback | `ProgressBar`, `LevelBar`, `Spinner`, `Image`, `Tooltip`, `Notification` | @@ -173,9 +173,11 @@ model, not the widget catalogue: wraps a `Menu` as a right-click popup — `Popup(x, y)` shows it at the cursor, it auto-sizes to its items, clamps itself inside the surface, and dismisses on outside-click. -- **Overlay layout container** — z-ordered stacking above a - primary child, so Popover (v0.8) / Toast (v0.8) / Notification / - Tooltip stack correctly without hosts arranging screen positions. +- ~~**Overlay layout container**~~ — **done (v0.18)**: `Overlay` + stacks z-ordered `Layers` above a primary `Content` child, so + Popover / Toast / Notification / Tooltip / ContextMenu float + without hosts arranging z-order. Events route top-down; `Modal` + makes a miss swallow (backdrop) instead of falling through. - **A11y bridge** — `Role` + `Label` fields on widgets already; wire an `A11yPublisher` interface hosts can plug (WAI-ARIA wrapping on wasm, TTY-cell metadata on tui, ...). diff --git a/overlay.go b/overlay.go new file mode 100644 index 0000000..2e9ac72 --- /dev/null +++ b/overlay.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/painter" + +// Overlay is a z-ordered stacking container: a primary Content child that fills +// the bounds, plus a stack of Layers painted on top of it in order (the last +// Layer is topmost). It is the piece the widget model was missing so transient +// widgets -- Popover, Toast, Notification, Tooltip, ContextMenu -- can float +// above the main UI without the host arranging screen positions or z-order. +// +// Unlike Stack (which shows exactly one page at a time), an Overlay draws every +// layer, and events route top-down: the topmost Layer whose HitTest covers the +// point handles the event; if none do, the event falls through to Content -- +// unless Modal is set, in which case a miss while any Layer is up is swallowed +// (a modal backdrop). Layers self-position via their own Bounds; only Content +// is resized to fill the Overlay. +type Overlay struct { + Base + Content Widget + Layers []Widget + Modal bool +} + +// NewOverlay builds an Overlay around the given primary child (which may be nil +// and set later). +func NewOverlay(content Widget) *Overlay { return &Overlay{Content: content} } + +// Push adds w as the new topmost layer. +func (o *Overlay) Push(w Widget) { o.Layers = append(o.Layers, w) } + +// Pop removes and returns the topmost layer, or nil when there are none. +func (o *Overlay) Pop() Widget { + if len(o.Layers) == 0 { + return nil + } + top := o.Layers[len(o.Layers)-1] + o.Layers = o.Layers[:len(o.Layers)-1] + return top +} + +// Top returns the topmost layer without removing it, or nil when there are none. +func (o *Overlay) Top() Widget { + if len(o.Layers) == 0 { + return nil + } + return o.Layers[len(o.Layers)-1] +} + +// Clear removes every layer, leaving just the Content. +func (o *Overlay) Clear() { o.Layers = nil } + +// SetBounds resizes Content to fill the Overlay; layers keep their own bounds +// (they self-position at a point). +func (o *Overlay) SetBounds(r Rect) { + o.Base.SetBounds(r) + if o.Content != nil { + o.Content.SetBounds(r) + } +} + +// Draw paints Content first, then each layer bottom-to-top. +func (o *Overlay) Draw(p painter.Painter, theme *Theme) { + if o.Content != nil { + o.Content.Draw(p, theme) + } + for _, l := range o.Layers { + l.Draw(p, theme) + } +} + +// OnEvent routes to the topmost layer whose HitTest covers the point; failing +// that, to Content -- or, when Modal and a layer is present, nowhere (the +// backdrop swallows the click). Coordinates are passed through unchanged (an +// Overlay is a surface-frame container, like Paned). +func (o *Overlay) OnEvent(ev Event) { + for i := len(o.Layers) - 1; i >= 0; i-- { + if o.Layers[i].HitTest(ev.X, ev.Y) { + o.Layers[i].OnEvent(ev) + return + } + } + if o.Modal && len(o.Layers) > 0 { + return + } + if o.Content != nil { + o.Content.OnEvent(ev) + } +} diff --git a/overlay_test.go b/overlay_test.go new file mode 100644 index 0000000..23c5a38 --- /dev/null +++ b/overlay_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "testing" + +func TestOverlayPushPopTop(t *testing.T) { + o := NewOverlay(&recordingWidget{}) + if o.Top() != nil || o.Pop() != nil { + t.Fatal("empty overlay Top/Pop should be nil") + } + a, b := &recordingWidget{}, &recordingWidget{} + o.Push(a) + o.Push(b) + if o.Top() != b { + t.Fatal("Top should be the last pushed layer") + } + if o.Pop() != b || o.Top() != a { + t.Fatal("Pop should remove+return the top, exposing the one below") + } + o.Clear() + if len(o.Layers) != 0 || o.Top() != nil { + t.Fatal("Clear should remove all layers") + } +} + +func TestOverlaySetBoundsFillsContentOnly(t *testing.T) { + content := &recordingWidget{} + layer := &recordingWidget{} + layer.SetBounds(Rect{X: 5, Y: 5, W: 10, H: 10}) // self-positioned + o := NewOverlay(content) + o.Push(layer) + o.SetBounds(Rect{X: 0, Y: 0, W: 100, H: 80}) + if content.Bounds() != (Rect{X: 0, Y: 0, W: 100, H: 80}) { + t.Errorf("Content not filled: %+v", content.Bounds()) + } + if layer.Bounds() != (Rect{X: 5, Y: 5, W: 10, H: 10}) { + t.Errorf("layer bounds should be untouched: %+v", layer.Bounds()) + } +} + +func TestOverlayDrawsContentThenLayers(t *testing.T) { + content := &recordingWidget{} + l1, l2 := &recordingWidget{}, &recordingWidget{} + o := NewOverlay(content) + o.Push(l1) + o.Push(l2) + o.Draw(newP(makeSurface(10, 10), 10), DefaultLight()) + if content.draws != 1 || l1.draws != 1 || l2.draws != 1 { + t.Errorf("draw counts: content=%d l1=%d l2=%d, want 1 each", + content.draws, l1.draws, l2.draws) + } +} + +func TestOverlayNilContentDrawNoPanic(t *testing.T) { + o := NewOverlay(nil) + o.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 10}) // Content nil branch + o.Draw(newP(makeSurface(10, 10), 10), DefaultLight()) + o.OnEvent(Event{Kind: EventClick, X: 1, Y: 1}) // Content nil, no layers +} + +func TestOverlayEventTopLayerCaptures(t *testing.T) { + content := &recordingWidget{} + bottom := &recordingWidget{} + top := &recordingWidget{} + // Both layers cover (10,10); top is pushed last so it wins there. + bottom.SetBounds(Rect{X: 0, Y: 0, W: 40, H: 40}) + top.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 20}) + o := NewOverlay(content) + o.Push(bottom) + o.Push(top) + + // Point inside both → topmost captures. + o.OnEvent(Event{Kind: EventClick, X: 10, Y: 10}) + if len(top.events) != 1 || len(bottom.events) != 0 || len(content.events) != 0 { + t.Fatalf("top should capture: top=%d bottom=%d content=%d", + len(top.events), len(bottom.events), len(content.events)) + } + // Point inside bottom only (30,30 is outside top's 20x20) → bottom captures. + o.OnEvent(Event{Kind: EventClick, X: 30, Y: 30}) + if len(bottom.events) != 1 { + t.Fatalf("bottom should capture the miss-of-top: %d", len(bottom.events)) + } +} + +func TestOverlayEventFallsThroughToContent(t *testing.T) { + content := &recordingWidget{} + layer := &recordingWidget{} + layer.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 10}) + o := NewOverlay(content) + o.Push(layer) + // Point outside every layer → non-modal overlay routes to Content. + o.OnEvent(Event{Kind: EventClick, X: 50, Y: 50}) + if len(content.events) != 1 || len(layer.events) != 0 { + t.Fatalf("miss should reach content: content=%d layer=%d", + len(content.events), len(layer.events)) + } +} + +func TestOverlayModalSwallowsMiss(t *testing.T) { + content := &recordingWidget{} + layer := &recordingWidget{} + layer.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 10}) + o := NewOverlay(content) + o.Modal = true + o.Push(layer) + // Miss while a layer is up → swallowed, Content untouched. + o.OnEvent(Event{Kind: EventClick, X: 50, Y: 50}) + if len(content.events) != 0 { + t.Fatalf("modal backdrop should swallow the miss, content got %d", len(content.events)) + } + // With no layers, even Modal falls through to Content. + o.Clear() + o.OnEvent(Event{Kind: EventClick, X: 50, Y: 50}) + if len(content.events) != 1 { + t.Fatalf("modal w/o layers should reach content: %d", len(content.events)) + } +}