Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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, ...).
Expand Down
92 changes: 92 additions & 0 deletions overlay.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
120 changes: 120 additions & 0 deletions overlay_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading