feat(lifecycle): coalesce idle callbacks and bump puregotk dependencies
Add mainloop.Coalescer to deduplicate bursts of same-key IdleAdd calls, preventing purego callback slot exhaustion under rapid UI events. Refactor omnibox and link_status to use coalescer instead of allocating fresh glib.SourceFunc closures per event. Lift timer callbacks to struct fields so they are allocated once. Bump puregotk to fb13c24 (refcounted callback lifecycle) and puregotk-webkit to v0.0.13. Remove local replace directives in favor of GitHub module refs. Add CI steps for verify-purego and CGO-disabled build.
- commit
- 87e495cd62bc68e74b2482d17acb960b72eb22e4
- author
- brice · · 2026-02-12 18:21 UTC
- parent
- 96b6ff4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +234 -49
M .github/workflows/ci.yml +4 -0
| @@ -109,3 +109,7 @@ jobs: | |||
| 109 | 109 | GDK_BACKEND: wayland | |
| 110 | 110 | WAYLAND_DISPLAY: wayland-0 | |
| 111 | 111 | run: go test -v ./... | |
| 112 | + | - name: Verify purego-only constraints | |
| 113 | + | run: make verify-purego | |
| 114 | + | - name: Verify CGO-disabled build | |
| 115 | + | run: CGO_ENABLED=0 go build -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber | |
| @@ -109,3 +109,7 @@ jobs: | |||||
| 109 | GDK_BACKEND: wayland | 109 | GDK_BACKEND: wayland | ||
| 110 | WAYLAND_DISPLAY: wayland-0 | 110 | WAYLAND_DISPLAY: wayland-0 | ||
| 111 | run: go test -v ./... | 111 | run: go test -v ./... | ||
| 112 | + | - name: Verify purego-only constraints | |||
| 113 | + | run: make verify-purego | |||
| 114 | + | - name: Verify CGO-disabled build | |||
| 115 | + | run: CGO_ENABLED=0 go build -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber | |||
M Makefile +9 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage | |||
| 84 | 84 | go tool cover -html=coverage.out -o coverage.html | |
| 85 | 85 | @echo "Coverage report: coverage.html" | |
| 86 | 86 | ||
| 87 | + | stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness | |
| 88 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go | |
| 89 | + | ||
| 90 | + | verify-purego: ## Ensure callback path stays cgo/export free | |
| 91 | + | bash ./scripts/verify_purego_only.sh | |
| 92 | + | ||
| 87 | 93 | # Linting | |
| 88 | 94 | lint: ## Run golangci-lint | |
| 89 | 95 | @echo "Running golangci-lint..." | |
| @@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working | |||
| 135 | 141 | @$(MAKE) build-quick | |
| 136 | 142 | @echo "\nRunning tests..." | |
| 137 | 143 | @$(MAKE) test | |
| 144 | + | @echo "\nVerifying purego-only constraints..." | |
| 145 | + | @$(MAKE) verify-purego | |
| 138 | 146 | @echo "\nAll checks passed!" | |
| 139 | 147 | ||
| 140 | 148 | # Documentation | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean | 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage | |||||
| 84 | go tool cover -html=coverage.out -o coverage.html | 84 | go tool cover -html=coverage.out -o coverage.html | ||
| 85 | @echo "Coverage report: coverage.html" | 85 | @echo "Coverage report: coverage.html" | ||
| 86 | 86 | ||||
| 87 | + | stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness | |||
| 88 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go | |||
| 89 | + | ||||
| 90 | + | verify-purego: ## Ensure callback path stays cgo/export free | |||
| 91 | + | bash ./scripts/verify_purego_only.sh | |||
| 92 | + | ||||
| 87 | # Linting | 93 | # Linting | ||
| 88 | lint: ## Run golangci-lint | 94 | lint: ## Run golangci-lint | ||
| 89 | @echo "Running golangci-lint..." | 95 | @echo "Running golangci-lint..." | ||
| @@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working | |||||
| 135 | @$(MAKE) build-quick | 141 | @$(MAKE) build-quick | ||
| 136 | @echo "\nRunning tests..." | 142 | @echo "\nRunning tests..." | ||
| 137 | @$(MAKE) test | 143 | @$(MAKE) test | ||
| 144 | + | @echo "\nVerifying purego-only constraints..." | |||
| 145 | + | @$(MAKE) verify-purego | |||
| 138 | @echo "\nAll checks passed!" | 146 | @echo "\nAll checks passed!" | ||
| 139 | 147 | ||||
| 140 | # Documentation | 148 | # Documentation | ||
M go.mod +3 -3
| @@ -3,14 +3,14 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.12 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.13 | |
| 7 | 7 | github.com/charmbracelet/bubbles v1.0.0 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/fsnotify/fsnotify v1.9.0 | |
| 11 | 11 | github.com/godbus/dbus/v5 v5.2.2 | |
| 12 | 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | |
| 13 | - | github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b | |
| 13 | + | github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0 | |
| 14 | 14 | github.com/ncruces/go-sqlite3 v0.30.5 | |
| 15 | 15 | github.com/pelletier/go-toml/v2 v2.2.4 | |
| 16 | 16 | github.com/pressly/goose/v3 v3.26.0 | |
| @@ -23,7 +23,7 @@ require ( | |||
| 23 | 23 | gopkg.in/yaml.v3 v3.0.1 | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 | |
| 26 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 | |
| 27 | 27 | ||
| 28 | 28 | require ( | |
| 29 | 29 | github.com/atotto/clipboard v0.1.4 // indirect | |
| @@ -3,14 +3,14 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.12 | 6 | + | github.com/bnema/puregotk-webkit v0.0.13 |
| 7 | github.com/charmbracelet/bubbles v1.0.0 | 7 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | github.com/fsnotify/fsnotify v1.9.0 | 10 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 11 | github.com/godbus/dbus/v5 v5.2.2 | 11 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | ||
| 13 | - | github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b | 13 | + | github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0 |
| 14 | github.com/ncruces/go-sqlite3 v0.30.5 | 14 | github.com/ncruces/go-sqlite3 v0.30.5 | ||
| 15 | github.com/pelletier/go-toml/v2 v2.2.4 | 15 | github.com/pelletier/go-toml/v2 v2.2.4 | ||
| 16 | github.com/pressly/goose/v3 v3.26.0 | 16 | github.com/pressly/goose/v3 v3.26.0 | ||
| @@ -23,7 +23,7 @@ require ( | |||||
| 23 | gopkg.in/yaml.v3 v3.0.1 | 23 | gopkg.in/yaml.v3 v3.0.1 | ||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 | 26 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 |
| 27 | 27 | ||||
| 28 | require ( | 28 | require ( | ||
| 29 | github.com/atotto/clipboard v0.1.4 // indirect | 29 | github.com/atotto/clipboard v0.1.4 // indirect | ||
M go.sum +4 -4
| @@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE | |||
| 4 | 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | |
| 5 | 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | |
| 6 | 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | |
| 7 | - | github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs= | |
| 8 | - | github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc= | |
| 9 | - | github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y= | |
| 10 | - | github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo= | |
| 7 | + | github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 h1:oV1Tlh5COw+K3ua6PnezSuVeFi/CxQ05DhhrzKYt+4Q= | |
| 8 | + | github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc= | |
| 9 | + | github.com/bnema/puregotk-webkit v0.0.13 h1:0j99i2a7GPhCQiIaV2LQ4n2p3Tw0q//V/CccDDU4M3A= | |
| 10 | + | github.com/bnema/puregotk-webkit v0.0.13/go.mod h1:wc+zzGdAy51YgyG1N3iwkQIusgCY4lFHGJUQgi4svJ0= | |
| 11 | 11 | github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= | |
| 12 | 12 | github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= | |
| 13 | 13 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= | |
| @@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE | |||||
| 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | ||
| 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | ||
| 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | ||
| 7 | - | github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs= | 7 | + | github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 h1:oV1Tlh5COw+K3ua6PnezSuVeFi/CxQ05DhhrzKYt+4Q= |
| 8 | - | github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc= | 8 | + | github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc= |
| 9 | - | github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y= | 9 | + | github.com/bnema/puregotk-webkit v0.0.13 h1:0j99i2a7GPhCQiIaV2LQ4n2p3Tw0q//V/CccDDU4M3A= |
| 10 | - | github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo= | 10 | + | github.com/bnema/puregotk-webkit v0.0.13/go.mod h1:wc+zzGdAy51YgyG1N3iwkQIusgCY4lFHGJUQgi4svJ0= |
| 11 | github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= | 11 | github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= | ||
| 12 | github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= | 12 | github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= | ||
| 13 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= | 13 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= | ||
M internal/ui/component/link_status.go +58 -22
| @@ -4,6 +4,7 @@ package component | |||
| 4 | 4 | import ( | |
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |
| 7 | 8 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 8 | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 9 | 10 | ||
| @@ -30,6 +31,9 @@ type LinkStatusOverlay struct { | |||
| 30 | 31 | showTimer uint // GLib timer for delayed show | |
| 31 | 32 | autoHideTimer uint // GLib timer for auto-hide after timeout | |
| 32 | 33 | visible bool // Whether overlay is currently visible (has .visible class) | |
| 34 | + | showTimerCb glib.SourceFunc | |
| 35 | + | autoHideCb glib.SourceFunc | |
| 36 | + | coalescer *mainloop.Coalescer | |
| 33 | 37 | mu sync.Mutex | |
| 34 | 38 | } | |
| 35 | 39 | ||
| @@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |||
| 60 | 64 | label.SetMaxWidthChars(linkStatusMaxChars) | |
| 61 | 65 | container.Append(label) | |
| 62 | 66 | ||
| 63 | - | return &LinkStatusOverlay{ | |
| 67 | + | overlay := &LinkStatusOverlay{ | |
| 64 | 68 | factory: factory, | |
| 65 | 69 | container: container, | |
| 66 | 70 | label: label, | |
| 67 | 71 | visible: false, | |
| 68 | 72 | } | |
| 73 | + | overlay.coalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 74 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 75 | + | fn() | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | glib.IdleAdd(&cb, 0) | |
| 79 | + | }) | |
| 80 | + | overlay.showTimerCb = func(_ uintptr) bool { | |
| 81 | + | overlay.mu.Lock() | |
| 82 | + | defer overlay.mu.Unlock() | |
| 83 | + | ||
| 84 | + | if overlay.pendingURI != "" { | |
| 85 | + | overlay.label.SetText(overlay.pendingURI) | |
| 86 | + | if !overlay.visible { | |
| 87 | + | overlay.visible = true | |
| 88 | + | overlay.container.AddCssClass("visible") | |
| 89 | + | } | |
| 90 | + | overlay.resetAutoHideTimerLocked() | |
| 91 | + | } | |
| 92 | + | overlay.showTimer = 0 | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | overlay.autoHideCb = func(_ uintptr) bool { | |
| 96 | + | overlay.mu.Lock() | |
| 97 | + | defer overlay.mu.Unlock() | |
| 98 | + | ||
| 99 | + | overlay.autoHideTimer = 0 | |
| 100 | + | overlay.hide() | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | return overlay | |
| 69 | 105 | } | |
| 70 | 106 | ||
| 71 | 107 | // Show displays the link status overlay with the given URI. | |
| @@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||
| 88 | 124 | ||
| 89 | 125 | l.pendingURI = uri | |
| 90 | 126 | ||
| 91 | - | // Start delay timer before showing | |
| 92 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 127 | + | // Coalesce rapid hover events before scheduling delay timer. | |
| 128 | + | l.coalescer.Post("link-status-show-delay", func() { | |
| 93 | 129 | l.mu.Lock() | |
| 94 | 130 | defer l.mu.Unlock() | |
| 95 | 131 | ||
| 96 | - | if l.pendingURI != "" { | |
| 97 | - | l.label.SetText(l.pendingURI) | |
| 98 | - | if !l.visible { | |
| 99 | - | l.visible = true | |
| 100 | - | l.container.AddCssClass("visible") | |
| 101 | - | } | |
| 102 | - | // Start or reset auto-hide timer | |
| 103 | - | l.resetAutoHideTimerLocked() | |
| 132 | + | if l.pendingURI == "" { | |
| 133 | + | return | |
| 104 | 134 | } | |
| 105 | - | l.showTimer = 0 | |
| 106 | - | return false // Don't repeat | |
| 135 | + | if l.showTimer != 0 { | |
| 136 | + | glib.SourceRemove(l.showTimer) | |
| 137 | + | } | |
| 138 | + | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0) | |
| 107 | 139 | }) | |
| 108 | - | ||
| 109 | - | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0) | |
| 110 | 140 | } | |
| 111 | 141 | ||
| 112 | 142 | // Hide manually hides the link status overlay. | |
| @@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |||
| 144 | 174 | l.autoHideTimer = 0 | |
| 145 | 175 | } | |
| 146 | 176 | ||
| 147 | - | // Start new auto-hide timer | |
| 148 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 177 | + | // Coalesce timer resets while pointer moves quickly across links. | |
| 178 | + | l.coalescer.Post("link-status-auto-hide", func() { | |
| 149 | 179 | l.mu.Lock() | |
| 150 | 180 | defer l.mu.Unlock() | |
| 151 | 181 | ||
| 152 | - | l.autoHideTimer = 0 | |
| 153 | - | l.hide() | |
| 154 | - | return false // Don't repeat | |
| 182 | + | if l.pendingURI == "" { | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | if l.autoHideTimer != 0 { | |
| 186 | + | glib.SourceRemove(l.autoHideTimer) | |
| 187 | + | } | |
| 188 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0) | |
| 155 | 189 | }) | |
| 156 | - | ||
| 157 | - | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |
| 158 | 190 | } | |
| 159 | 191 | ||
| 160 | 192 | // Widget returns the underlying widget for embedding in overlays. | |
| @@ -185,6 +217,10 @@ func (l *LinkStatusOverlay) Cleanup() { | |||
| 185 | 217 | glib.SourceRemove(l.autoHideTimer) | |
| 186 | 218 | l.autoHideTimer = 0 | |
| 187 | 219 | } | |
| 220 | + | if l.coalescer != nil { | |
| 221 | + | l.coalescer.Destroy() | |
| 222 | + | l.coalescer = nil | |
| 223 | + | } | |
| 188 | 224 | l.pendingURI = "" | |
| 189 | 225 | l.visible = false | |
| 190 | 226 | } | |
| @@ -4,6 +4,7 @@ package component | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |||
| 7 | "github.com/jwijenbergh/puregotk/v4/glib" | 8 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 8 | "github.com/jwijenbergh/puregotk/v4/gtk" | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 9 | 10 | ||||
| @@ -30,6 +31,9 @@ type LinkStatusOverlay struct { | |||||
| 30 | showTimer uint // GLib timer for delayed show | 31 | showTimer uint // GLib timer for delayed show | ||
| 31 | autoHideTimer uint // GLib timer for auto-hide after timeout | 32 | autoHideTimer uint // GLib timer for auto-hide after timeout | ||
| 32 | visible bool // Whether overlay is currently visible (has .visible class) | 33 | visible bool // Whether overlay is currently visible (has .visible class) | ||
| 34 | + | showTimerCb glib.SourceFunc | |||
| 35 | + | autoHideCb glib.SourceFunc | |||
| 36 | + | coalescer *mainloop.Coalescer | |||
| 33 | mu sync.Mutex | 37 | mu sync.Mutex | ||
| 34 | } | 38 | } | ||
| 35 | 39 | ||||
| @@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |||||
| 60 | label.SetMaxWidthChars(linkStatusMaxChars) | 64 | label.SetMaxWidthChars(linkStatusMaxChars) | ||
| 61 | container.Append(label) | 65 | container.Append(label) | ||
| 62 | 66 | ||||
| 63 | - | return &LinkStatusOverlay{ | 67 | + | overlay := &LinkStatusOverlay{ |
| 64 | factory: factory, | 68 | factory: factory, | ||
| 65 | container: container, | 69 | container: container, | ||
| 66 | label: label, | 70 | label: label, | ||
| 67 | visible: false, | 71 | visible: false, | ||
| 68 | } | 72 | } | ||
| 73 | + | overlay.coalescer = mainloop.NewCoalescer(func(fn func()) { | |||
| 74 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 75 | + | fn() | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | glib.IdleAdd(&cb, 0) | |||
| 79 | + | }) | |||
| 80 | + | overlay.showTimerCb = func(_ uintptr) bool { | |||
| 81 | + | overlay.mu.Lock() | |||
| 82 | + | defer overlay.mu.Unlock() | |||
| 83 | + | ||||
| 84 | + | if overlay.pendingURI != "" { | |||
| 85 | + | overlay.label.SetText(overlay.pendingURI) | |||
| 86 | + | if !overlay.visible { | |||
| 87 | + | overlay.visible = true | |||
| 88 | + | overlay.container.AddCssClass("visible") | |||
| 89 | + | } | |||
| 90 | + | overlay.resetAutoHideTimerLocked() | |||
| 91 | + | } | |||
| 92 | + | overlay.showTimer = 0 | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | overlay.autoHideCb = func(_ uintptr) bool { | |||
| 96 | + | overlay.mu.Lock() | |||
| 97 | + | defer overlay.mu.Unlock() | |||
| 98 | + | ||||
| 99 | + | overlay.autoHideTimer = 0 | |||
| 100 | + | overlay.hide() | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | return overlay | |||
| 69 | } | 105 | } | ||
| 70 | 106 | ||||
| 71 | // Show displays the link status overlay with the given URI. | 107 | // Show displays the link status overlay with the given URI. | ||
| @@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||||
| 88 | 124 | ||||
| 89 | l.pendingURI = uri | 125 | l.pendingURI = uri | ||
| 90 | 126 | ||||
| 91 | - | // Start delay timer before showing | 127 | + | // Coalesce rapid hover events before scheduling delay timer. |
| 92 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | 128 | + | l.coalescer.Post("link-status-show-delay", func() { |
| 93 | l.mu.Lock() | 129 | l.mu.Lock() | ||
| 94 | defer l.mu.Unlock() | 130 | defer l.mu.Unlock() | ||
| 95 | 131 | ||||
| 96 | - | if l.pendingURI != "" { | 132 | + | if l.pendingURI == "" { |
| 97 | - | l.label.SetText(l.pendingURI) | 133 | + | return |
| 98 | - | if !l.visible { | |||
| 99 | - | l.visible = true | |||
| 100 | - | l.container.AddCssClass("visible") | |||
| 101 | - | } | |||
| 102 | - | // Start or reset auto-hide timer | |||
| 103 | - | l.resetAutoHideTimerLocked() | |||
| 104 | } | 134 | } | ||
| 105 | - | l.showTimer = 0 | 135 | + | if l.showTimer != 0 { |
| 106 | - | return false // Don't repeat | 136 | + | glib.SourceRemove(l.showTimer) |
| 137 | + | } | |||
| 138 | + | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0) | |||
| 107 | }) | 139 | }) | ||
| 108 | - | ||||
| 109 | - | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0) | |||
| 110 | } | 140 | } | ||
| 111 | 141 | ||||
| 112 | // Hide manually hides the link status overlay. | 142 | // Hide manually hides the link status overlay. | ||
| @@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |||||
| 144 | l.autoHideTimer = 0 | 174 | l.autoHideTimer = 0 | ||
| 145 | } | 175 | } | ||
| 146 | 176 | ||||
| 147 | - | // Start new auto-hide timer | 177 | + | // Coalesce timer resets while pointer moves quickly across links. |
| 148 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | 178 | + | l.coalescer.Post("link-status-auto-hide", func() { |
| 149 | l.mu.Lock() | 179 | l.mu.Lock() | ||
| 150 | defer l.mu.Unlock() | 180 | defer l.mu.Unlock() | ||
| 151 | 181 | ||||
| 152 | - | l.autoHideTimer = 0 | 182 | + | if l.pendingURI == "" { |
| 153 | - | l.hide() | 183 | + | return |
| 154 | - | return false // Don't repeat | 184 | + | } |
| 185 | + | if l.autoHideTimer != 0 { | |||
| 186 | + | glib.SourceRemove(l.autoHideTimer) | |||
| 187 | + | } | |||
| 188 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0) | |||
| 155 | }) | 189 | }) | ||
| 156 | - | ||||
| 157 | - | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |||
| 158 | } | 190 | } | ||
| 159 | 191 | ||||
| 160 | // Widget returns the underlying widget for embedding in overlays. | 192 | // Widget returns the underlying widget for embedding in overlays. | ||
| @@ -185,6 +217,10 @@ func (l *LinkStatusOverlay) Cleanup() { | |||||
| 185 | glib.SourceRemove(l.autoHideTimer) | 217 | glib.SourceRemove(l.autoHideTimer) | ||
| 186 | l.autoHideTimer = 0 | 218 | l.autoHideTimer = 0 | ||
| 187 | } | 219 | } | ||
| 220 | + | if l.coalescer != nil { | |||
| 221 | + | l.coalescer.Destroy() | |||
| 222 | + | l.coalescer = nil | |||
| 223 | + | } | |||
| 188 | l.pendingURI = "" | 224 | l.pendingURI = "" | ||
| 189 | l.visible = false | 225 | l.visible = false | ||
| 190 | } | 226 | } | ||
M internal/ui/component/omnibox.go +23 -19
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 18 | 18 | "github.com/bnema/dumber/internal/ui/input" | |
| 19 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 20 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |
| 20 | 21 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 21 | 22 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | 23 | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| @@ -138,6 +139,7 @@ type Omnibox struct { | |||
| 138 | 139 | // Click outside handler | |
| 139 | 140 | clickOutsideController *gtk.GestureClick | |
| 140 | 141 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | |
| 142 | + | idleCoalescer *mainloop.Coalescer | |
| 141 | 143 | } | |
| 142 | 144 | ||
| 143 | 145 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 182 | 184 | ctx: ctx, | |
| 183 | 185 | uiScale: uiScale, | |
| 184 | 186 | } | |
| 187 | + | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 188 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 189 | + | fn() | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | glib.IdleAdd(&cb, 0) | |
| 193 | + | }) | |
| 185 | 194 | ||
| 186 | 195 | if err := o.createWidgets(); err != nil { | |
| 187 | 196 | log.Error().Err(err).Msg("failed to create omnibox widgets") | |
| @@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() { | |||
| 817 | 826 | o.debounceTimer.Stop() | |
| 818 | 827 | } | |
| 819 | 828 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | |
| 820 | - | // Schedule on GTK main thread — performSearch reads GTK widget state | |
| 821 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 822 | - | o.performSearch() | |
| 823 | - | return false | |
| 824 | - | } | |
| 825 | - | glib.IdleAdd(&cb, 0) | |
| 829 | + | o.idleCoalescer.Post("omnibox-search", o.performSearch) | |
| 826 | 830 | }) | |
| 827 | 831 | o.debounceMu.Unlock() | |
| 828 | 832 | } | |
| @@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 879 | 883 | capturedToken := o.ghostToken | |
| 880 | 884 | o.mu.Unlock() | |
| 881 | 885 | ||
| 882 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 886 | + | o.idleCoalescer.Post("omnibox-ghost-set", func() { | |
| 883 | 887 | if o.entry == nil { | |
| 884 | - | return false | |
| 888 | + | return | |
| 885 | 889 | } | |
| 886 | 890 | ||
| 887 | 891 | o.mu.RLock() | |
| @@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 889 | 893 | currentToken := o.ghostToken | |
| 890 | 894 | o.mu.RUnlock() | |
| 891 | 895 | if currentInput != originalInput { | |
| 892 | - | return false | |
| 896 | + | return | |
| 893 | 897 | } | |
| 894 | 898 | if capturedToken != currentToken { | |
| 895 | - | return false | |
| 899 | + | return | |
| 896 | 900 | } | |
| 897 | 901 | o.mu.Lock() | |
| 898 | 902 | o.isAcceptingGhost = true | |
| @@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 905 | 909 | o.mu.Lock() | |
| 906 | 910 | o.isAcceptingGhost = false | |
| 907 | 911 | o.mu.Unlock() | |
| 908 | - | return false | |
| 909 | - | } | |
| 910 | - | glib.IdleAdd(&cb, 0) | |
| 912 | + | }) | |
| 911 | 913 | } | |
| 912 | 914 | ||
| 913 | 915 | // clearGhostText hides the ghost completion text. | |
| @@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() { | |||
| 927 | 929 | return | |
| 928 | 930 | } | |
| 929 | 931 | ||
| 930 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 932 | + | o.idleCoalescer.Post("omnibox-ghost-clear", func() { | |
| 931 | 933 | if o.entry == nil { | |
| 932 | - | return false | |
| 934 | + | return | |
| 933 | 935 | } | |
| 934 | 936 | o.mu.RLock() | |
| 935 | 937 | currentToken := o.ghostToken | |
| 936 | 938 | o.mu.RUnlock() | |
| 937 | 939 | if capturedToken != currentToken { | |
| 938 | - | return false | |
| 940 | + | return | |
| 939 | 941 | } | |
| 940 | 942 | o.mu.Lock() | |
| 941 | 943 | o.isAcceptingGhost = true | |
| @@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() { | |||
| 950 | 952 | o.mu.Lock() | |
| 951 | 953 | o.isAcceptingGhost = false | |
| 952 | 954 | o.mu.Unlock() | |
| 953 | - | return false | |
| 954 | - | } | |
| 955 | - | glib.IdleAdd(&cb, 0) | |
| 955 | + | }) | |
| 956 | 956 | } | |
| 957 | 957 | ||
| 958 | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | |
| @@ -2337,6 +2337,10 @@ func (o *Omnibox) Destroy() { | |||
| 2337 | 2337 | o.retainedCallbacks = nil | |
| 2338 | 2338 | o.clickOutsideController = nil | |
| 2339 | 2339 | o.clickOutsideCb = nil | |
| 2340 | + | if o.idleCoalescer != nil { | |
| 2341 | + | o.idleCoalescer.Destroy() | |
| 2342 | + | o.idleCoalescer = nil | |
| 2343 | + | } | |
| 2340 | 2344 | ||
| 2341 | 2345 | if o.outerBox != nil { | |
| 2342 | 2346 | o.outerBox.Unparent() | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/dumber/internal/ui/adapter" | 17 | "github.com/bnema/dumber/internal/ui/adapter" | ||
| 18 | "github.com/bnema/dumber/internal/ui/input" | 18 | "github.com/bnema/dumber/internal/ui/input" | ||
| 19 | "github.com/bnema/dumber/internal/ui/layout" | 19 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 20 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |||
| 20 | "github.com/jwijenbergh/puregotk/v4/gdk" | 21 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 21 | "github.com/jwijenbergh/puregotk/v4/glib" | 22 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 22 | "github.com/jwijenbergh/puregotk/v4/graphene" | 23 | "github.com/jwijenbergh/puregotk/v4/graphene" | ||
| @@ -138,6 +139,7 @@ type Omnibox struct { | |||||
| 138 | // Click outside handler | 139 | // Click outside handler | ||
| 139 | clickOutsideController *gtk.GestureClick | 140 | clickOutsideController *gtk.GestureClick | ||
| 140 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | 141 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | ||
| 142 | + | idleCoalescer *mainloop.Coalescer | |||
| 141 | } | 143 | } | ||
| 142 | 144 | ||||
| 143 | // OmniboxConfig holds configuration for creating an Omnibox. | 145 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| @@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 182 | ctx: ctx, | 184 | ctx: ctx, | ||
| 183 | uiScale: uiScale, | 185 | uiScale: uiScale, | ||
| 184 | } | 186 | } | ||
| 187 | + | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |||
| 188 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 189 | + | fn() | |||
| 190 | + | return false | |||
| 191 | + | } | |||
| 192 | + | glib.IdleAdd(&cb, 0) | |||
| 193 | + | }) | |||
| 185 | 194 | ||||
| 186 | if err := o.createWidgets(); err != nil { | 195 | if err := o.createWidgets(); err != nil { | ||
| 187 | log.Error().Err(err).Msg("failed to create omnibox widgets") | 196 | log.Error().Err(err).Msg("failed to create omnibox widgets") | ||
| @@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 817 | o.debounceTimer.Stop() | 826 | o.debounceTimer.Stop() | ||
| 818 | } | 827 | } | ||
| 819 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | 828 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | ||
| 820 | - | // Schedule on GTK main thread — performSearch reads GTK widget state | 829 | + | o.idleCoalescer.Post("omnibox-search", o.performSearch) |
| 821 | - | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 822 | - | o.performSearch() | |||
| 823 | - | return false | |||
| 824 | - | } | |||
| 825 | - | glib.IdleAdd(&cb, 0) | |||
| 826 | }) | 830 | }) | ||
| 827 | o.debounceMu.Unlock() | 831 | o.debounceMu.Unlock() | ||
| 828 | } | 832 | } | ||
| @@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 879 | capturedToken := o.ghostToken | 883 | capturedToken := o.ghostToken | ||
| 880 | o.mu.Unlock() | 884 | o.mu.Unlock() | ||
| 881 | 885 | ||||
| 882 | - | var cb glib.SourceFunc = func(uintptr) bool { | 886 | + | o.idleCoalescer.Post("omnibox-ghost-set", func() { |
| 883 | if o.entry == nil { | 887 | if o.entry == nil { | ||
| 884 | - | return false | 888 | + | return |
| 885 | } | 889 | } | ||
| 886 | 890 | ||||
| 887 | o.mu.RLock() | 891 | o.mu.RLock() | ||
| @@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 889 | currentToken := o.ghostToken | 893 | currentToken := o.ghostToken | ||
| 890 | o.mu.RUnlock() | 894 | o.mu.RUnlock() | ||
| 891 | if currentInput != originalInput { | 895 | if currentInput != originalInput { | ||
| 892 | - | return false | 896 | + | return |
| 893 | } | 897 | } | ||
| 894 | if capturedToken != currentToken { | 898 | if capturedToken != currentToken { | ||
| 895 | - | return false | 899 | + | return |
| 896 | } | 900 | } | ||
| 897 | o.mu.Lock() | 901 | o.mu.Lock() | ||
| 898 | o.isAcceptingGhost = true | 902 | o.isAcceptingGhost = true | ||
| @@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 905 | o.mu.Lock() | 909 | o.mu.Lock() | ||
| 906 | o.isAcceptingGhost = false | 910 | o.isAcceptingGhost = false | ||
| 907 | o.mu.Unlock() | 911 | o.mu.Unlock() | ||
| 908 | - | return false | 912 | + | }) |
| 909 | - | } | |||
| 910 | - | glib.IdleAdd(&cb, 0) | |||
| 911 | } | 913 | } | ||
| 912 | 914 | ||||
| 913 | // clearGhostText hides the ghost completion text. | 915 | // clearGhostText hides the ghost completion text. | ||
| @@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() { | |||||
| 927 | return | 929 | return | ||
| 928 | } | 930 | } | ||
| 929 | 931 | ||||
| 930 | - | var cb glib.SourceFunc = func(uintptr) bool { | 932 | + | o.idleCoalescer.Post("omnibox-ghost-clear", func() { |
| 931 | if o.entry == nil { | 933 | if o.entry == nil { | ||
| 932 | - | return false | 934 | + | return |
| 933 | } | 935 | } | ||
| 934 | o.mu.RLock() | 936 | o.mu.RLock() | ||
| 935 | currentToken := o.ghostToken | 937 | currentToken := o.ghostToken | ||
| 936 | o.mu.RUnlock() | 938 | o.mu.RUnlock() | ||
| 937 | if capturedToken != currentToken { | 939 | if capturedToken != currentToken { | ||
| 938 | - | return false | 940 | + | return |
| 939 | } | 941 | } | ||
| 940 | o.mu.Lock() | 942 | o.mu.Lock() | ||
| 941 | o.isAcceptingGhost = true | 943 | o.isAcceptingGhost = true | ||
| @@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() { | |||||
| 950 | o.mu.Lock() | 952 | o.mu.Lock() | ||
| 951 | o.isAcceptingGhost = false | 953 | o.isAcceptingGhost = false | ||
| 952 | o.mu.Unlock() | 954 | o.mu.Unlock() | ||
| 953 | - | return false | 955 | + | }) |
| 954 | - | } | |||
| 955 | - | glib.IdleAdd(&cb, 0) | |||
| 956 | } | 956 | } | ||
| 957 | 957 | ||||
| 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | ||
| @@ -2337,6 +2337,10 @@ func (o *Omnibox) Destroy() { | |||||
| 2337 | o.retainedCallbacks = nil | 2337 | o.retainedCallbacks = nil | ||
| 2338 | o.clickOutsideController = nil | 2338 | o.clickOutsideController = nil | ||
| 2339 | o.clickOutsideCb = nil | 2339 | o.clickOutsideCb = nil | ||
| 2340 | + | if o.idleCoalescer != nil { | |||
| 2341 | + | o.idleCoalescer.Destroy() | |||
| 2342 | + | o.idleCoalescer = nil | |||
| 2343 | + | } | |||
| 2340 | 2344 | ||||
| 2341 | if o.outerBox != nil { | 2345 | if o.outerBox != nil { | ||
| 2342 | o.outerBox.Unparent() | 2346 | o.outerBox.Unparent() | ||
A internal/ui/component/omnibox_callback_pressure_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | |
| 6 | + | t.Skip("TODO: wire GTK stress harness and assert no abrupt crash markers") | |
| 7 | + | } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | |||
| 6 | + | t.Skip("TODO: wire GTK stress harness and assert no abrupt crash markers") | |||
| 7 | + | } | |||
A internal/ui/mainloop/coalescer.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package mainloop | |
| 2 | + | ||
| 3 | + | import "sync" | |
| 4 | + | ||
| 5 | + | // Coalescer merges bursts of same-key main-loop tasks. | |
| 6 | + | type Coalescer struct { | |
| 7 | + | mu sync.Mutex | |
| 8 | + | pending map[string]bool | |
| 9 | + | callbacks map[string]func() | |
| 10 | + | post func(func()) | |
| 11 | + | destroyed bool | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func NewCoalescer(post func(func())) *Coalescer { | |
| 15 | + | if post == nil { | |
| 16 | + | panic("mainloop.NewCoalescer: post function cannot be nil") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | return &Coalescer{ | |
| 20 | + | pending: make(map[string]bool), | |
| 21 | + | callbacks: make(map[string]func()), | |
| 22 | + | post: post, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *Coalescer) Post(key string, fn func()) { | |
| 27 | + | if fn == nil || key == "" { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | c.mu.Lock() | |
| 32 | + | if c.destroyed { | |
| 33 | + | c.mu.Unlock() | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | c.callbacks[key] = fn | |
| 37 | + | if c.pending[key] { | |
| 38 | + | c.mu.Unlock() | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | c.pending[key] = true | |
| 42 | + | post := c.post | |
| 43 | + | c.mu.Unlock() | |
| 44 | + | ||
| 45 | + | post(func() { | |
| 46 | + | c.mu.Lock() | |
| 47 | + | if c.destroyed { | |
| 48 | + | delete(c.pending, key) | |
| 49 | + | delete(c.callbacks, key) | |
| 50 | + | c.mu.Unlock() | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | fn := c.callbacks[key] | |
| 54 | + | delete(c.pending, key) | |
| 55 | + | delete(c.callbacks, key) | |
| 56 | + | c.mu.Unlock() | |
| 57 | + | ||
| 58 | + | if fn != nil { | |
| 59 | + | fn() | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (c *Coalescer) Destroy() { | |
| 65 | + | c.mu.Lock() | |
| 66 | + | c.destroyed = true | |
| 67 | + | c.pending = map[string]bool{} | |
| 68 | + | c.callbacks = map[string]func(){} | |
| 69 | + | c.mu.Unlock() | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package mainloop | |||
| 2 | + | ||||
| 3 | + | import "sync" | |||
| 4 | + | ||||
| 5 | + | // Coalescer merges bursts of same-key main-loop tasks. | |||
| 6 | + | type Coalescer struct { | |||
| 7 | + | mu sync.Mutex | |||
| 8 | + | pending map[string]bool | |||
| 9 | + | callbacks map[string]func() | |||
| 10 | + | post func(func()) | |||
| 11 | + | destroyed bool | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewCoalescer(post func(func())) *Coalescer { | |||
| 15 | + | if post == nil { | |||
| 16 | + | panic("mainloop.NewCoalescer: post function cannot be nil") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | return &Coalescer{ | |||
| 20 | + | pending: make(map[string]bool), | |||
| 21 | + | callbacks: make(map[string]func()), | |||
| 22 | + | post: post, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *Coalescer) Post(key string, fn func()) { | |||
| 27 | + | if fn == nil || key == "" { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | c.mu.Lock() | |||
| 32 | + | if c.destroyed { | |||
| 33 | + | c.mu.Unlock() | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | c.callbacks[key] = fn | |||
| 37 | + | if c.pending[key] { | |||
| 38 | + | c.mu.Unlock() | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | c.pending[key] = true | |||
| 42 | + | post := c.post | |||
| 43 | + | c.mu.Unlock() | |||
| 44 | + | ||||
| 45 | + | post(func() { | |||
| 46 | + | c.mu.Lock() | |||
| 47 | + | if c.destroyed { | |||
| 48 | + | delete(c.pending, key) | |||
| 49 | + | delete(c.callbacks, key) | |||
| 50 | + | c.mu.Unlock() | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | fn := c.callbacks[key] | |||
| 54 | + | delete(c.pending, key) | |||
| 55 | + | delete(c.callbacks, key) | |||
| 56 | + | c.mu.Unlock() | |||
| 57 | + | ||||
| 58 | + | if fn != nil { | |||
| 59 | + | fn() | |||
| 60 | + | } | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (c *Coalescer) Destroy() { | |||
| 65 | + | c.mu.Lock() | |||
| 66 | + | c.destroyed = true | |||
| 67 | + | c.pending = map[string]bool{} | |||
| 68 | + | c.callbacks = map[string]func(){} | |||
| 69 | + | c.mu.Unlock() | |||
| 70 | + | } | |||
A internal/ui/mainloop/coalescer_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package mainloop | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) { | |
| 6 | + | queue := make([]func(), 0, 8) | |
| 7 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |
| 8 | + | ||
| 9 | + | value := 0 | |
| 10 | + | for i := 1; i <= 5; i++ { | |
| 11 | + | v := i | |
| 12 | + | c.Post("omnibox-search", func() { value = v }) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if len(queue) != 1 { | |
| 16 | + | t.Fatalf("expected 1 scheduled callback, got %d", len(queue)) | |
| 17 | + | } | |
| 18 | + | queue[0]() | |
| 19 | + | ||
| 20 | + | if value != 5 { | |
| 21 | + | t.Fatalf("expected latest callback to run, got %d", value) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCoalescerDropsWorkAfterDestroy(t *testing.T) { | |
| 26 | + | queue := make([]func(), 0, 4) | |
| 27 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |
| 28 | + | ||
| 29 | + | ran := false | |
| 30 | + | c.Post("ghost-clear", func() { ran = true }) | |
| 31 | + | c.Destroy() | |
| 32 | + | ||
| 33 | + | if len(queue) != 1 { | |
| 34 | + | t.Fatalf("expected one queued callback before destroy, got %d", len(queue)) | |
| 35 | + | } | |
| 36 | + | queue[0]() | |
| 37 | + | ||
| 38 | + | if ran { | |
| 39 | + | t.Fatalf("expected queued work to be dropped after destroy") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | c.Post("ghost-clear", func() { ran = true }) | |
| 43 | + | if len(queue) != 1 { | |
| 44 | + | t.Fatalf("expected no new callback after destroy, got %d", len(queue)) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestNewCoalescerPanicsOnNilPost(t *testing.T) { | |
| 49 | + | defer func() { | |
| 50 | + | if r := recover(); r == nil { | |
| 51 | + | t.Fatalf("expected NewCoalescer to panic when post is nil") | |
| 52 | + | } | |
| 53 | + | }() | |
| 54 | + | ||
| 55 | + | _ = NewCoalescer(nil) | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package mainloop | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) { | |||
| 6 | + | queue := make([]func(), 0, 8) | |||
| 7 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |||
| 8 | + | ||||
| 9 | + | value := 0 | |||
| 10 | + | for i := 1; i <= 5; i++ { | |||
| 11 | + | v := i | |||
| 12 | + | c.Post("omnibox-search", func() { value = v }) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if len(queue) != 1 { | |||
| 16 | + | t.Fatalf("expected 1 scheduled callback, got %d", len(queue)) | |||
| 17 | + | } | |||
| 18 | + | queue[0]() | |||
| 19 | + | ||||
| 20 | + | if value != 5 { | |||
| 21 | + | t.Fatalf("expected latest callback to run, got %d", value) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCoalescerDropsWorkAfterDestroy(t *testing.T) { | |||
| 26 | + | queue := make([]func(), 0, 4) | |||
| 27 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |||
| 28 | + | ||||
| 29 | + | ran := false | |||
| 30 | + | c.Post("ghost-clear", func() { ran = true }) | |||
| 31 | + | c.Destroy() | |||
| 32 | + | ||||
| 33 | + | if len(queue) != 1 { | |||
| 34 | + | t.Fatalf("expected one queued callback before destroy, got %d", len(queue)) | |||
| 35 | + | } | |||
| 36 | + | queue[0]() | |||
| 37 | + | ||||
| 38 | + | if ran { | |||
| 39 | + | t.Fatalf("expected queued work to be dropped after destroy") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | c.Post("ghost-clear", func() { ran = true }) | |||
| 43 | + | if len(queue) != 1 { | |||
| 44 | + | t.Fatalf("expected no new callback after destroy, got %d", len(queue)) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestNewCoalescerPanicsOnNilPost(t *testing.T) { | |||
| 49 | + | defer func() { | |||
| 50 | + | if r := recover(); r == nil { | |||
| 51 | + | t.Fatalf("expected NewCoalescer to panic when post is nil") | |||
| 52 | + | } | |||
| 53 | + | }() | |||
| 54 | + | ||||
| 55 | + | _ = NewCoalescer(nil) | |||
| 56 | + | } | |||