diff --git a/app/event_bridge.go b/app/event_bridge.go index 46b7ed4..def39e2 100644 --- a/app/event_bridge.go +++ b/app/event_bridge.go @@ -24,6 +24,13 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { // (the platform's OnScroll callback doesn't provide mouse coordinates). var lastMousePos geometry.Point + // Track the keyboard modifiers so mouse and wheel events can carry them. + // The platform's mouse callbacks report only a button and a position, so + // without this every MouseEvent is built with ModNone and ⌥click, ⇧click, + // ⌃click and shift-extend-selection are simply not expressible — an app + // either does without them or reimplements this tracking itself. + var mods event.Modifiers + es.OnMouseMove(func(x, y float64) { pos := geometry.Pt(float32(x), float32(y)) lastMousePos = pos @@ -33,7 +40,7 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { pressedButtons, pos, pos, // global position same as local for root dispatch - event.ModNone, + mods, ) w.HandleEvent(e) }) @@ -48,7 +55,7 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { pressedButtons, pos, pos, - event.ModNone, + mods, ) w.HandleEvent(e) }) @@ -63,14 +70,19 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { pressedButtons, pos, pos, - event.ModNone, + mods, ) w.HandleEvent(e) }) - es.OnKeyPress(func(key gpucontext.Key, mods gpucontext.Modifiers) { + es.OnKeyPress(func(key gpucontext.Key, platMods gpucontext.Modifiers) { uiKey := translateKey(key) - uiMods := translateModifiers(mods) + uiMods := translateModifiers(platMods) + // A key event reports the modifiers held BEFORE it, so pressing Shift + // alone reports no Shift. Fold the key itself in, or holding a modifier + // and clicking — with no other key in between, which is the whole + // gesture — would leave the state empty. + mods = uiMods | modifierForKey(uiKey) // Rune=0: character input is delivered separately via OnTextInput. // KeyPress only carries the key code for navigation (arrows, Tab, // Backspace, etc.) and modifier detection (Ctrl+C, etc.). @@ -83,9 +95,11 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { w.HandleEvent(e) }) - es.OnKeyRelease(func(key gpucontext.Key, mods gpucontext.Modifiers) { + es.OnKeyRelease(func(key gpucontext.Key, platMods gpucontext.Modifiers) { uiKey := translateKey(key) - uiMods := translateModifiers(mods) + uiMods := translateModifiers(platMods) + // Releasing a modifier clears it: the reported state still contains it. + mods = uiMods &^ modifierForKey(uiKey) e := event.NewKeyEvent( event.KeyRelease, uiKey, @@ -113,7 +127,7 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { delta, lastMousePos, lastMousePos, - event.ModNone, + mods, ) w.HandleEvent(e) }) @@ -123,6 +137,10 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { }) es.OnFocus(func(focused bool) { + // A modifier believed to be held after the window lost focus would turn + // the next ordinary click into a modified one: the release happened + // somewhere else and this window never saw it. + mods = event.ModNone w.HandleFocusChange(focused) }) @@ -447,6 +465,22 @@ func translateKey(key gpucontext.Key) event.Key { } // translateModifiers converts gpucontext.Modifiers to event.Modifiers. +// modifierForKey is the modifier bit a key sets while it is held, or ModNone +// for anything that is not a modifier. +func modifierForKey(k event.Key) event.Modifiers { + switch k { + case event.KeyLeftShift, event.KeyRightShift: + return event.ModShift + case event.KeyLeftCtrl, event.KeyRightCtrl: + return event.ModCtrl + case event.KeyLeftAlt, event.KeyRightAlt: + return event.ModAlt + case event.KeyLeftSuper, event.KeyRightSuper: + return event.ModSuper + } + return event.ModNone +} + func translateModifiers(mods gpucontext.Modifiers) event.Modifiers { var result event.Modifiers if mods.HasShift() { diff --git a/app/event_bridge_modifiers_test.go b/app/event_bridge_modifiers_test.go new file mode 100644 index 0000000..95df420 --- /dev/null +++ b/app/event_bridge_modifiers_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "testing" + + "github.com/gogpu/gpucontext" + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" + "github.com/gogpu/ui/theme" + "github.com/gogpu/ui/widget" +) + +// modRecorder captures the modifiers of every mouse event it is handed. +type modRecorder struct { + widget.WidgetBase + got []event.Modifiers + seen int +} + +func (w *modRecorder) Layout(_ widget.Context, c geometry.Constraints) geometry.Size { + return geometry.Sz(c.MaxWidth, c.MaxHeight) +} +func (w *modRecorder) Draw(widget.Context, widget.Canvas) {} +func (w *modRecorder) Event(_ widget.Context, e event.Event) bool { + if me, ok := e.(*event.MouseEvent); ok { + w.got = append(w.got, me.Modifiers()) + w.seen++ + return true + } + return false +} + +func bridgeWithRecorder(t *testing.T) (*mockEventSource, *modRecorder) { + t.Helper() + es := &mockEventSource{} + sched := state.NewScheduler(func(_ []widget.Widget) {}) + w := newWindow(nil, nil, sched, theme.DefaultLight(), RenderModeHostManaged) + rec := &modRecorder{} + rec.SetVisible(true) + rec.SetEnabled(true) + w.SetRoot(rec) + attachEventBridge(es, w) + return es, rec +} + +// A click while a modifier is held has to arrive as a modified click. +// +// The platform's mouse callbacks carry only a button and a position, so the +// bridge has to remember what the keyboard is holding. Without that every mouse +// event is built with ModNone and ⌥click, ⇧click and ⌃click cannot be expressed +// at all — an application either does without them or reimplements this +// tracking on top of the key events itself. +func TestMouseEventsCarryHeldModifiers(t *testing.T) { + es, rec := bridgeWithRecorder(t) + + // Alt down, then click. Nothing else is pressed in between: that is the + // gesture, and it is why the key's own modifier bit has to be folded in — + // a key event reports what was held BEFORE it, so this one reports nothing. + es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) + es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + + if rec.seen == 0 { + t.Fatal("the click never reached the widget") + } + if last := rec.got[len(rec.got)-1]; !last.Has(event.ModAlt) { + t.Errorf("click carried %v, want Alt — a modifier held over a click is lost", last) + } +} + +// Releasing the modifier stops modifying the clicks that follow. +func TestMouseModifiersClearOnRelease(t *testing.T) { + es, rec := bridgeWithRecorder(t) + + es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) + es.onKeyRelease(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) + es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + + if last := rec.got[len(rec.got)-1]; last.Has(event.ModAlt) { + t.Errorf("click carried %v after Alt was released", last) + } +} + +// A modifier released while another window had focus was never seen here, so +// holding it must not survive the focus change — otherwise the next ordinary +// click is silently a modified one. +func TestMouseModifiersClearOnFocusLoss(t *testing.T) { + es, rec := bridgeWithRecorder(t) + + es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) + es.onFocus(false) + es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + + if last := rec.got[len(rec.got)-1]; last.Has(event.ModAlt) { + t.Errorf("click carried %v after the window lost focus", last) + } +}