detached 06a0086

feat(cef): adaptive message pump, GPU compositing, render & input fixes

- Replace fixed 33ms timer with demand-driven adaptive pump via
  OnScheduleMessagePumpWork + host-side re-scheduling after each
  DoMessageLoopWork (matching CEF reference external pump pattern)
- Enable GPU compositing by removing --disable-gpu-compositing and
  --in-process-gpu flags; GPU runs in subprocess helper for stability
- Add glPixelStorei(GL_UNPACK_ROW_LENGTH) for per-rect texture uploads
  instead of per-row, reducing GL call count on dirty rect updates
- Accumulate dirty rects between OnPaint calls to prevent rendering
  artifacts (menu trails, ghosts) when multiple paints arrive between
  GL renders
- Forward focus-in/focus-out to CEF via host.SetFocus()
- Send actual pointer coordinates in scroll events instead of (0,0)
- Map CEF cursor types to GDK cursor names (pointer, text, grab, etc.)
- Replace fmt.Fprintf(os.Stderr) debug prints with logging.FromContext
- Pass context through WebView for structured logging
- Remove zerolog.Logger parameter from NewEngine, use context instead
- Serialize UpdateHistoryTitle through historyWorker queue to prevent
  concurrent map writes crash
commit
06a008673d2a4e8dd7b6589bb8621b22edd50386
author
brice · · 2026-03-20 08:13 UTC
committer
brice · · 2026-03-28 04:36 UTC
parent
56e9f5a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +358 -150
M internal/application/usecase/navigate.go +48 -31
@@ -32,6 +32,7 @@ const (
3232 // historyRecord holds data for async history recording.
3333 type historyRecord struct {
3434 url string
35+ title string // non-empty for title-update-only records
3536 visits int
3637 }
3738
@@ -227,23 +228,35 @@ func (uc *NavigateUseCase) historyWorker() {
227228 ticker := time.NewTicker(historyWorkerFlushInterval)
228229 defer ticker.Stop()
229230
230- pending := make(map[string]int)
231+ pendingVisits := make(map[string]int)
232+ pendingTitles := make(map[string]string)
231233
232- flushPending := func() {
233- if len(pending) == 0 {
234- return
234+ processRecord := func(record historyRecord) {
235+ if record.title != "" {
236+ pendingTitles[record.url] = record.title
237+ }
238+ if record.visits > 0 {
239+ pendingVisits[record.url] += record.visits
235240 }
236- for historyURL, visits := range pending {
241+ }
242+
243+ flushPending := func() {
244+ for historyURL, visits := range pendingVisits {
237245 uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
238246 }
239- clear(pending)
247+ clear(pendingVisits)
248+
249+ for historyURL, title := range pendingTitles {
250+ uc.persistTitleUpdate(uc.ctx, historyURL, title)
251+ }
252+ clear(pendingTitles)
240253 }
241254
242255 drainQueue := func() {
243256 for {
244257 select {
245258 case record := <-uc.historyQueue:
246- pending[record.url] += record.visits
259+ processRecord(record)
247260 default:
248261 return
249262 }
@@ -253,7 +266,7 @@ func (uc *NavigateUseCase) historyWorker() {
253266 for {
254267 select {
255268 case record := <-uc.historyQueue:
256- pending[record.url] += record.visits
269+ processRecord(record)
257270 case <-ticker.C:
258271 flushPending()
259272 case <-uc.done:
@@ -312,45 +325,49 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
312325 }
313326 }
314327
315-// UpdateHistoryTitle updates the title of a history entry after page load.
316-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {
328+// persistTitleUpdate writes a title update to the database.
329+// Called from the background worker goroutine.
330+func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) {
317331 log := logging.FromContext(ctx)
318332
319- // Canonicalize URL the same way history records are persisted.
320- historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
321- StripTrackingParams: stripTrackingParamsForHistoryDedup,
322- })
323- if historyURL == "" {
324- log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update")
325- return nil
326- }
327- log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
328-
329333 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
330334 if err != nil {
331- return fmt.Errorf("failed to find history entry: %w", err)
335+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
336+ return
332337 }
333-
334338 if entry == nil {
335- // Entry doesn't exist - don't create it here
336- // Initial navigation should have already created the entry
337- // This can happen if URL changed during page load (SPA, redirect)
338- log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
339- return nil
339+ return
340340 }
341341
342- // Update existing entry's title
343342 entry.Title = title
344343 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
345344 if err := updater.UpdateMetadata(ctx, entry); err != nil {
346- return fmt.Errorf("failed to update history metadata: %w", err)
345+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
347346 }
348- return nil
347+ return
349348 }
350349 if err := uc.historyRepo.Save(ctx, entry); err != nil {
351- return fmt.Errorf("failed to update history title: %w", err)
350+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
351+ }
352+}
353+
354+// UpdateHistoryTitle queues a title update for a history entry. The actual
355+// DB write happens in the background historyWorker to avoid concurrent
356+// repo access from the GTK main thread and the worker goroutine.
357+func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) error {
358+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
359+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
360+ })
361+ if historyURL == "" {
362+ return nil
352363 }
353364
365+ // Non-blocking send — same queue as visit records.
366+ select {
367+ case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
368+ default:
369+ // Queue full — title update is best-effort.
370+ }
354371 return nil
355372 }
356373
@@ -32,6 +32,7 @@ const (
32 // historyRecord holds data for async history recording.32 // historyRecord holds data for async history recording.
33 type historyRecord struct {33 type historyRecord struct {
34 url string34 url string
35+ title string // non-empty for title-update-only records
35 visits int36 visits int
36 }37 }
37 38
@@ -227,23 +228,35 @@ func (uc *NavigateUseCase) historyWorker() {
227 ticker := time.NewTicker(historyWorkerFlushInterval)228 ticker := time.NewTicker(historyWorkerFlushInterval)
228 defer ticker.Stop()229 defer ticker.Stop()
229 230
230- pending := make(map[string]int)231+ pendingVisits := make(map[string]int)
232+ pendingTitles := make(map[string]string)
231 233
232- flushPending := func() {234+ processRecord := func(record historyRecord) {
233- if len(pending) == 0 {235+ if record.title != "" {
234- return236+ pendingTitles[record.url] = record.title
237+ }
238+ if record.visits > 0 {
239+ pendingVisits[record.url] += record.visits
235 }240 }
236- for historyURL, visits := range pending {241+ }
242+
243+ flushPending := func() {
244+ for historyURL, visits := range pendingVisits {
237 uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})245 uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
238 }246 }
239- clear(pending)247+ clear(pendingVisits)
248+
249+ for historyURL, title := range pendingTitles {
250+ uc.persistTitleUpdate(uc.ctx, historyURL, title)
251+ }
252+ clear(pendingTitles)
240 }253 }
241 254
242 drainQueue := func() {255 drainQueue := func() {
243 for {256 for {
244 select {257 select {
245 case record := <-uc.historyQueue:258 case record := <-uc.historyQueue:
246- pending[record.url] += record.visits259+ processRecord(record)
247 default:260 default:
248 return261 return
249 }262 }
@@ -253,7 +266,7 @@ func (uc *NavigateUseCase) historyWorker() {
253 for {266 for {
254 select {267 select {
255 case record := <-uc.historyQueue:268 case record := <-uc.historyQueue:
256- pending[record.url] += record.visits269+ processRecord(record)
257 case <-ticker.C:270 case <-ticker.C:
258 flushPending()271 flushPending()
259 case <-uc.done:272 case <-uc.done:
@@ -312,45 +325,49 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
312 }325 }
313 }326 }
314 327
315-// UpdateHistoryTitle updates the title of a history entry after page load.328+// persistTitleUpdate writes a title update to the database.
316-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {329+// Called from the background worker goroutine.
330+func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) {
317 log := logging.FromContext(ctx)331 log := logging.FromContext(ctx)
318 332
319- // Canonicalize URL the same way history records are persisted.
320- historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
321- StripTrackingParams: stripTrackingParamsForHistoryDedup,
322- })
323- if historyURL == "" {
324- log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update")
325- return nil
326- }
327- log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
328-
329 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)333 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
330 if err != nil {334 if err != nil {
331- return fmt.Errorf("failed to find history entry: %w", err)335+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
336+ return
332 }337 }
333-
334 if entry == nil {338 if entry == nil {
335- // Entry doesn't exist - don't create it here339+ return
336- // Initial navigation should have already created the entry
337- // This can happen if URL changed during page load (SPA, redirect)
338- log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
339- return nil
340 }340 }
341 341
342- // Update existing entry's title
343 entry.Title = title342 entry.Title = title
344 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {343 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
345 if err := updater.UpdateMetadata(ctx, entry); err != nil {344 if err := updater.UpdateMetadata(ctx, entry); err != nil {
346- return fmt.Errorf("failed to update history metadata: %w", err)345+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
347 }346 }
348- return nil347+ return
349 }348 }
350 if err := uc.historyRepo.Save(ctx, entry); err != nil {349 if err := uc.historyRepo.Save(ctx, entry); err != nil {
351- return fmt.Errorf("failed to update history title: %w", err)350+ log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
351+ }
352+}
353+
354+// UpdateHistoryTitle queues a title update for a history entry. The actual
355+// DB write happens in the background historyWorker to avoid concurrent
356+// repo access from the GTK main thread and the worker goroutine.
357+func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) error {
358+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
359+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
360+ })
361+ if historyURL == "" {
362+ return nil
352 }363 }
353 364
365+ // Non-blocking send — same queue as visit records.
366+ select {
367+ case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
368+ default:
369+ // Queue full — title update is best-effort.
370+ }
354 return nil371 return nil
355 }372 }
356 373
M internal/bootstrap/engine.go +1 -1
@@ -77,7 +77,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
7777 )
7878 case "cef":
7979 cefCfg := cfg.Engine.CEF
80- return cef.NewEngine(input.Ctx, cefCfg, input.Logger)
80+ return cef.NewEngine(input.Ctx, cefCfg)
8181 default:
8282 return nil, fmt.Errorf("unknown engine type: %q", engineType)
8383 }
@@ -77,7 +77,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
77 )77 )
78 case "cef":78 case "cef":
79 cefCfg := cfg.Engine.CEF79 cefCfg := cfg.Engine.CEF
80- return cef.NewEngine(input.Ctx, cefCfg, input.Logger)80+ return cef.NewEngine(input.Ctx, cefCfg)
81 default:81 default:
82 return nil, fmt.Errorf("unknown engine type: %q", engineType)82 return nil, fmt.Errorf("unknown engine type: %q", engineType)
83 }83 }
A internal/infrastructure/cef/app.go +46 -0
@@ -0,0 +1,46 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+)
6+
7+// dumberApp implements purecef.App to provide a BrowserProcessHandler with
8+// demand-driven message pump scheduling (OnScheduleMessagePumpWork).
9+type dumberApp struct {
10+ engine *Engine
11+ bph purecef.BrowserProcessHandler
12+}
13+
14+// newDumberApp creates an App whose BrowserProcessHandler drives the engine's
15+// adaptive message pump.
16+func newDumberApp(engine *Engine) purecef.App {
17+ app := &dumberApp{engine: engine}
18+ app.bph = purecef.NewBrowserProcessHandler(&dumberBPH{engine: engine})
19+ return purecef.NewApp(app)
20+}
21+
22+func (a *dumberApp) OnBeforeCommandLineProcessing(_ string, _ purecef.CommandLine) {}
23+func (a *dumberApp) OnRegisterCustomSchemes(_ purecef.SchemeRegistrar) {}
24+func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
25+func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
26+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
27+
28+// dumberBPH implements purecef.BrowserProcessHandler. Only
29+// OnScheduleMessagePumpWork carries real logic; the rest are no-ops.
30+type dumberBPH struct {
31+ engine *Engine
32+}
33+
34+func (h *dumberBPH) OnRegisterCustomPreferences(_ purecef.PreferencesType, _ purecef.PreferenceRegistrar) {
35+}
36+func (h *dumberBPH) OnContextInitialized() {}
37+func (h *dumberBPH) OnBeforeChildProcessLaunch(_ purecef.CommandLine) {}
38+func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 }
39+func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
40+func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
41+
42+// OnScheduleMessagePumpWork is called by CEF when work needs to be done.
43+// delay_ms <= 0 means "as soon as possible"; > 0 means "after this delay".
44+func (h *dumberBPH) OnScheduleMessagePumpWork(delayMs int64) {
45+ h.engine.scheduleMessagePumpWork(delayMs)
46+}
@@ -0,0 +1,46 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+)
6+
7+// dumberApp implements purecef.App to provide a BrowserProcessHandler with
8+// demand-driven message pump scheduling (OnScheduleMessagePumpWork).
9+type dumberApp struct {
10+ engine *Engine
11+ bph purecef.BrowserProcessHandler
12+}
13+
14+// newDumberApp creates an App whose BrowserProcessHandler drives the engine's
15+// adaptive message pump.
16+func newDumberApp(engine *Engine) purecef.App {
17+ app := &dumberApp{engine: engine}
18+ app.bph = purecef.NewBrowserProcessHandler(&dumberBPH{engine: engine})
19+ return purecef.NewApp(app)
20+}
21+
22+func (a *dumberApp) OnBeforeCommandLineProcessing(_ string, _ purecef.CommandLine) {}
23+func (a *dumberApp) OnRegisterCustomSchemes(_ purecef.SchemeRegistrar) {}
24+func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
25+func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
26+func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
27+
28+// dumberBPH implements purecef.BrowserProcessHandler. Only
29+// OnScheduleMessagePumpWork carries real logic; the rest are no-ops.
30+type dumberBPH struct {
31+ engine *Engine
32+}
33+
34+func (h *dumberBPH) OnRegisterCustomPreferences(_ purecef.PreferencesType, _ purecef.PreferenceRegistrar) {
35+}
36+func (h *dumberBPH) OnContextInitialized() {}
37+func (h *dumberBPH) OnBeforeChildProcessLaunch(_ purecef.CommandLine) {}
38+func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 }
39+func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
40+func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
41+
42+// OnScheduleMessagePumpWork is called by CEF when work needs to be done.
43+// delay_ms <= 0 means "as soon as possible"; > 0 means "after this delay".
44+func (h *dumberBPH) OnScheduleMessagePumpWork(delayMs int64) {
45+ h.engine.scheduleMessagePumpWork(delayMs)
46+}
M internal/infrastructure/cef/engine.go +89 -55
@@ -2,21 +2,23 @@ package cef
22
33 import (
44 "context"
5+ "sync"
56
67 purecef "github.com/bnema/purego-cef/cef"
78 "github.com/jwijenbergh/puregotk/v4/glib"
8- "github.com/rs/zerolog"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
1112 )
1213
1314 // Compile-time interface check.
1415 var _ port.Engine = (*Engine)(nil)
1516
16-// cefPumpIntervalMS matches CEF's own kMaxTimerDelay (1000/30 ≈ 33ms) used in
17-// the reference external message pump implementation. This is a conservative
18-// fallback; Phase 2 will use OnScheduleMessagePumpWork for precise scheduling.
19-const cefPumpIntervalMS uint = 33
17+// cefMaxTimerDelayMS is the maximum delay between pump ticks.
18+// Set to 33ms (30fps) as a balance between responsiveness and CPU cost.
19+// With --disable-gpu-compositing, Chromium rasterizes in software so
20+// every pump tick has significant CPU cost.
21+const cefMaxTimerDelayMS int64 = 33
2022
2123 // Engine implements port.Engine for the CEF browser backend.
2224 // It manages the CEF lifecycle and provides access to all engine subsystems.
@@ -25,14 +27,17 @@ type Engine struct {
2527 gl *glLoader
2628 factory *WebViewFactory
2729 pool *WebViewPool
28- logger zerolog.Logger
2930
30- // Message pump state. The pump is a repeating glib.TimeoutAdd source
31- // started from OnToolkitReady (not before GTK init).
32- pumpCB glib.SourceFunc
33- pumpSourceID uint
34- pumpStarted bool
35- pumpClosing bool
31+ // Adaptive message pump state. OnScheduleMessagePumpWork schedules
32+ // one-shot glib sources; pumpSourceID tracks the pending source so it
33+ // can be cancelled when CEF requests a different delay.
34+ pumpMu sync.Mutex
35+ pumpCB glib.SourceFunc // kept alive for GC
36+ pumpSourceID uint
37+ pumpReady bool // set true once GTK main loop is running
38+ pumpClosing bool
39+ pumpHasPending bool // an OnScheduleMessagePumpWork arrived before pumpReady
40+ pumpPendingMs int64 // delay from the last pre-ready request
3641
3742 // Reentrancy guard: DoMessageLoopWork can trigger callbacks that
3843 // re-enter the pump. The reference implementation detects this and
@@ -100,11 +105,26 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
100105 }
101106
102107 // OnToolkitReady is called after the UI toolkit has initialized.
103-// This is the earliest safe point to start the CEF message pump — GTK and
104-// libadwaita are fully initialized, and the GLib main context is running.
108+// Marks the pump as ready so that OnScheduleMessagePumpWork callbacks
109+// (which may have arrived during cef_initialize) start scheduling work.
105110 func (e *Engine) OnToolkitReady(_ context.Context) error {
106- e.logger.Debug().Msg("cef: OnToolkitReady called, starting message pump")
107- e.startMessagePump()
111+ log := logging.FromContext(e.ctx)
112+ log.Debug().Msg("cef: OnToolkitReady called, pump is now ready")
113+
114+ e.pumpMu.Lock()
115+ e.pumpReady = true
116+ hasPending := e.pumpHasPending
117+ pendingMs := e.pumpPendingMs
118+ e.pumpHasPending = false
119+ e.pumpMu.Unlock()
120+
121+ // Replay any buffered OnScheduleMessagePumpWork that arrived before GTK
122+ // was ready, or kick an immediate pump if none was buffered.
123+ if hasPending {
124+ e.scheduleMessagePumpWork(pendingMs)
125+ } else {
126+ e.scheduleMessagePumpWork(0)
127+ }
108128 return nil
109129 }
110130
@@ -124,36 +144,60 @@ func (e *Engine) SetHandlerContext(ctx context.Context) {
124144 }
125145
126146 // ---------------------------------------------------------------------------
127-// CEF message pump (fixed-interval fallback, Phase 1)
147+// Adaptive CEF message pump (demand-driven via OnScheduleMessagePumpWork)
128148 // ---------------------------------------------------------------------------
129149
130-func (e *Engine) startMessagePump() {
131- if e.pumpStarted || e.pumpClosing {
150+// scheduleMessagePumpWork is called by the BrowserProcessHandler when CEF
151+// has work to process, and by performMessageLoopWork to re-schedule after
152+// each pump tick. It replaces any pending one-shot timer with a new one
153+// matching the requested delay.
154+func (e *Engine) scheduleMessagePumpWork(delayMs int64) {
155+ e.pumpMu.Lock()
156+ defer e.pumpMu.Unlock()
157+
158+ if e.pumpClosing {
159+ return
160+ }
161+
162+ // Buffer requests that arrive before the GTK main loop is running.
163+ if !e.pumpReady {
164+ e.pumpHasPending = true
165+ e.pumpPendingMs = delayMs
132166 return
133167 }
134168
169+ // Cancel any pending scheduled work.
170+ if e.pumpSourceID != 0 {
171+ glib.SourceRemove(e.pumpSourceID)
172+ e.pumpSourceID = 0
173+ }
174+
175+ // Cap the delay at the maximum tick interval.
176+ if delayMs > cefMaxTimerDelayMS {
177+ delayMs = cefMaxTimerDelayMS
178+ }
179+
180+ // The callback is one-shot (returns false).
135181 e.pumpCB = glib.SourceFunc(func(_ uintptr) bool {
136- if e.pumpClosing {
137- e.pumpSourceID = 0
138- return false // remove the source
139- }
182+ e.pumpMu.Lock()
183+ e.pumpSourceID = 0
184+ e.pumpMu.Unlock()
185+
140186 e.performMessageLoopWork()
141- return !e.pumpClosing
187+ return false
142188 })
143189
144- e.pumpSourceID = glib.TimeoutAdd(cefPumpIntervalMS, &e.pumpCB, 0)
145- if e.pumpSourceID == 0 {
146- e.logger.Error().Msg("cef: failed to install message pump timer")
147- return
190+ if delayMs <= 0 {
191+ e.pumpSourceID = glib.IdleAdd(&e.pumpCB, 0)
192+ } else {
193+ e.pumpSourceID = glib.TimeoutAdd(uint(delayMs), &e.pumpCB, 0)
148194 }
149-
150- e.pumpStarted = true
151- e.logger.Debug().
152- Uint("interval_ms", cefPumpIntervalMS).
153- Msg("cef: started message pump")
154195 }
155196
156197 func (e *Engine) stopMessagePump() {
198+ e.pumpMu.Lock()
199+ defer e.pumpMu.Unlock()
200+
157201 e.pumpClosing = true
158202 if e.pumpSourceID != 0 {
159203 glib.SourceRemove(e.pumpSourceID)
@@ -161,39 +205,29 @@ func (e *Engine) stopMessagePump() {
161205 }
162206 }
163207
164-// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.
165-// Matches the pattern from CEF's main_message_loop_external_pump.cc:
166-// if we're already inside DoMessageLoopWork, just flag reentrancy and return.
167-var pumpCallCount uint64
168-
208+// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection
209+// and re-schedules follow-up work. Matches the pattern from CEF's
210+// main_message_loop_external_pump.cc: the host must always re-schedule
211+// after each DoMessageLoopWork call because CEF does not call
212+// OnScheduleMessagePumpWork after returning from DoMessageLoopWork.
169213 func (e *Engine) performMessageLoopWork() {
170214 if e.pumpActive {
171- e.logger.Warn().Msg("cef: pump reentrancy detected, deferring")
172215 e.pumpReentry = true
173216 return
174217 }
175218
176- pumpCallCount++
177- if pumpCallCount <= 5 {
178- e.logger.Debug().Uint64("call", pumpCallCount).Msg("cef: DoMessageLoopWork enter")
179- }
180-
181219 e.pumpActive = true
182220 e.pumpReentry = false
183221 purecef.DoMessageLoopWork()
184222 e.pumpActive = false
185223
186- if pumpCallCount <= 5 {
187- e.logger.Debug().Uint64("call", pumpCallCount).Bool("reentry", e.pumpReentry).Msg("cef: DoMessageLoopWork exit")
188- }
189-
190- // If CEF re-entered during the call, do one more pass immediately.
191- // Cap at one extra pass to avoid unbounded recursion.
192224 if e.pumpReentry {
193- e.logger.Debug().Msg("cef: pump reentry follow-up pass")
194- e.pumpReentry = false
195- e.pumpActive = true
196- purecef.DoMessageLoopWork()
197- e.pumpActive = false
225+ // CEF called OnScheduleMessagePumpWork re-entrantly during
226+ // DoMessageLoopWork — schedule immediate follow-up.
227+ e.scheduleMessagePumpWork(0)
228+ } else {
229+ // No re-entrant request — schedule at the max timer delay
230+ // to keep the pump alive for pending work.
231+ e.scheduleMessagePumpWork(cefMaxTimerDelayMS)
198232 }
199233 }
@@ -2,21 +2,23 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
5 6
6 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
7 "github.com/jwijenbergh/puregotk/v4/glib"8 "github.com/jwijenbergh/puregotk/v4/glib"
8- "github.com/rs/zerolog"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
11 )12 )
12 13
13 // Compile-time interface check.14 // Compile-time interface check.
14 var _ port.Engine = (*Engine)(nil)15 var _ port.Engine = (*Engine)(nil)
15 16
16-// cefPumpIntervalMS matches CEF's own kMaxTimerDelay (1000/30 ≈ 33ms) used in17+// cefMaxTimerDelayMS is the maximum delay between pump ticks.
17-// the reference external message pump implementation. This is a conservative18+// Set to 33ms (30fps) as a balance between responsiveness and CPU cost.
18-// fallback; Phase 2 will use OnScheduleMessagePumpWork for precise scheduling.19+// With --disable-gpu-compositing, Chromium rasterizes in software so
19-const cefPumpIntervalMS uint = 3320+// every pump tick has significant CPU cost.
21+const cefMaxTimerDelayMS int64 = 33
20 22
21 // Engine implements port.Engine for the CEF browser backend.23 // Engine implements port.Engine for the CEF browser backend.
22 // It manages the CEF lifecycle and provides access to all engine subsystems.24 // It manages the CEF lifecycle and provides access to all engine subsystems.
@@ -25,14 +27,17 @@ type Engine struct {
25 gl *glLoader27 gl *glLoader
26 factory *WebViewFactory28 factory *WebViewFactory
27 pool *WebViewPool29 pool *WebViewPool
28- logger zerolog.Logger
29 30
30- // Message pump state. The pump is a repeating glib.TimeoutAdd source31+ // Adaptive message pump state. OnScheduleMessagePumpWork schedules
31- // started from OnToolkitReady (not before GTK init).32+ // one-shot glib sources; pumpSourceID tracks the pending source so it
32- pumpCB glib.SourceFunc33+ // can be cancelled when CEF requests a different delay.
33- pumpSourceID uint34+ pumpMu sync.Mutex
34- pumpStarted bool35+ pumpCB glib.SourceFunc // kept alive for GC
35- pumpClosing bool36+ pumpSourceID uint
37+ pumpReady bool // set true once GTK main loop is running
38+ pumpClosing bool
39+ pumpHasPending bool // an OnScheduleMessagePumpWork arrived before pumpReady
40+ pumpPendingMs int64 // delay from the last pre-ready request
36 41
37 // Reentrancy guard: DoMessageLoopWork can trigger callbacks that42 // Reentrancy guard: DoMessageLoopWork can trigger callbacks that
38 // re-enter the pump. The reference implementation detects this and43 // re-enter the pump. The reference implementation detects this and
@@ -100,11 +105,26 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
100 }105 }
101 106
102 // OnToolkitReady is called after the UI toolkit has initialized.107 // OnToolkitReady is called after the UI toolkit has initialized.
103-// This is the earliest safe point to start the CEF message pump — GTK and108+// Marks the pump as ready so that OnScheduleMessagePumpWork callbacks
104-// libadwaita are fully initialized, and the GLib main context is running.109+// (which may have arrived during cef_initialize) start scheduling work.
105 func (e *Engine) OnToolkitReady(_ context.Context) error {110 func (e *Engine) OnToolkitReady(_ context.Context) error {
106- e.logger.Debug().Msg("cef: OnToolkitReady called, starting message pump")111+ log := logging.FromContext(e.ctx)
107- e.startMessagePump()112+ log.Debug().Msg("cef: OnToolkitReady called, pump is now ready")
113+
114+ e.pumpMu.Lock()
115+ e.pumpReady = true
116+ hasPending := e.pumpHasPending
117+ pendingMs := e.pumpPendingMs
118+ e.pumpHasPending = false
119+ e.pumpMu.Unlock()
120+
121+ // Replay any buffered OnScheduleMessagePumpWork that arrived before GTK
122+ // was ready, or kick an immediate pump if none was buffered.
123+ if hasPending {
124+ e.scheduleMessagePumpWork(pendingMs)
125+ } else {
126+ e.scheduleMessagePumpWork(0)
127+ }
108 return nil128 return nil
109 }129 }
110 130
@@ -124,36 +144,60 @@ func (e *Engine) SetHandlerContext(ctx context.Context) {
124 }144 }
125 145
126 // ---------------------------------------------------------------------------146 // ---------------------------------------------------------------------------
127-// CEF message pump (fixed-interval fallback, Phase 1)147+// Adaptive CEF message pump (demand-driven via OnScheduleMessagePumpWork)
128 // ---------------------------------------------------------------------------148 // ---------------------------------------------------------------------------
129 149
130-func (e *Engine) startMessagePump() {150+// scheduleMessagePumpWork is called by the BrowserProcessHandler when CEF
131- if e.pumpStarted || e.pumpClosing {151+// has work to process, and by performMessageLoopWork to re-schedule after
152+// each pump tick. It replaces any pending one-shot timer with a new one
153+// matching the requested delay.
154+func (e *Engine) scheduleMessagePumpWork(delayMs int64) {
155+ e.pumpMu.Lock()
156+ defer e.pumpMu.Unlock()
157+
158+ if e.pumpClosing {
159+ return
160+ }
161+
162+ // Buffer requests that arrive before the GTK main loop is running.
163+ if !e.pumpReady {
164+ e.pumpHasPending = true
165+ e.pumpPendingMs = delayMs
132 return166 return
133 }167 }
134 168
169+ // Cancel any pending scheduled work.
170+ if e.pumpSourceID != 0 {
171+ glib.SourceRemove(e.pumpSourceID)
172+ e.pumpSourceID = 0
173+ }
174+
175+ // Cap the delay at the maximum tick interval.
176+ if delayMs > cefMaxTimerDelayMS {
177+ delayMs = cefMaxTimerDelayMS
178+ }
179+
180+ // The callback is one-shot (returns false).
135 e.pumpCB = glib.SourceFunc(func(_ uintptr) bool {181 e.pumpCB = glib.SourceFunc(func(_ uintptr) bool {
136- if e.pumpClosing {182+ e.pumpMu.Lock()
137- e.pumpSourceID = 0183+ e.pumpSourceID = 0
138- return false // remove the source184+ e.pumpMu.Unlock()
139- }185+
140 e.performMessageLoopWork()186 e.performMessageLoopWork()
141- return !e.pumpClosing187+ return false
142 })188 })
143 189
144- e.pumpSourceID = glib.TimeoutAdd(cefPumpIntervalMS, &e.pumpCB, 0)190+ if delayMs <= 0 {
145- if e.pumpSourceID == 0 {191+ e.pumpSourceID = glib.IdleAdd(&e.pumpCB, 0)
146- e.logger.Error().Msg("cef: failed to install message pump timer")192+ } else {
147- return193+ e.pumpSourceID = glib.TimeoutAdd(uint(delayMs), &e.pumpCB, 0)
148 }194 }
149-
150- e.pumpStarted = true
151- e.logger.Debug().
152- Uint("interval_ms", cefPumpIntervalMS).
153- Msg("cef: started message pump")
154 }195 }
155 196
156 func (e *Engine) stopMessagePump() {197 func (e *Engine) stopMessagePump() {
198+ e.pumpMu.Lock()
199+ defer e.pumpMu.Unlock()
200+
157 e.pumpClosing = true201 e.pumpClosing = true
158 if e.pumpSourceID != 0 {202 if e.pumpSourceID != 0 {
159 glib.SourceRemove(e.pumpSourceID)203 glib.SourceRemove(e.pumpSourceID)
@@ -161,39 +205,29 @@ func (e *Engine) stopMessagePump() {
161 }205 }
162 }206 }
163 207
164-// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.208+// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection
165-// Matches the pattern from CEF's main_message_loop_external_pump.cc:209+// and re-schedules follow-up work. Matches the pattern from CEF's
166-// if we're already inside DoMessageLoopWork, just flag reentrancy and return.210+// main_message_loop_external_pump.cc: the host must always re-schedule
167-var pumpCallCount uint64211+// after each DoMessageLoopWork call because CEF does not call
168-212+// OnScheduleMessagePumpWork after returning from DoMessageLoopWork.
169 func (e *Engine) performMessageLoopWork() {213 func (e *Engine) performMessageLoopWork() {
170 if e.pumpActive {214 if e.pumpActive {
171- e.logger.Warn().Msg("cef: pump reentrancy detected, deferring")
172 e.pumpReentry = true215 e.pumpReentry = true
173 return216 return
174 }217 }
175 218
176- pumpCallCount++
177- if pumpCallCount <= 5 {
178- e.logger.Debug().Uint64("call", pumpCallCount).Msg("cef: DoMessageLoopWork enter")
179- }
180-
181 e.pumpActive = true219 e.pumpActive = true
182 e.pumpReentry = false220 e.pumpReentry = false
183 purecef.DoMessageLoopWork()221 purecef.DoMessageLoopWork()
184 e.pumpActive = false222 e.pumpActive = false
185 223
186- if pumpCallCount <= 5 {
187- e.logger.Debug().Uint64("call", pumpCallCount).Bool("reentry", e.pumpReentry).Msg("cef: DoMessageLoopWork exit")
188- }
189-
190- // If CEF re-entered during the call, do one more pass immediately.
191- // Cap at one extra pass to avoid unbounded recursion.
192 if e.pumpReentry {224 if e.pumpReentry {
193- e.logger.Debug().Msg("cef: pump reentry follow-up pass")225+ // CEF called OnScheduleMessagePumpWork re-entrantly during
194- e.pumpReentry = false226+ // DoMessageLoopWork — schedule immediate follow-up.
195- e.pumpActive = true227+ e.scheduleMessagePumpWork(0)
196- purecef.DoMessageLoopWork()228+ } else {
197- e.pumpActive = false229+ // No re-entrant request — schedule at the max timer delay
230+ // to keep the pump alive for pending work.
231+ e.scheduleMessagePumpWork(cefMaxTimerDelayMS)
198 }232 }
199 }233 }
M internal/infrastructure/cef/engine_init.go +24 -20
@@ -7,13 +7,14 @@ import (
77 "path/filepath"
88
99 purecef "github.com/bnema/purego-cef/cef"
10- "github.com/rs/zerolog"
1110
1211 "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/logging"
1313 )
1414
1515 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
16-func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.Logger) (*Engine, error) {
16+func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
17+ logger := logging.FromContext(ctx)
1718 // 1. Initialize CEF.
1819 settings := purecef.DefaultSettings()
1920 if cfg.CEFDir != "" {
@@ -46,41 +47,44 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.L
4647 // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
4748 // does not survive fork cleanly, causing renderer crashes. Without zygote,
4849 // CEF launches each subprocess as a fresh exec of the helper binary.
50+ // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
51+ // does not survive fork cleanly, causing renderer crashes. Without zygote,
52+ // CEF launches each subprocess as a fresh exec of the helper binary.
4953 savedArgs := os.Args
50- os.Args = appendIfMissing(os.Args, "--in-process-gpu")
5154 os.Args = appendIfMissing(os.Args, "--no-zygote")
5255
53- logger.Debug().Msg("cef: calling Init")
54- if err := purecef.Init(settings); err != nil {
56+ // 2. Build the engine early so the App can reference it for the pump callback.
57+ eng := &Engine{
58+ ctx: ctx,
59+ }
60+
61+ // Create the CEF App with a BrowserProcessHandler that drives the
62+ // adaptive message pump via OnScheduleMessagePumpWork.
63+ app := newDumberApp(eng)
64+
65+ logger.Debug().Msg("cef: calling InitWithApp")
66+ if err := purecef.InitWithApp(settings, app); err != nil {
5567 os.Args = savedArgs
56- return nil, fmt.Errorf("cef.Init: %w", err)
68+ return nil, fmt.Errorf("cef.InitWithApp: %w", err)
5769 }
5870 os.Args = savedArgs
59- logger.Debug().Msg("cef: Init returned OK")
71+ logger.Debug().Msg("cef: InitWithApp returned OK")
6072
61- // 2. Load GL.
73+ // 3. Load GL.
6274 gl, err := newGLLoader()
6375 if err != nil {
6476 purecef.Shutdown()
6577 return nil, fmt.Errorf("GL loader: %w", err)
6678 }
6779
68- // 3. Create factory + pool.
80+ // 4. Create factory + pool and wire them into the engine.
6981 scale := int32(1) // TODO: detect from GDK
7082 factory := newWebViewFactory(gl, scale)
7183 pool := newWebViewPool(factory)
7284
73- // 4. Build the engine. The CEF message pump is started later from
74- // OnToolkitReady, after GTK/libadwaita have fully initialized.
75- // Starting it here (before the GTK main loop) causes a SIGSEGV with
76- // --in-process-gpu because Chromium GPU bootstrap runs too early.
77- eng := &Engine{
78- ctx: ctx,
79- gl: gl,
80- factory: factory,
81- pool: pool,
82- logger: logger,
83- }
85+ eng.gl = gl
86+ eng.factory = factory
87+ eng.pool = pool
8488
8589 return eng, nil
8690 }
@@ -7,13 +7,14 @@ import (
7 "path/filepath"7 "path/filepath"
8 8
9 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
10- "github.com/rs/zerolog"
11 10
12 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/logging"
13 )13 )
14 14
15 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.15 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
16-func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.Logger) (*Engine, error) {16+func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
17+ logger := logging.FromContext(ctx)
17 // 1. Initialize CEF.18 // 1. Initialize CEF.
18 settings := purecef.DefaultSettings()19 settings := purecef.DefaultSettings()
19 if cfg.CEFDir != "" {20 if cfg.CEFDir != "" {
@@ -46,41 +47,44 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.L
46 // --no-zygote: Disable the zygote process which uses fork(). Go's runtime47 // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
47 // does not survive fork cleanly, causing renderer crashes. Without zygote,48 // does not survive fork cleanly, causing renderer crashes. Without zygote,
48 // CEF launches each subprocess as a fresh exec of the helper binary.49 // CEF launches each subprocess as a fresh exec of the helper binary.
50+ // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
51+ // does not survive fork cleanly, causing renderer crashes. Without zygote,
52+ // CEF launches each subprocess as a fresh exec of the helper binary.
49 savedArgs := os.Args53 savedArgs := os.Args
50- os.Args = appendIfMissing(os.Args, "--in-process-gpu")
51 os.Args = appendIfMissing(os.Args, "--no-zygote")54 os.Args = appendIfMissing(os.Args, "--no-zygote")
52 55
53- logger.Debug().Msg("cef: calling Init")56+ // 2. Build the engine early so the App can reference it for the pump callback.
54- if err := purecef.Init(settings); err != nil {57+ eng := &Engine{
58+ ctx: ctx,
59+ }
60+
61+ // Create the CEF App with a BrowserProcessHandler that drives the
62+ // adaptive message pump via OnScheduleMessagePumpWork.
63+ app := newDumberApp(eng)
64+
65+ logger.Debug().Msg("cef: calling InitWithApp")
66+ if err := purecef.InitWithApp(settings, app); err != nil {
55 os.Args = savedArgs67 os.Args = savedArgs
56- return nil, fmt.Errorf("cef.Init: %w", err)68+ return nil, fmt.Errorf("cef.InitWithApp: %w", err)
57 }69 }
58 os.Args = savedArgs70 os.Args = savedArgs
59- logger.Debug().Msg("cef: Init returned OK")71+ logger.Debug().Msg("cef: InitWithApp returned OK")
60 72
61- // 2. Load GL.73+ // 3. Load GL.
62 gl, err := newGLLoader()74 gl, err := newGLLoader()
63 if err != nil {75 if err != nil {
64 purecef.Shutdown()76 purecef.Shutdown()
65 return nil, fmt.Errorf("GL loader: %w", err)77 return nil, fmt.Errorf("GL loader: %w", err)
66 }78 }
67 79
68- // 3. Create factory + pool.80+ // 4. Create factory + pool and wire them into the engine.
69 scale := int32(1) // TODO: detect from GDK81 scale := int32(1) // TODO: detect from GDK
70 factory := newWebViewFactory(gl, scale)82 factory := newWebViewFactory(gl, scale)
71 pool := newWebViewPool(factory)83 pool := newWebViewPool(factory)
72 84
73- // 4. Build the engine. The CEF message pump is started later from85+ eng.gl = gl
74- // OnToolkitReady, after GTK/libadwaita have fully initialized.86+ eng.factory = factory
75- // Starting it here (before the GTK main loop) causes a SIGSEGV with87+ eng.pool = pool
76- // --in-process-gpu because Chromium GPU bootstrap runs too early.
77- eng := &Engine{
78- ctx: ctx,
79- gl: gl,
80- factory: factory,
81- pool: pool,
82- logger: logger,
83- }
84 88
85 return eng, nil89 return eng, nil
86 }90 }
M internal/infrastructure/cef/factory.go +2 -1
@@ -36,13 +36,14 @@ func newWebViewFactory(gl *glLoader, scale int32) *WebViewFactory {
3636 // Create builds a new CEF off-screen browser WebView. The browser is created
3737 // asynchronously; the returned WebView is usable immediately but navigation
3838 // will fail with errNoBrowser until OnAfterCreated fires.
39-func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) {
39+func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
4040 id := port.WebViewID(f.nextID.Add(1))
4141
4242 pipeline := newRenderPipeline(f.gl, f.scale)
4343
4444 wv := &WebView{
4545 id: id,
46+ ctx: ctx,
4647 pipeline: pipeline,
4748 }
4849
@@ -36,13 +36,14 @@ func newWebViewFactory(gl *glLoader, scale int32) *WebViewFactory {
36 // Create builds a new CEF off-screen browser WebView. The browser is created36 // Create builds a new CEF off-screen browser WebView. The browser is created
37 // asynchronously; the returned WebView is usable immediately but navigation37 // asynchronously; the returned WebView is usable immediately but navigation
38 // will fail with errNoBrowser until OnAfterCreated fires.38 // will fail with errNoBrowser until OnAfterCreated fires.
39-func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) {39+func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
40 id := port.WebViewID(f.nextID.Add(1))40 id := port.WebViewID(f.nextID.Add(1))
41 41
42 pipeline := newRenderPipeline(f.gl, f.scale)42 pipeline := newRenderPipeline(f.gl, f.scale)
43 43
44 wv := &WebView{44 wv := &WebView{
45 id: id,45 id: id,
46+ ctx: ctx,
46 pipeline: pipeline,47 pipeline: pipeline,
47 }48 }
48 49
M internal/infrastructure/cef/gl_loader.go +8 -0
@@ -34,6 +34,8 @@ const (
3434 glClampToEdge = 0x812F
3535 glTextureWrapS = 0x2802
3636 glTextureWrapT = 0x2803
37+
38+ glUnpackRowLength = 0x0CF2
3739 )
3840
3941 // glLoader holds dynamically loaded OpenGL function pointers.
@@ -82,6 +84,9 @@ type glLoader struct {
8284 vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr)
8385 enableVertexAttribArray func(index uint32)
8486
87+ // Pixel store
88+ pixelStorei func(pname uint32, param int32)
89+
8590 // Draw / state functions
8691 clear func(mask uint32)
8792 clearColor func(red float32, green float32, blue float32, alpha float32)
@@ -151,6 +156,9 @@ func (gl *glLoader) registerAll() (retErr error) {
151156 purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer")
152157 purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray")
153158
159+ // Pixel store
160+ purego.RegisterLibFunc(&gl.pixelStorei, gl.handle, "glPixelStorei")
161+
154162 // Draw / state
155163 purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear")
156164 purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor")
@@ -34,6 +34,8 @@ const (
34 glClampToEdge = 0x812F34 glClampToEdge = 0x812F
35 glTextureWrapS = 0x280235 glTextureWrapS = 0x2802
36 glTextureWrapT = 0x280336 glTextureWrapT = 0x2803
37+
38+ glUnpackRowLength = 0x0CF2
37 )39 )
38 40
39 // glLoader holds dynamically loaded OpenGL function pointers.41 // glLoader holds dynamically loaded OpenGL function pointers.
@@ -82,6 +84,9 @@ type glLoader struct {
82 vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr)84 vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr)
83 enableVertexAttribArray func(index uint32)85 enableVertexAttribArray func(index uint32)
84 86
87+ // Pixel store
88+ pixelStorei func(pname uint32, param int32)
89+
85 // Draw / state functions90 // Draw / state functions
86 clear func(mask uint32)91 clear func(mask uint32)
87 clearColor func(red float32, green float32, blue float32, alpha float32)92 clearColor func(red float32, green float32, blue float32, alpha float32)
@@ -151,6 +156,9 @@ func (gl *glLoader) registerAll() (retErr error) {
151 purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer")156 purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer")
152 purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray")157 purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray")
153 158
159+ // Pixel store
160+ purego.RegisterLibFunc(&gl.pixelStorei, gl.handle, "glPixelStorei")
161+
154 // Draw / state162 // Draw / state
155 purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear")163 purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear")
156 purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor")164 purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor")
M internal/infrastructure/cef/input_bridge.go +43 -1
@@ -17,6 +17,10 @@ type inputBridge struct {
1717 host purecef.BrowserHost
1818 scale int32
1919 mu sync.Mutex
20+
21+ // Last known pointer position, used for scroll events which don't
22+ // carry their own coordinates from GDK.
23+ lastX, lastY float64
2024 }
2125
2226 // newInputBridge creates an input bridge with the given HiDPI scale factor.
@@ -75,6 +79,19 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
7579
7680 glArea.AddController(&scroll.EventController)
7781
82+ // Focus — forward to CEF so it knows when it has/loses keyboard focus.
83+ focus := gtk.NewEventControllerFocus()
84+ focusEnterCb := func(_ gtk.EventControllerFocus) {
85+ ib.onFocusIn()
86+ }
87+ focus.ConnectEnter(&focusEnterCb)
88+ focusLeaveCb := func(_ gtk.EventControllerFocus) {
89+ ib.onFocusOut()
90+ }
91+ focus.ConnectLeave(&focusLeaveCb)
92+
93+ glArea.AddController(&focus.EventController)
94+
7895 // Keyboard
7996 key := gtk.NewEventControllerKey()
8097 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
@@ -102,6 +119,10 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
102119 func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
103120 ib.mu.Lock()
104121 host := ib.host
122+ if !leave {
123+ ib.lastX = x
124+ ib.lastY = y
125+ }
105126 ib.mu.Unlock()
106127 if host == nil {
107128 return
@@ -144,16 +165,37 @@ func (ib *inputBridge) onMouseRelease(x, y float64, button, mods uint, clickCoun
144165 func (ib *inputBridge) onScroll(dx, dy float64) {
145166 ib.mu.Lock()
146167 host := ib.host
168+ x, y := ib.lastX, ib.lastY
147169 ib.mu.Unlock()
148170 if host == nil {
149171 return
150172 }
151173
152- evt := buildMouseEvent(0, 0, 0, ib.scale)
174+ evt := buildMouseEvent(x, y, 0, ib.scale)
153175 deltaX, deltaY := translateScrollDeltas(dx, dy)
154176 host.SendMouseWheelEvent(&evt, deltaX, deltaY)
155177 }
156178
179+func (ib *inputBridge) onFocusIn() {
180+ ib.mu.Lock()
181+ host := ib.host
182+ ib.mu.Unlock()
183+ if host == nil {
184+ return
185+ }
186+ host.SetFocus(1)
187+}
188+
189+func (ib *inputBridge) onFocusOut() {
190+ ib.mu.Lock()
191+ host := ib.host
192+ ib.mu.Unlock()
193+ if host == nil {
194+ return
195+ }
196+ host.SetFocus(0)
197+}
198+
157199 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
158200 ib.mu.Lock()
159201 host := ib.host
@@ -17,6 +17,10 @@ type inputBridge struct {
17 host purecef.BrowserHost17 host purecef.BrowserHost
18 scale int3218 scale int32
19 mu sync.Mutex19 mu sync.Mutex
20+
21+ // Last known pointer position, used for scroll events which don't
22+ // carry their own coordinates from GDK.
23+ lastX, lastY float64
20 }24 }
21 25
22 // newInputBridge creates an input bridge with the given HiDPI scale factor.26 // newInputBridge creates an input bridge with the given HiDPI scale factor.
@@ -75,6 +79,19 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
75 79
76 glArea.AddController(&scroll.EventController)80 glArea.AddController(&scroll.EventController)
77 81
82+ // Focus — forward to CEF so it knows when it has/loses keyboard focus.
83+ focus := gtk.NewEventControllerFocus()
84+ focusEnterCb := func(_ gtk.EventControllerFocus) {
85+ ib.onFocusIn()
86+ }
87+ focus.ConnectEnter(&focusEnterCb)
88+ focusLeaveCb := func(_ gtk.EventControllerFocus) {
89+ ib.onFocusOut()
90+ }
91+ focus.ConnectLeave(&focusLeaveCb)
92+
93+ glArea.AddController(&focus.EventController)
94+
78 // Keyboard95 // Keyboard
79 key := gtk.NewEventControllerKey()96 key := gtk.NewEventControllerKey()
80 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {97 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
@@ -102,6 +119,10 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
102 func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {119 func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
103 ib.mu.Lock()120 ib.mu.Lock()
104 host := ib.host121 host := ib.host
122+ if !leave {
123+ ib.lastX = x
124+ ib.lastY = y
125+ }
105 ib.mu.Unlock()126 ib.mu.Unlock()
106 if host == nil {127 if host == nil {
107 return128 return
@@ -144,16 +165,37 @@ func (ib *inputBridge) onMouseRelease(x, y float64, button, mods uint, clickCoun
144 func (ib *inputBridge) onScroll(dx, dy float64) {165 func (ib *inputBridge) onScroll(dx, dy float64) {
145 ib.mu.Lock()166 ib.mu.Lock()
146 host := ib.host167 host := ib.host
168+ x, y := ib.lastX, ib.lastY
147 ib.mu.Unlock()169 ib.mu.Unlock()
148 if host == nil {170 if host == nil {
149 return171 return
150 }172 }
151 173
152- evt := buildMouseEvent(0, 0, 0, ib.scale)174+ evt := buildMouseEvent(x, y, 0, ib.scale)
153 deltaX, deltaY := translateScrollDeltas(dx, dy)175 deltaX, deltaY := translateScrollDeltas(dx, dy)
154 host.SendMouseWheelEvent(&evt, deltaX, deltaY)176 host.SendMouseWheelEvent(&evt, deltaX, deltaY)
155 }177 }
156 178
179+func (ib *inputBridge) onFocusIn() {
180+ ib.mu.Lock()
181+ host := ib.host
182+ ib.mu.Unlock()
183+ if host == nil {
184+ return
185+ }
186+ host.SetFocus(1)
187+}
188+
189+func (ib *inputBridge) onFocusOut() {
190+ ib.mu.Lock()
191+ host := ib.host
192+ ib.mu.Unlock()
193+ if host == nil {
194+ return
195+ }
196+ host.SetFocus(0)
197+}
198+
157 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {199 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
158 ib.mu.Lock()200 ib.mu.Lock()
159 host := ib.host201 host := ib.host
M internal/infrastructure/cef/render_pipeline.go +15 -20
@@ -1,8 +1,6 @@
11 package cef
22
33 import (
4- "fmt"
5- "os"
64 "sync"
75 "unsafe"
86
@@ -150,7 +148,11 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
150148 }
151149 }
152150
153- rp.dirtyRects = append(rp.dirtyRects[:0], rects...)
151+ // Accumulate dirty rects across multiple OnPaint calls between renders.
152+ // If we replaced instead of appending, paints that arrive before the
153+ // next GL render would lose their dirty rect metadata — the pixels are
154+ // in staging but never uploaded to the texture, causing trails/ghosts.
155+ rp.dirtyRects = append(rp.dirtyRects, rects...)
154156 rp.needsUpload = true
155157
156158 rp.mu.Unlock()
@@ -159,8 +161,6 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
159161 }
160162
161163 // onGLRender is the GTK "render" signal handler. GL context is current.
162-var glRenderCount uint64
163-
164164 func (rp *renderPipeline) onGLRender() bool {
165165 rp.mu.Lock()
166166
@@ -168,17 +168,12 @@ func (rp *renderPipeline) onGLRender() bool {
168168 sizeChanged := rp.sizeChanged
169169 rp.needsUpload = false
170170
171- glRenderCount++
172- if glRenderCount <= 5 {
173- fmt.Fprintf(os.Stderr, "cef-debug: onGLRender call=%d needsUpload=%v sizeChanged=%v glReady=%v\n",
174- glRenderCount, needsUpload, sizeChanged, rp.glReady)
175- }
176-
177- // Snapshot dirty rects for upload.
171+ // Snapshot dirty rects for upload and clear the accumulator.
178172 var dirtyRects []rect
179173 if needsUpload {
180174 dirtyRects = make([]rect, len(rp.dirtyRects))
181175 copy(dirtyRects, rp.dirtyRects)
176+ rp.dirtyRects = rp.dirtyRects[:0]
182177 }
183178
184179 rp.mu.Unlock()
@@ -258,16 +253,16 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) {
258253 if fullUpload {
259254 gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil)
260255 } else {
256+ // Use GL_UNPACK_ROW_LENGTH so GL understands the PBO's full-width
257+ // stride, allowing one glTexSubImage2D per dirty rect instead of
258+ // one per row.
259+ gl.pixelStorei(glUnpackRowLength, w)
261260 for _, r := range dirtyRects {
262- // For sub-rect uploads with stride mismatches we must upload row by row
263- // because glTexSubImage2D with a PBO reads contiguous memory from the
264- // offset, but our rows in the PBO follow the full-width stride.
265- for row := r.Y; row < r.Y+r.Height; row++ {
266- rowOffset := uintptr(int(row)*stride + int(r.X)*4)
267- gl.texSubImage2D(glTexture2D, 0, r.X, row, r.Width, 1, glBGRA, glUnsignedByte,
268- pboOffset(rowOffset))
269- }
261+ offset := uintptr(int(r.Y)*stride + int(r.X)*4)
262+ gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
263+ glBGRA, glUnsignedByte, pboOffset(offset))
270264 }
265+ gl.pixelStorei(glUnpackRowLength, 0)
271266 }
272267 gl.bindTexture(glTexture2D, 0)
273268
@@ -1,8 +1,6 @@
1 package cef1 package cef
2 2
3 import (3 import (
4- "fmt"
5- "os"
6 "sync"4 "sync"
7 "unsafe"5 "unsafe"
8 6
@@ -150,7 +148,11 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
150 }148 }
151 }149 }
152 150
153- rp.dirtyRects = append(rp.dirtyRects[:0], rects...)151+ // Accumulate dirty rects across multiple OnPaint calls between renders.
152+ // If we replaced instead of appending, paints that arrive before the
153+ // next GL render would lose their dirty rect metadata — the pixels are
154+ // in staging but never uploaded to the texture, causing trails/ghosts.
155+ rp.dirtyRects = append(rp.dirtyRects, rects...)
154 rp.needsUpload = true156 rp.needsUpload = true
155 157
156 rp.mu.Unlock()158 rp.mu.Unlock()
@@ -159,8 +161,6 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
159 }161 }
160 162
161 // onGLRender is the GTK "render" signal handler. GL context is current.163 // onGLRender is the GTK "render" signal handler. GL context is current.
162-var glRenderCount uint64
163-
164 func (rp *renderPipeline) onGLRender() bool {164 func (rp *renderPipeline) onGLRender() bool {
165 rp.mu.Lock()165 rp.mu.Lock()
166 166
@@ -168,17 +168,12 @@ func (rp *renderPipeline) onGLRender() bool {
168 sizeChanged := rp.sizeChanged168 sizeChanged := rp.sizeChanged
169 rp.needsUpload = false169 rp.needsUpload = false
170 170
171- glRenderCount++171+ // Snapshot dirty rects for upload and clear the accumulator.
172- if glRenderCount <= 5 {
173- fmt.Fprintf(os.Stderr, "cef-debug: onGLRender call=%d needsUpload=%v sizeChanged=%v glReady=%v\n",
174- glRenderCount, needsUpload, sizeChanged, rp.glReady)
175- }
176-
177- // Snapshot dirty rects for upload.
178 var dirtyRects []rect172 var dirtyRects []rect
179 if needsUpload {173 if needsUpload {
180 dirtyRects = make([]rect, len(rp.dirtyRects))174 dirtyRects = make([]rect, len(rp.dirtyRects))
181 copy(dirtyRects, rp.dirtyRects)175 copy(dirtyRects, rp.dirtyRects)
176+ rp.dirtyRects = rp.dirtyRects[:0]
182 }177 }
183 178
184 rp.mu.Unlock()179 rp.mu.Unlock()
@@ -258,16 +253,16 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) {
258 if fullUpload {253 if fullUpload {
259 gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil)254 gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil)
260 } else {255 } else {
256+ // Use GL_UNPACK_ROW_LENGTH so GL understands the PBO's full-width
257+ // stride, allowing one glTexSubImage2D per dirty rect instead of
258+ // one per row.
259+ gl.pixelStorei(glUnpackRowLength, w)
261 for _, r := range dirtyRects {260 for _, r := range dirtyRects {
262- // For sub-rect uploads with stride mismatches we must upload row by row261+ offset := uintptr(int(r.Y)*stride + int(r.X)*4)
263- // because glTexSubImage2D with a PBO reads contiguous memory from the262+ gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
264- // offset, but our rows in the PBO follow the full-width stride.263+ glBGRA, glUnsignedByte, pboOffset(offset))
265- for row := r.Y; row < r.Y+r.Height; row++ {
266- rowOffset := uintptr(int(row)*stride + int(r.X)*4)
267- gl.texSubImage2D(glTexture2D, 0, r.X, row, r.Width, 1, glBGRA, glUnsignedByte,
268- pboOffset(rowOffset))
269- }
270 }264 }
265+ gl.pixelStorei(glUnpackRowLength, 0)
271 }266 }
272 gl.bindTexture(glTexture2D, 0)267 gl.bindTexture(glTexture2D, 0)
273 268
M internal/infrastructure/cef/webview.go +1 -0
@@ -29,6 +29,7 @@ var errNoBrowser = errors.New("cef: browser not yet created")
2929 // through a renderPipeline and driven by an inputBridge.
3030 type WebView struct {
3131 id port.WebViewID
32+ ctx context.Context
3233 browser purecef.Browser
3334 host purecef.BrowserHost
3435 client purecef.Client // prevent GC from collecting the client before CEF AddRef's it
@@ -29,6 +29,7 @@ var errNoBrowser = errors.New("cef: browser not yet created")
29 // through a renderPipeline and driven by an inputBridge.29 // through a renderPipeline and driven by an inputBridge.
30 type WebView struct {30 type WebView struct {
31 id port.WebViewID31 id port.WebViewID
32+ ctx context.Context
32 browser purecef.Browser33 browser purecef.Browser
33 host purecef.BrowserHost34 host purecef.BrowserHost
34 client purecef.Client // prevent GC from collecting the client before CEF AddRef's it35 client purecef.Client // prevent GC from collecting the client before CEF AddRef's it
M internal/infrastructure/cef/webview_handlers.go +81 -21
@@ -1,13 +1,12 @@
11 package cef
22
33 import (
4- "fmt"
5- "os"
64 "unsafe"
75
86 purecef "github.com/bnema/purego-cef/cef"
97
108 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
1110 )
1211
1312 // handlerSet implements all CEF handler interfaces and dispatches events to the
@@ -65,8 +64,6 @@ func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32
6564 // GetViewRect fills the rect struct with the pipeline dimensions.
6665 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.
6766 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.
68-var getViewRectCount uint64
69-
7067 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
7168 if rect == nil {
7269 return
@@ -85,11 +82,6 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
8582 ht = 1
8683 }
8784
88- getViewRectCount++
89- if getViewRectCount <= 5 {
90- fmt.Fprintf(os.Stderr, "cef-debug: GetViewRect call=%d w=%d h=%d\n", getViewRectCount, w, ht)
91- }
92-
9385 rect.X = 0
9486 rect.Y = 0
9587 rect.Width = w
@@ -108,17 +100,10 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
108100
109101 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
110102 // to the render pipeline for GPU upload.
111-var onPaintCount uint64
112-
113103 func (h *handlerSet) OnPaint(
114104 _ purecef.Browser, _ purecef.PaintElementType,
115105 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
116106 ) {
117- onPaintCount++
118- if onPaintCount <= 3 {
119- fmt.Fprintf(os.Stderr, "cef-debug: OnPaint call=%d w=%d h=%d dirtyRects=%d buffer_nil=%v\n",
120- onPaintCount, width, height, len(dirtyRects), buffer == nil)
121- }
122107 rects := make([]rect, len(dirtyRects))
123108 for i, dr := range dirtyRects {
124109 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -209,8 +194,10 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64
209194 h.wv.updateProgress(progress)
210195 }
211196
212-func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, _ purecef.CursorType, _ *purecef.CursorInfo) int32 {
213- return 0
197+func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {
198+ name := cefCursorToGDKName(cursorType)
199+ h.wv.pipeline.glArea.SetCursorFromName(&name)
200+ return 1 // handled
214201 }
215202
216203 func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
@@ -258,8 +245,12 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
258245
259246 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.
260247 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
261- fmt.Fprintf(os.Stderr, "cef-debug: OnLoadEnd frame_nil=%v is_main=%v http_status=%d\n",
262- frame == nil, frame != nil && frame.IsMain(), httpStatusCode)
248+ log := logging.FromContext(h.wv.ctx)
249+ log.Debug().
250+ Bool("frame_nil", frame == nil).
251+ Bool("is_main", frame != nil && frame.IsMain()).
252+ Int32("http_status", httpStatusCode).
253+ Msg("cef: OnLoadEnd")
263254 if frame == nil || !frame.IsMain() {
264255 return
265256 }
@@ -306,7 +297,10 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
306297
307298 // OnAfterCreated stores the browser and host references and enables input.
308299 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
309- fmt.Fprintf(os.Stderr, "cef-debug: OnAfterCreated browser_nil=%v\n", browser == nil)
300+ log := logging.FromContext(h.wv.ctx)
301+ log.Debug().
302+ Bool("browser_nil", browser == nil).
303+ Msg("cef: OnAfterCreated")
310304 h.wv.browser = browser
311305 h.wv.host = browser.GetHost()
312306 h.wv.input.setHost(h.wv.host)
@@ -420,3 +414,69 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
420414 }
421415
422416 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
417+
418+// cefCursorToGDKName maps CEF cursor types to GDK/CSS cursor names.
419+//
420+//nolint:mnd,cyclop // cursor type lookup table
421+func cefCursorToGDKName(ct purecef.CursorType) string {
422+ switch ct {
423+ case purecef.CursorTypeCtPointer:
424+ return "default"
425+ case purecef.CursorTypeCtCross:
426+ return "crosshair"
427+ case purecef.CursorTypeCtHand:
428+ return "pointer"
429+ case purecef.CursorTypeCtIbeam:
430+ return "text"
431+ case purecef.CursorTypeCtWait:
432+ return "wait"
433+ case purecef.CursorTypeCtHelp:
434+ return "help"
435+ case purecef.CursorTypeCtEastresize:
436+ return "e-resize"
437+ case purecef.CursorTypeCtNorthresize:
438+ return "n-resize"
439+ case purecef.CursorTypeCtNortheastresize:
440+ return "ne-resize"
441+ case purecef.CursorTypeCtNorthwestresize:
442+ return "nw-resize"
443+ case purecef.CursorTypeCtSouthresize:
444+ return "s-resize"
445+ case purecef.CursorTypeCtSoutheastresize:
446+ return "se-resize"
447+ case purecef.CursorTypeCtSouthwestresize:
448+ return "sw-resize"
449+ case purecef.CursorTypeCtWestresize:
450+ return "w-resize"
451+ case purecef.CursorTypeCtNorthsouthresize:
452+ return "ns-resize"
453+ case purecef.CursorTypeCtEastwestresize:
454+ return "ew-resize"
455+ case purecef.CursorTypeCtNortheastsouthwestresize:
456+ return "nesw-resize"
457+ case purecef.CursorTypeCtNorthwestsoutheastresize:
458+ return "nwse-resize"
459+ case purecef.CursorTypeCtColumnresize:
460+ return "col-resize"
461+ case purecef.CursorTypeCtRowresize:
462+ return "row-resize"
463+ case purecef.CursorTypeCtMove:
464+ return "move"
465+ case purecef.CursorTypeCtProgress:
466+ return "progress"
467+ case purecef.CursorTypeCtNodrop:
468+ return "no-drop"
469+ case purecef.CursorTypeCtNotallowed:
470+ return "not-allowed"
471+ case purecef.CursorTypeCtGrab:
472+ return "grab"
473+ case purecef.CursorTypeCtGrabbing:
474+ return "grabbing"
475+ case purecef.CursorTypeCtZoomin:
476+ return "zoom-in"
477+ case purecef.CursorTypeCtZoomout:
478+ return "zoom-out"
479+ default:
480+ return "default"
481+ }
482+}
@@ -1,13 +1,12 @@
1 package cef1 package cef
2 2
3 import (3 import (
4- "fmt"
5- "os"
6 "unsafe"4 "unsafe"
7 5
8 purecef "github.com/bnema/purego-cef/cef"6 purecef "github.com/bnema/purego-cef/cef"
9 7
10 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
11 )10 )
12 11
13 // handlerSet implements all CEF handler interfaces and dispatches events to the12 // handlerSet implements all CEF handler interfaces and dispatches events to the
@@ -65,8 +64,6 @@ func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32
65 // GetViewRect fills the rect struct with the pipeline dimensions.64 // GetViewRect fills the rect struct with the pipeline dimensions.
66 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.65 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.
67 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.66 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.
68-var getViewRectCount uint64
69-
70 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {67 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
71 if rect == nil {68 if rect == nil {
72 return69 return
@@ -85,11 +82,6 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
85 ht = 182 ht = 1
86 }83 }
87 84
88- getViewRectCount++
89- if getViewRectCount <= 5 {
90- fmt.Fprintf(os.Stderr, "cef-debug: GetViewRect call=%d w=%d h=%d\n", getViewRectCount, w, ht)
91- }
92-
93 rect.X = 085 rect.X = 0
94 rect.Y = 086 rect.Y = 0
95 rect.Width = w87 rect.Width = w
@@ -108,17 +100,10 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
108 100
109 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects101 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
110 // to the render pipeline for GPU upload.102 // to the render pipeline for GPU upload.
111-var onPaintCount uint64
112-
113 func (h *handlerSet) OnPaint(103 func (h *handlerSet) OnPaint(
114 _ purecef.Browser, _ purecef.PaintElementType,104 _ purecef.Browser, _ purecef.PaintElementType,
115 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,105 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
116 ) {106 ) {
117- onPaintCount++
118- if onPaintCount <= 3 {
119- fmt.Fprintf(os.Stderr, "cef-debug: OnPaint call=%d w=%d h=%d dirtyRects=%d buffer_nil=%v\n",
120- onPaintCount, width, height, len(dirtyRects), buffer == nil)
121- }
122 rects := make([]rect, len(dirtyRects))107 rects := make([]rect, len(dirtyRects))
123 for i, dr := range dirtyRects {108 for i, dr := range dirtyRects {
124 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}109 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -209,8 +194,10 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64
209 h.wv.updateProgress(progress)194 h.wv.updateProgress(progress)
210 }195 }
211 196
212-func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, _ purecef.CursorType, _ *purecef.CursorInfo) int32 {197+func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {
213- return 0198+ name := cefCursorToGDKName(cursorType)
199+ h.wv.pipeline.glArea.SetCursorFromName(&name)
200+ return 1 // handled
214 }201 }
215 202
216 func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}203 func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
@@ -258,8 +245,12 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
258 245
259 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.246 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.
260 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {247 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
261- fmt.Fprintf(os.Stderr, "cef-debug: OnLoadEnd frame_nil=%v is_main=%v http_status=%d\n",248+ log := logging.FromContext(h.wv.ctx)
262- frame == nil, frame != nil && frame.IsMain(), httpStatusCode)249+ log.Debug().
250+ Bool("frame_nil", frame == nil).
251+ Bool("is_main", frame != nil && frame.IsMain()).
252+ Int32("http_status", httpStatusCode).
253+ Msg("cef: OnLoadEnd")
263 if frame == nil || !frame.IsMain() {254 if frame == nil || !frame.IsMain() {
264 return255 return
265 }256 }
@@ -306,7 +297,10 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
306 297
307 // OnAfterCreated stores the browser and host references and enables input.298 // OnAfterCreated stores the browser and host references and enables input.
308 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {299 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
309- fmt.Fprintf(os.Stderr, "cef-debug: OnAfterCreated browser_nil=%v\n", browser == nil)300+ log := logging.FromContext(h.wv.ctx)
301+ log.Debug().
302+ Bool("browser_nil", browser == nil).
303+ Msg("cef: OnAfterCreated")
310 h.wv.browser = browser304 h.wv.browser = browser
311 h.wv.host = browser.GetHost()305 h.wv.host = browser.GetHost()
312 h.wv.input.setHost(h.wv.host)306 h.wv.input.setHost(h.wv.host)
@@ -420,3 +414,69 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
420 }414 }
421 415
422 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}416 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
417+
418+// cefCursorToGDKName maps CEF cursor types to GDK/CSS cursor names.
419+//
420+//nolint:mnd,cyclop // cursor type lookup table
421+func cefCursorToGDKName(ct purecef.CursorType) string {
422+ switch ct {
423+ case purecef.CursorTypeCtPointer:
424+ return "default"
425+ case purecef.CursorTypeCtCross:
426+ return "crosshair"
427+ case purecef.CursorTypeCtHand:
428+ return "pointer"
429+ case purecef.CursorTypeCtIbeam:
430+ return "text"
431+ case purecef.CursorTypeCtWait:
432+ return "wait"
433+ case purecef.CursorTypeCtHelp:
434+ return "help"
435+ case purecef.CursorTypeCtEastresize:
436+ return "e-resize"
437+ case purecef.CursorTypeCtNorthresize:
438+ return "n-resize"
439+ case purecef.CursorTypeCtNortheastresize:
440+ return "ne-resize"
441+ case purecef.CursorTypeCtNorthwestresize:
442+ return "nw-resize"
443+ case purecef.CursorTypeCtSouthresize:
444+ return "s-resize"
445+ case purecef.CursorTypeCtSoutheastresize:
446+ return "se-resize"
447+ case purecef.CursorTypeCtSouthwestresize:
448+ return "sw-resize"
449+ case purecef.CursorTypeCtWestresize:
450+ return "w-resize"
451+ case purecef.CursorTypeCtNorthsouthresize:
452+ return "ns-resize"
453+ case purecef.CursorTypeCtEastwestresize:
454+ return "ew-resize"
455+ case purecef.CursorTypeCtNortheastsouthwestresize:
456+ return "nesw-resize"
457+ case purecef.CursorTypeCtNorthwestsoutheastresize:
458+ return "nwse-resize"
459+ case purecef.CursorTypeCtColumnresize:
460+ return "col-resize"
461+ case purecef.CursorTypeCtRowresize:
462+ return "row-resize"
463+ case purecef.CursorTypeCtMove:
464+ return "move"
465+ case purecef.CursorTypeCtProgress:
466+ return "progress"
467+ case purecef.CursorTypeCtNodrop:
468+ return "no-drop"
469+ case purecef.CursorTypeCtNotallowed:
470+ return "not-allowed"
471+ case purecef.CursorTypeCtGrab:
472+ return "grab"
473+ case purecef.CursorTypeCtGrabbing:
474+ return "grabbing"
475+ case purecef.CursorTypeCtZoomin:
476+ return "zoom-in"
477+ case purecef.CursorTypeCtZoomout:
478+ return "zoom-out"
479+ default:
480+ return "default"
481+ }
482+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close