diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd48a95..f2399c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ had changed in them - which is the gap this file exists to close. ### Fixed +- **Menus close on Escape or a click outside them.** A dropdown stayed open + until its title was clicked again or an item picked. A click outside now + closes it without acting on whatever was underneath. - **The Nodes panel shows its filter once.** The panel drew the table's filter in its own box and the table drew it again underneath, so typing showed the text twice with two carets. diff --git a/internal/ui/shell/interact_test.go b/internal/ui/shell/interact_test.go index ae7bb3db..c50fc263 100644 --- a/internal/ui/shell/interact_test.go +++ b/internal/ui/shell/interact_test.go @@ -6,6 +6,7 @@ import ( "gioui.org/f32" "gioui.org/io/input" + "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" @@ -141,3 +142,49 @@ func TestClickingAViewTabSwitchesTheView(t *testing.T) { t.Fatalf("the view did not change from %v", before) } } + +// Escape closes an open menu (#769). +func TestEscapeClosesAMenu(t *testing.T) { + h := newHarness(t) + h.sh.SetMenu("View", []MenuItem{{Label: "Something", Action: "x"}}) + h.frame() + h.click(f32.Pt(float32(h.sh.menus[1].x)+6, 8)) + if h.sh.openMenu != 1 { + t.Fatalf("the menu did not open: %d", h.sh.openMenu) + } + h.r.Queue(key.Event{Name: key.NameEscape, State: key.Press}) + h.frame() + if h.sh.openMenu != -1 { + t.Fatalf("Escape left menu %d open", h.sh.openMenu) + } +} + +// A press anywhere outside an open menu closes it, and a press on another +// title moves to that menu instead (#769). +func TestAClickOutsideClosesAMenu(t *testing.T) { + h := newHarness(t) + h.sh.OnMenu = func(a string) { h.actions = append(h.actions, a) } + h.sh.SetMenu("View", []MenuItem{{Label: "Something", Action: "x", Section: "One"}}) + h.sh.SetMenu("Simulation", []MenuItem{{Label: "Else", Action: "y"}}) + h.frame() + h.click(f32.Pt(float32(h.sh.menus[1].x)+6, 8)) + if h.sh.openMenu != 1 { + t.Fatalf("the menu did not open: %d", h.sh.openMenu) + } + // Inside the dropdown, on its heading: stays open. + h.click(f32.Pt(float32(h.sh.menus[1].x)+20, float32(h.th.RowHeight())+12)) + if h.sh.openMenu != 1 { + t.Fatalf("a press inside the dropdown closed it") + } + h.click(f32.Pt(float32(h.sh.menus[2].x)+6, 8)) + if h.sh.openMenu != 2 { + t.Fatalf("pressing the next title left %d open, want 2", h.sh.openMenu) + } + h.click(f32.Pt(900, 600)) + if h.sh.openMenu != -1 { + t.Fatalf("a press in the body left menu %d open", h.sh.openMenu) + } + if len(h.actions) != 0 { + t.Errorf("closing the menu ran %v", h.actions) + } +} diff --git a/internal/ui/shell/menu.go b/internal/ui/shell/menu.go index 9a886d07..a6b86968 100644 --- a/internal/ui/shell/menu.go +++ b/internal/ui/shell/menu.go @@ -211,6 +211,10 @@ func (sh *Shell) menuDrop(t *theme.Theme, gtx layout.Context) { if len(m.items) == 0 { return } + if sh.menuDismissed(gtx) { + sh.openMenu = -1 + return + } for i := range m.items { if m.items[i].click.Clicked(gtx) { sh.openMenu = -1 @@ -348,8 +352,18 @@ func (sh *Shell) menuDrop(t *theme.Theme, gtx layout.Context) { if x+box.X > gtx.Constraints.Max.X { x = gtx.Constraints.Max.X - box.X } - off := op.Offset(image.Pt(x, gtx.Dp(t.RowHeight()))).Push(gtx.Ops) + top := gtx.Dp(t.RowHeight()) + // The scrim: everything below the bar, so a press anywhere else closes + // the menu. The bar itself stays live, so pressing another title moves + // to that menu rather than only closing this one. + scrim := clip.Rect{Min: image.Pt(0, top), Max: gtx.Constraints.Max}.Push(gtx.Ops) + event.Op(gtx.Ops, &sh.menuScrim) + scrim.Pop() + off := op.Offset(image.Pt(x, top)).Push(gtx.Ops) defer off.Pop() + area := clip.Rect{Max: box}.Push(gtx.Ops) + event.Op(gtx.Ops, &sh.menuBox) + area.Pop() paint.FillShape(gtx.Ops, theme.Alpha(t.P.Panel, 0.98), clip.Rect{Max: box}.Op()) comp.Border(gtx, box, 2, 1, t.P.Rule) in := op.Offset(image.Pt(pad, pad)).Push(gtx.Ops) diff --git a/internal/ui/shell/menudismiss.go b/internal/ui/shell/menudismiss.go new file mode 100644 index 00000000..a91c4497 --- /dev/null +++ b/internal/ui/shell/menudismiss.go @@ -0,0 +1,41 @@ +// Closing an open menu without choosing anything from it. +package shell + +import ( + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" +) + +// menuDismissed reports whether the open menu was asked to close: Escape, or a +// press outside it. The press is taken by the scrim rather than passed on, so +// a click that closes a menu does not also act on the map or panel beneath. +func (sh *Shell) menuDismissed(gtx layout.Context) bool { + gone := false + for { + ev, ok := gtx.Event(key.Filter{Name: key.NameEscape}) + if !ok { + break + } + if ke, ok := ev.(key.Event); ok && ke.State == key.Press { + gone = true + } + } + for { + ev, ok := gtx.Event(pointer.Filter{Target: &sh.menuScrim, Kinds: pointer.Press}) + if !ok { + break + } + if pe, ok := ev.(pointer.Event); ok && pe.Kind == pointer.Press { + gone = true + } + } + // Presses on the box itself are drained and ignored: they are what keeps + // a click on a heading or the padding from falling through to the scrim. + for { + if _, ok := gtx.Event(pointer.Filter{Target: &sh.menuBox, Kinds: pointer.Press}); !ok { + break + } + } + return gone +} diff --git a/internal/ui/shell/shell.go b/internal/ui/shell/shell.go index bbfe37d7..f59f03bf 100644 --- a/internal/ui/shell/shell.go +++ b/internal/ui/shell/shell.go @@ -53,7 +53,11 @@ type Shell struct { // openMenu is which menu is showing, or -1. Not zero: zero is the first // menu, and a zero value that means "File is open" opens it on launch. openMenu int - tr transport + // menuScrim and menuBox are the pointer targets an open dropdown lays + // down: the scrim over the window below the bar, which closes it, and the + // dropdown's own box, which keeps a press on a heading from reaching it. + menuScrim, menuBox struct{} + tr transport // rfChip is the counts line's physics toggle. rfChip widget.Clickable snap *state.Snapshot