From 78fb1400453cc81f521736dfd1ec59a7a8b19b59 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 10 Jul 2026 14:40:00 +0200 Subject: [PATCH] toolkit: add ContextMenu right-click popup helper The overlay wrapper the widget model lacked around the bare Menu: Popup(x, y) shows a Menu at the cursor, it auto-sizes to its items (widest label + shortcut/chevron, floored), clamps itself inside the surface so it never spills off an edge, forwards an inside-click to the Menu (firing the row's Action and closing via OnClose), and dismisses on any outside-click. Mirrors how DropDown/DatePicker own their pop-ups. 100% coverage. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +-- contextmenu.go | 112 ++++++++++++++++++++++++++++++++++++ contextmenu_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 contextmenu.go create mode 100644 contextmenu_test.go diff --git a/README.md b/README.md index 927d10e..e634750 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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. diff --git a/contextmenu.go b/contextmenu.go new file mode 100644 index 0000000..e23e030 --- /dev/null +++ b/contextmenu.go @@ -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 +} diff --git a/contextmenu_test.go b/contextmenu_test.go new file mode 100644 index 0000000..fbbf9ad --- /dev/null +++ b/contextmenu_test.go @@ -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") + } +}