From 93c8fe9927afc09b0fe87ba8bb6e136dd88067a9 Mon Sep 17 00:00:00 2001 From: samyfodil Date: Sun, 2 Aug 2026 20:28:37 -0500 Subject: [PATCH] fix(app): carry keyboard modifiers on mouse and wheel events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every MouseEvent and WheelEvent the bridge builds is given event.ModNone, so MouseEvent.Modifiers() is always empty. ⌥click, ⇧click, ⌃click and shift-to-extend-a-selection cannot be expressed by an application at all. The platform's mouse callbacks report a button and a position and nothing else, so the modifier state has to come from the key events, which do carry it. The bridge now tracks it there and stamps it onto the mouse and wheel events it builds. Two details that are the whole difficulty: - A key event reports the modifiers held BEFORE it, so the event that presses Alt reports no Alt. Holding a modifier and clicking, with no other key in between, is exactly the gesture — so the key's own bit is folded in on press and taken out on release. - Focus loss clears the state. A modifier released while another window had focus was never seen here, and believing it is still held turns the next ordinary click into a modified one. Found in a terminal emulator on this toolkit, where ⌥click reveals a masked value: the click arrived with no modifier, and the application had to reimplement this tracking on top of the key events to tell the two clicks apart. Doing it in the bridge means every application gets it instead of each one rediscovering it. --- app/event_bridge.go | 50 ++++++++++++--- app/event_bridge_modifiers_test.go | 97 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 app/event_bridge_modifiers_test.go 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) + } +}