Series: Building Go's GPU Ecosystem
Recent articles:
The Problem: Every Widget Reinvented Click Detection
Our GUI toolkit has 27 widgets — buttons, checkboxes, sliders, text fields, dropdowns, dialogs, tab views, tree views, data tables, docking panels, and more — and each one implemented its own click/drag/selection logic from scratch. Button tracked pressed state. Slider tracked dragging. TextField had its own MouseDrag handler. No code was shared. No disambiguation existed.
When we wanted to add text selection to TextField (drag to select, double-click to select word, triple-click to select all), we realized the ad-hoc approach couldn't scale. What happens when a TextField is inside a ScrollView? Both want drag events. Who wins?
Flutter solved this problem in 2017 with the GestureArena. We decided to port the same architecture to Go.
What We Built
The Gesture Arena
The arena is the core disambiguation mechanism. When a pointer goes down, all widgets under the pointer register their gesture recognizers in the arena. The recognizers compete:
- First to accept wins. If a recognizer is confident it detected its gesture (e.g., drag exceeded the slop threshold), it accepts and all others are rejected.
- Last to not reject wins. If everyone else rejects (e.g., no movement for drag), the remaining recognizer wins by default.
- Sweep after pointer up. If no one accepted, the first member wins as a last resort.
// Widget implements gesture.GestureAware
func (w *Widget) GestureHitTest(pos geometry.Point) []gesture.Recognizer {
return []gesture.Recognizer{w.clickRec}
}
Four Recognizers + VelocityTracker
| Recognizer | What it detects | Used by |
|---|---|---|
| ClickRecognizer | Single/double/triple click with timing | Button, Checkbox, Radio |
| DragRecognizer | Pan, horizontal, vertical drag with velocity | Slider, SplitView |
| LongPressRecognizer | Press-and-hold (500ms, frame-based) | Future: context menu |
| TapAndDragRecognizer | Click counting + drag in one recognizer | TextField |
| VelocityTracker | Least-squares velocity for fling detection | ScrollView momentum |
Per-Device Thresholds
Mouse and touch have different precision. A 1px movement on a mouse is intentional; on a touchscreen it's noise. We use the same constants Flutter validated against millions of users:
| Parameter | Mouse | Touch |
|---|---|---|
| Drag threshold (slop) | 1px | 18px |
| Double-click timeout | 300ms | 300ms |
| Double-click distance | 4px (Chromium) | 100px |
| Anti-bounce minimum | 40ms | 40ms |
Unified Pointer Pipeline
We replaced the legacy mouse callback path with a single PointerEvent-based pipeline:
Platform → PointerEvent → HandlePointerEvent
├── Gesture Arena (recognizer dispatch for GestureAware widgets)
└── Derived MouseEvent (existing widgets continue working unchanged)
Every pointer event flows through this single path. No dual dispatch. Existing widgets receive MouseEvent as before — derived from the same PointerEvent that feeds the arena.
Container Widget Hit-Testing
The hardest bug we hit: a Collapsible widget (parent) and a Checkbox (child) both had ClickRecognizers. The parent won the arena and silently dropped clicks meant for the child.
The fix: GestureHitTest(pos geometry.Point) receives widget-local coordinates. Container widgets only return recognizers when the pointer is in their interactive region:
// Collapsible returns recognizer ONLY for header clicks
func (w *Widget) GestureHitTest(pos geometry.Point) []gesture.Recognizer {
headerRect := geometry.NewRect(0, 0, w.Bounds().Width(), w.headerHeight)
if !headerRect.Contains(pos) {
return nil // Content-area click → child widgets handle it
}
return []gesture.Recognizer{w.clickRec}
}
TextField: The Full Story
TextField was our proof-of-concept. Before v0.1.54, it could place a cursor on click. That's it. No selection, no word selection, no clipboard.
Now:
- Click to place cursor (with Shift+Click for range extension)
- Drag to select text character-by-character
-
Double-click to select word (uses
wordBoundsAt()with Unicode word boundaries) - Triple-click to select all text
- Double-click + drag for word-by-word selection
- Ctrl+C/V/X for OS clipboard (Win32, macOS, Linux)
All of this through one TapAndDragRecognizer that provides consecutiveTapCount on every callback — exactly how Flutter's TextSelectionGestureDetector works.
OS Clipboard
We added widget.ClipboardProvider — a DI interface (same pattern as our SoundPlayer) that bridges widget clipboard requests to the platform:
// In widget package — no platform imports
widget.ClipboardWrite("copied text")
text := widget.ClipboardRead()
// Desktop layer registers the platform implementation
widget.RegisterClipboardProvider(gogpuApp) // Win32/macOS/Linux
Ctrl+C copies to system clipboard. Ctrl+V pastes from it. Works across applications.
Signals Integration
Gesture state is exposed as opt-in reactive signals:
dragRecognizer := gesture.NewDragRecognizer(gesture.DragConfig{
OnDragStart: handleStart,
}, gesture.WithDraggingSignal(isDragging))
// Widget binds to signal for reactive redraw
state.BindToScheduler(isDragging, widget, scheduler)
Bool signals suppress no-op notifications. Point signals fire on every change. Frame-based timers (no goroutines) for long press.
Numbers
- ~8,300 LOC added (gesture/ package + widget migrations + clipboard + tests)
- 124 gesture tests, 96% coverage
-
20 widgets implement
GestureAwarewithGestureHitTest - 0 goroutines in the entire gesture system — single-threaded by design
- 5 enterprise references studied: Flutter (source code verified), Chromium, GTK4, Qt6, Android
What's Next
The gesture system is designed for multi-touch from day one. PointerEvent carries PointerID and PointerType (mouse/touch/pen). When Android support lands (@besmpl is working on it), pinch-to-zoom and multi-finger gestures will plug into the same arena.
Context menu (right-click) is the next visible feature. The gesture infrastructure supports any button — ClickRecognizer just needs a ButtonRight config.
gogpu/ui is an enterprise GUI toolkit for Go with GPU-accelerated rendering. Zero CGO, 220K+ LOC, 70 packages, 7,800+ tests.
Help Us Build Go's GUI Future
The gesture system is the kind of infrastructure that needs real-world validation. We tested with our gallery and examples, but the edge cases live in your applications.
Try it. Clone the repo, run the gallery, drag-select some text, double-click a word. If something feels wrong — file an issue. If it feels right — even better. Tell us.
git clone https://github.com/gogpu/ui.git && cd ui
GOGPU_GRAPHICS_API=vulkan go run ./examples/gallery/
Star the repo. It sounds small, but GitHub stars are how Go developers discover projects. We're listed in awesome-go — every star helps us stay there.
Spread the word. A mention in your team's Slack, a retweet, a discussion with a colleague who's frustrated with GUI options in Go. Most Go developers don't know a pure Go GUI toolkit with GPU rendering exists.
Contribute. The gesture system is designed for extensibility. Build a custom recognizer, add a new widget, improve accessibility, test on your hardware. Check CONTRIBUTING.md. @besmpl contributed 20+ PRs across the ecosystem including Android arm64 support — contributors make this project better.
Support the project. If gogpu saves you from CGO hell or fills a gap in Go's ecosystem, consider supporting on Open Collective. Open source is free to use but not free to build.
The gogpu ecosystem is 1.25M+ lines of pure Go with zero CGO. gogpu/ui alone: 220K+ LOC, 70 packages, 7,800+ tests. Five GPU backends. Three platforms. One goal: make Go the best language for building desktop applications.
Top comments (0)