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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ every native target Go ships.
| Tabs | `Notebook` |
| Scroll | `ScrollView` |
| Feedback | `ProgressBar`, `LevelBar`, `Spinner`, `Image`, `Tooltip`, `Notification` |
| Navigation | `Menu` + `MenuItem.Shortcut`, `MenuBar` + `Alt+letter`, `Dialog`, `MessageDialog` |
| Navigation | `Menu` + `MenuItem.Shortcut`, `MenuBar` + `Alt+letter`, `ContextMenu`, `Dialog`, `MessageDialog` |
| Bars | `Toolbar`, `Statusbar`, 10 stock `DrawIcon*` helpers |
| Composite | `FileChooser`, `ColorChooser`, `Calendar`, `DatePicker`, `MarkdownView` |
| Charts | `LineChart`, `BarChart`, `PieChart` |
Expand Down Expand Up @@ -169,9 +169,10 @@ model, not the widget catalogue:
`SplitDropPayload` / `JoinDropPayload` for multi-item payloads.
DropZone dropped its synthetic-`EventChar` seam and now drives its
hover cue + `OnDrop` from the formal lifecycle as a `DropTarget`.
- **Context menu helper** — one-line `ShowContextMenu(x, y, *Menu,
*Popover)` that spawns a Menu at worker-relative coords + auto-
dismisses on outside-click.
- ~~**Context menu helper**~~ — **done (v0.17)**: `ContextMenu`
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.
Expand Down
112 changes: 112 additions & 0 deletions contextmenu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// 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"

// ContextMenu is a right-click popup: a Menu that appears at an arbitrary point
// (the cursor), auto-sizes to its items, clamps itself inside the surface so it
// never spills off an edge, and dismisses when the user clicks outside it. It
// is the overlay wrapper the widget model was missing around the bare Menu,
// mirroring how DropDown/DatePicker own their pop-ups.
//
// The ContextMenu's own Bounds is the whole surface it may cover (so it can
// catch an outside-click anywhere); AnchorX/AnchorY and incoming event
// coordinates are in that same frame. Call Popup(x, y) to show it at a point.
type ContextMenu struct {
Base
Menu *Menu
Open bool
AnchorX, AnchorY int
}

// ContextMenuMinW is the floor on a context menu's width so a menu of very
// short labels still reads as a panel.
const ContextMenuMinW = 96

// NewContextMenu wraps the given Menu as a (closed) context menu.
func NewContextMenu(menu *Menu) *ContextMenu { return &ContextMenu{Menu: menu} }

// Popup opens the menu anchored at (x, y) and wires the Menu's OnClose so that
// activating an item (or the menu closing itself) also closes the overlay.
func (c *ContextMenu) Popup(x, y int) {
c.AnchorX, c.AnchorY = x, y
c.Open = true
c.Menu.OnClose = func() { c.Open = false }
}

// Close hides the menu.
func (c *ContextMenu) Close() { c.Open = false }

// menuSize measures the popup: width is the widest row (label + shortcut or
// submenu chevron) floored at ContextMenuMinW; height is the summed row heights
// plus the 4px body inset.
func (c *ContextMenu) menuSize() (w, h int) {
w = ContextMenuMinW
h = 4
for _, it := range c.Menu.Items {
if it.Separator {
h += MenuSeparatorH
continue
}
rowW := 16 + TextWidth(it.Label)
if it.Submenu != nil {
rowW += 12
} else if it.Shortcut != "" {
rowW += 12 + TextWidth(it.Shortcut)
}
if rowW > w {
w = rowW
}
h += MenuRowH
}
return w, h
}

// MenuBounds is the rect the Menu occupies: the measured size placed at the
// anchor, then shifted so it stays fully inside the surface (c.Bounds()).
func (c *ContextMenu) MenuBounds() Rect {
w, h := c.menuSize()
surf := c.Bounds()
x, y := c.AnchorX, c.AnchorY
if x+w > surf.X+surf.W {
x = surf.X + surf.W - w
}
if y+h > surf.Y+surf.H {
y = surf.Y + surf.H - h
}
if x < surf.X {
x = surf.X
}
if y < surf.Y {
y = surf.Y
}
return Rect{X: x, Y: y, W: w, H: h}
}

// Draw paints the Menu at its clamped bounds when open; nothing when closed.
func (c *ContextMenu) Draw(p painter.Painter, theme *Theme) {
if !c.Open {
return
}
c.Menu.SetBounds(c.MenuBounds())
c.Menu.Draw(p, theme)
}

// OnEvent routes a click inside the menu to the Menu (translated to its local
// frame, so the hit row's Action fires and closes the overlay via OnClose); a
// click anywhere outside dismisses the menu.
func (c *ContextMenu) OnEvent(ev Event) {
if !c.Open || ev.Kind != EventClick {
return
}
mb := c.MenuBounds()
if ev.X >= mb.X && ev.X < mb.X+mb.W && ev.Y >= mb.Y && ev.Y < mb.Y+mb.H {
c.Menu.SetBounds(mb)
c.Menu.OnEvent(Event{Kind: EventClick, X: ev.X - mb.X, Y: ev.Y - mb.Y})
return
}
c.Open = false // outside click → dismiss
}
136 changes: 136 additions & 0 deletions contextmenu_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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"

// newTestContextMenu returns a context menu over a 200x200 surface with three
// items (Cut, a separator, Paste-with-shortcut) plus one submenu row.
func newTestContextMenu() (*ContextMenu, *[]string) {
fired := &[]string{}
menu := NewMenu([]MenuItem{
{Label: "Cut", Action: func() { *fired = append(*fired, "Cut") }},
{Separator: true},
{Label: "Paste", Shortcut: "Ctrl+V", Action: func() { *fired = append(*fired, "Paste") }},
{Label: "More", Submenu: NewMenu(nil)},
})
cm := NewContextMenu(menu)
cm.SetBounds(Rect{X: 0, Y: 0, W: 200, H: 200})
return cm, fired
}

func TestContextMenuPopupOpens(t *testing.T) {
cm, _ := newTestContextMenu()
if cm.Open {
t.Fatal("should start closed")
}
cm.Popup(30, 40)
if !cm.Open || cm.AnchorX != 30 || cm.AnchorY != 40 {
t.Fatalf("Popup did not open at anchor: open=%v (%d,%d)", cm.Open, cm.AnchorX, cm.AnchorY)
}
cm.Close()
if cm.Open {
t.Fatal("Close did not hide")
}
}

func TestContextMenuSizeFloorsAndGrows(t *testing.T) {
// A menu of tiny labels floors at ContextMenuMinW.
small := NewContextMenu(NewMenu([]MenuItem{{Label: "a", Action: func() {}}}))
if w, _ := small.menuSize(); w != ContextMenuMinW {
t.Errorf("small width = %d, want floor %d", w, ContextMenuMinW)
}
// A genuinely wide label grows the width past the floor (the rowW > w arm).
wide := NewContextMenu(NewMenu([]MenuItem{
{Label: "Paste Special As Plain Text", Shortcut: "Ctrl+Shift+V", Action: func() {}},
}))
if w, _ := wide.menuSize(); w <= ContextMenuMinW {
t.Errorf("wide menu width = %d, want > floor %d", w, ContextMenuMinW)
}
// Height: 3 rows * MenuRowH + 1 separator + 4 inset (the standard fixture).
cm, _ := newTestContextMenu()
if _, h := cm.menuSize(); h != 3*MenuRowH+MenuSeparatorH+4 {
t.Errorf("height = %d, want %d", h, 3*MenuRowH+MenuSeparatorH+4)
}
}

func TestContextMenuClampsInsideSurface(t *testing.T) {
cm, _ := newTestContextMenu()
// Anchor in the far bottom-right corner: the menu must shift up+left to stay
// fully inside the 200x200 surface.
cm.Popup(199, 199)
mb := cm.MenuBounds()
if mb.X+mb.W > 200 || mb.Y+mb.H > 200 {
t.Errorf("menu spilled off bottom-right: %+v", mb)
}
// Anchor past the top-left origin clamps back to the surface origin.
cm.AnchorX, cm.AnchorY = -50, -50
mb = cm.MenuBounds()
if mb.X < 0 || mb.Y < 0 {
t.Errorf("menu spilled off top-left: %+v", mb)
}
}

func TestContextMenuDrawOnlyWhenOpen(t *testing.T) {
cm, _ := newTestContextMenu()
surf := makeSurface(200, 200)
// Closed: nothing painted (corner stays sentinel).
cm.Draw(newP(surf, 200), DefaultLight())
sentinel := RGBA{R: 0xC8, G: 0xC8, B: 0xC8, A: 255}
if got := pixelAt(surf, 200, 5, 5); got != sentinel {
t.Errorf("closed menu painted at (5,5): %+v", got)
}
// Open: the menu body paints its Border frame somewhere.
cm.Popup(10, 10)
cm.Draw(newP(surf, 200), DefaultLight())
if got := countInk(surf, 200, 200, DefaultLight().Border); got == 0 {
t.Error("open menu drew no border")
}
}

func TestContextMenuClickInsideFiresAndCloses(t *testing.T) {
cm, fired := newTestContextMenu()
cm.Popup(10, 10)
mb := cm.MenuBounds()
// Click the first row ("Cut"): its local Y is inside the first MenuRowH band
// (body inset 2 + a few px). Convert to surface coords.
localY := 2 + MenuRowH/2
cm.OnEvent(Event{Kind: EventClick, X: mb.X + 10, Y: mb.Y + localY})
if len(*fired) != 1 || (*fired)[0] != "Cut" {
t.Fatalf("expected Cut to fire, got %v", *fired)
}
if cm.Open {
t.Error("activating an item should close the menu (via OnClose)")
}
}

func TestContextMenuClickOutsideDismisses(t *testing.T) {
cm, fired := newTestContextMenu()
cm.Popup(10, 10)
mb := cm.MenuBounds()
// Click well outside the menu rect → dismiss, no action.
cm.OnEvent(Event{Kind: EventClick, X: mb.X + mb.W + 20, Y: mb.Y + mb.H + 20})
if cm.Open {
t.Error("outside click should dismiss")
}
if len(*fired) != 0 {
t.Errorf("outside click fired an action: %v", *fired)
}
}

func TestContextMenuIgnoresWhenClosedOrNonClick(t *testing.T) {
cm, _ := newTestContextMenu()
// Closed: any event is a no-op.
cm.OnEvent(Event{Kind: EventClick, X: 5, Y: 5})
if cm.Open {
t.Error("event on a closed menu should not open it")
}
// Open but non-click: ignored (menu stays open).
cm.Popup(10, 10)
cm.OnEvent(Event{Kind: EventKeyDown, Code: "Escape"})
if !cm.Open {
t.Error("non-click event should not dismiss")
}
}
Loading