detached b59184c

feat(cef): implement Phase 1 engine features and fix shortcut propagation

- Zoom: SetZoomLevel/GetZoomLevel with linear-to-logarithmic conversion
- Background color: JS injection at runtime + ARGB packed color at creation
- HiDPI: detect scale from GDK primary monitor, implement GetScreenInfo
- Middle-click: intercept button 2 on hovered links → OnLinkMiddleClick
- Audio state: implement AudioHandler for IsPlayingAudio tracking
- Fix keyboard shortcuts (Ctrl+L, Ctrl+F, etc.) by returning false for
  Ctrl/Alt modified key events so GTK's ShortcutController can process them
commit
b59184cca681be642af179dc5962eab63734db6d
author
brice · · 2026-03-20 22:38 UTC
committer
brice · · 2026-03-28 04:37 UTC
parent
a5e0073
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +201 -26
M internal/infrastructure/cef/engine.go +5 -2
@@ -119,8 +119,11 @@ func (e *Engine) OnToolkitReady(_ context.Context) error {
119119 return nil
120120 }
121121
122-// UpdateAppearance updates default background color for new WebViews (Phase 1 stub).
123-func (e *Engine) UpdateAppearance(_ context.Context, _, _, _, _ float64) error {
122+// UpdateAppearance updates the default background color for new WebViews.
123+func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) error {
124+ if e.factory != nil {
125+ e.factory.setDefaultBackgroundColor(r, g, b, alpha)
126+ }
124127 return nil
125128 }
126129
@@ -119,8 +119,11 @@ func (e *Engine) OnToolkitReady(_ context.Context) error {
119 return nil119 return nil
120 }120 }
121 121
122-// UpdateAppearance updates default background color for new WebViews (Phase 1 stub).122+// UpdateAppearance updates the default background color for new WebViews.
123-func (e *Engine) UpdateAppearance(_ context.Context, _, _, _, _ float64) error {123+func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) error {
124+ if e.factory != nil {
125+ e.factory.setDefaultBackgroundColor(r, g, b, alpha)
126+ }
124 return nil127 return nil
125 }128 }
126 129
M internal/infrastructure/cef/engine_init.go +18 -2
@@ -10,6 +10,7 @@ import (
1010 "syscall"
1111
1212 purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/gdk"
1314 "github.com/rs/zerolog"
1415
1516 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -102,8 +103,23 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
102103 return nil, fmt.Errorf("GL loader: %w", err)
103104 }
104105
105- // 4. Create factory + pool and wire them into the engine.
106- scale := int32(1) // TODO: detect from GDK
106+ // 4. Detect HiDPI scale from the primary monitor.
107+ scale := int32(1)
108+ if display := gdk.DisplayGetDefault(); display != nil {
109+ if monitors := display.GetMonitors(); monitors != nil {
110+ obj := monitors.GetObject(0)
111+ if obj != nil {
112+ mon := &gdk.Monitor{}
113+ mon.SetGoPointer(obj.GoPointer())
114+ if s := mon.GetScaleFactor(); s > 0 {
115+ scale = int32(s)
116+ logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
117+ }
118+ }
119+ }
120+ }
121+
122+ // 5. Create factory + pool and wire them into the engine.
107123 factory := newWebViewFactory(eng, gl, scale)
108124 pool := newWebViewPool(factory)
109125
@@ -10,6 +10,7 @@ import (
10 "syscall"10 "syscall"
11 11
12 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/gdk"
13 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
14 15
15 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -102,8 +103,23 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
102 return nil, fmt.Errorf("GL loader: %w", err)103 return nil, fmt.Errorf("GL loader: %w", err)
103 }104 }
104 105
105- // 4. Create factory + pool and wire them into the engine.106+ // 4. Detect HiDPI scale from the primary monitor.
106- scale := int32(1) // TODO: detect from GDK107+ scale := int32(1)
108+ if display := gdk.DisplayGetDefault(); display != nil {
109+ if monitors := display.GetMonitors(); monitors != nil {
110+ obj := monitors.GetObject(0)
111+ if obj != nil {
112+ mon := &gdk.Monitor{}
113+ mon.SetGoPointer(obj.GoPointer())
114+ if s := mon.GetScaleFactor(); s > 0 {
115+ scale = int32(s)
116+ logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
117+ }
118+ }
119+ }
120+ }
121+
122+ // 5. Create factory + pool and wire them into the engine.
107 factory := newWebViewFactory(eng, gl, scale)123 factory := newWebViewFactory(eng, gl, scale)
108 pool := newWebViewPool(factory)124 pool := newWebViewPool(factory)
109 125
M internal/infrastructure/cef/factory.go +30 -4
@@ -17,10 +17,11 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
1717 // gets a unique ID, its own renderPipeline and inputBridge, and an
1818 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
1919 type WebViewFactory struct {
20- engine *Engine
21- gl *glLoader
22- nextID atomic.Uint64
23- scale int32
20+ engine *Engine
21+ gl *glLoader
22+ nextID atomic.Uint64
23+ scale int32
24+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
2425 }
2526
2627 // newWebViewFactory returns a factory that will create WebViews using the
@@ -36,6 +37,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, scale int32) *WebViewFactor
3637 }
3738 }
3839
40+// setDefaultBackgroundColor stores a packed ARGB color for new browser creation.
41+func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
42+ const s = colorScale
43+ argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)
44+ f.bgColor.Store(argb)
45+}
46+
3947 // Create builds a new CEF off-screen browser WebView. The browser is created
4048 // asynchronously; the returned WebView is usable immediately but navigation
4149 // will fail with errNoBrowser until OnAfterCreated fires.
@@ -58,6 +66,21 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
5866 input.attachTo(pipeline.glArea)
5967 wv.input = input
6068
69+ // Wire middle-click → new tab using the cached hover URI.
70+ input.onMiddleClick = func(_ string) {
71+ wv.mu.RLock()
72+ hoverURI := wv.lastHoverURI
73+ cb := wv.callbacks
74+ wv.mu.RUnlock()
75+ if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
76+ return
77+ }
78+ uri := hoverURI
79+ wv.runOnGTK(func() {
80+ cb.OnLinkMiddleClick(uri)
81+ })
82+ }
83+
6184 // Build a CEF client backed by our handlerSet.
6285 // Store on WebView to prevent GC collection before CEF AddRef's it.
6386 client := purecef.NewClient(wv.handlers)
@@ -70,6 +93,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
7093 // Configure BrowserSettings.
7194 settings := purecef.DefaultBrowserSettings()
7295 settings.WindowlessFrameRate = 60
96+ if bg := f.bgColor.Load(); bg != 0 {
97+ settings.BackgroundColor = bg
98+ }
7399
74100 // Defer browser creation until the GL area has a non-zero size.
75101 // CEF requires GetViewRect to return a non-empty rect, but the GL area
@@ -17,10 +17,11 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
17 // gets a unique ID, its own renderPipeline and inputBridge, and an17 // gets a unique ID, its own renderPipeline and inputBridge, and an
18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).18 // asynchronously-created CEF browser (via BrowserHostCreateBrowser).
19 type WebViewFactory struct {19 type WebViewFactory struct {
20- engine *Engine20+ engine *Engine
21- gl *glLoader21+ gl *glLoader
22- nextID atomic.Uint6422+ nextID atomic.Uint64
23- scale int3223+ scale int32
24+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
24 }25 }
25 26
26 // newWebViewFactory returns a factory that will create WebViews using the27 // newWebViewFactory returns a factory that will create WebViews using the
@@ -36,6 +37,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, scale int32) *WebViewFactor
36 }37 }
37 }38 }
38 39
40+// setDefaultBackgroundColor stores a packed ARGB color for new browser creation.
41+func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
42+ const s = colorScale
43+ argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)
44+ f.bgColor.Store(argb)
45+}
46+
39 // Create builds a new CEF off-screen browser WebView. The browser is created47 // Create builds a new CEF off-screen browser WebView. The browser is created
40 // asynchronously; the returned WebView is usable immediately but navigation48 // asynchronously; the returned WebView is usable immediately but navigation
41 // will fail with errNoBrowser until OnAfterCreated fires.49 // will fail with errNoBrowser until OnAfterCreated fires.
@@ -58,6 +66,21 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
58 input.attachTo(pipeline.glArea)66 input.attachTo(pipeline.glArea)
59 wv.input = input67 wv.input = input
60 68
69+ // Wire middle-click → new tab using the cached hover URI.
70+ input.onMiddleClick = func(_ string) {
71+ wv.mu.RLock()
72+ hoverURI := wv.lastHoverURI
73+ cb := wv.callbacks
74+ wv.mu.RUnlock()
75+ if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
76+ return
77+ }
78+ uri := hoverURI
79+ wv.runOnGTK(func() {
80+ cb.OnLinkMiddleClick(uri)
81+ })
82+ }
83+
61 // Build a CEF client backed by our handlerSet.84 // Build a CEF client backed by our handlerSet.
62 // Store on WebView to prevent GC collection before CEF AddRef's it.85 // Store on WebView to prevent GC collection before CEF AddRef's it.
63 client := purecef.NewClient(wv.handlers)86 client := purecef.NewClient(wv.handlers)
@@ -70,6 +93,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
70 // Configure BrowserSettings.93 // Configure BrowserSettings.
71 settings := purecef.DefaultBrowserSettings()94 settings := purecef.DefaultBrowserSettings()
72 settings.WindowlessFrameRate = 6095 settings.WindowlessFrameRate = 60
96+ if bg := f.bgColor.Load(); bg != 0 {
97+ settings.BackgroundColor = bg
98+ }
73 99
74 // Defer browser creation until the GL area has a non-zero size.100 // Defer browser creation until the GL area has a non-zero size.
75 // CEF requires GetViewRect to return a non-empty rect, but the GL area101 // CEF requires GetViewRect to return a non-empty rect, but the GL area
M internal/infrastructure/cef/input_bridge.go +21 -1
@@ -51,6 +51,10 @@ type inputBridge struct {
5151 imContext *gtk.IMContextSimple
5252 // Prevent GC from collecting signal callbacks.
5353 commitCb func(gtk.IMContext, string)
54+
55+ // onMiddleClick is called when button 2 (middle) is pressed on a link.
56+ // The callback receives the hovered URI. Set by the factory.
57+ onMiddleClick func(uri string)
5458 }
5559
5660 // newInputBridge creates an input bridge with the given HiDPI scale factor.
@@ -154,7 +158,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
154158 }
155159
156160 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
157- ib.onKeyPress(keyval, keycode, uint(state))
161+ mods := uint(state)
162+ ib.onKeyPress(keyval, keycode, mods)
163+
164+ // Let Ctrl/Alt modified key combos propagate to the window's
165+ // ShortcutController so that app shortcuts (Ctrl+L, Ctrl+F, …)
166+ // still fire. Only consume unmodified / Shift-only keys that
167+ // are text input destined for CEF.
168+ if mods&uint(gdk.ControlMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
169+ return false
170+ }
158171 return true
159172 }
160173 key.ConnectKeyPressed(&keyPressCb)
@@ -198,11 +211,18 @@ func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
198211 func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) {
199212 ib.mu.Lock()
200213 host := ib.host
214+ middleCB := ib.onMiddleClick
201215 ib.mu.Unlock()
202216 if host == nil {
203217 return
204218 }
205219
220+ // Middle-click on a link opens in a new tab instead of sending to CEF.
221+ if button == 2 && middleCB != nil {
222+ middleCB("")
223+ return
224+ }
225+
206226 evt := buildMouseEvent(x, y, mods, ib.scale)
207227 btn := translateMouseButton(button)
208228 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
@@ -51,6 +51,10 @@ type inputBridge struct {
51 imContext *gtk.IMContextSimple51 imContext *gtk.IMContextSimple
52 // Prevent GC from collecting signal callbacks.52 // Prevent GC from collecting signal callbacks.
53 commitCb func(gtk.IMContext, string)53 commitCb func(gtk.IMContext, string)
54+
55+ // onMiddleClick is called when button 2 (middle) is pressed on a link.
56+ // The callback receives the hovered URI. Set by the factory.
57+ onMiddleClick func(uri string)
54 }58 }
55 59
56 // newInputBridge creates an input bridge with the given HiDPI scale factor.60 // newInputBridge creates an input bridge with the given HiDPI scale factor.
@@ -154,7 +158,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
154 }158 }
155 159
156 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {160 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
157- ib.onKeyPress(keyval, keycode, uint(state))161+ mods := uint(state)
162+ ib.onKeyPress(keyval, keycode, mods)
163+
164+ // Let Ctrl/Alt modified key combos propagate to the window's
165+ // ShortcutController so that app shortcuts (Ctrl+L, Ctrl+F, …)
166+ // still fire. Only consume unmodified / Shift-only keys that
167+ // are text input destined for CEF.
168+ if mods&uint(gdk.ControlMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
169+ return false
170+ }
158 return true171 return true
159 }172 }
160 key.ConnectKeyPressed(&keyPressCb)173 key.ConnectKeyPressed(&keyPressCb)
@@ -198,11 +211,18 @@ func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
198 func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) {211 func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) {
199 ib.mu.Lock()212 ib.mu.Lock()
200 host := ib.host213 host := ib.host
214+ middleCB := ib.onMiddleClick
201 ib.mu.Unlock()215 ib.mu.Unlock()
202 if host == nil {216 if host == nil {
203 return217 return
204 }218 }
205 219
220+ // Middle-click on a link opens in a new tab instead of sending to CEF.
221+ if button == 2 && middleCB != nil {
222+ middleCB("")
223+ return
224+ }
225+
206 evt := buildMouseEvent(x, y, mods, ib.scale)226 evt := buildMouseEvent(x, y, mods, ib.scale)
207 btn := translateMouseButton(button)227 btn := translateMouseButton(button)
208 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))228 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
M internal/infrastructure/cef/webview.go +78 -14
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/base64"
66 "errors"
7+ "fmt"
8+ "math"
79 "sync"
810 "sync/atomic"
911 "unsafe"
@@ -61,10 +63,15 @@ type WebView struct {
6163 canGoFwd bool
6264 isLoading bool
6365
66+ // Last known hover URI for middle-click → new tab.
67+ lastHoverURI string
68+
6469 // Atomic state.
65- destroyed atomic.Bool
66- fullscreen atomic.Bool
67- generation atomic.Uint64
70+ destroyed atomic.Bool
71+ fullscreen atomic.Bool
72+ generation atomic.Uint64
73+ audioPlaying atomic.Bool
74+ zoomFactor atomic.Value // float64, initialized to 1.0
6875 }
6976
7077 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
@@ -254,7 +261,7 @@ func (wv *WebView) State() port.WebViewState {
254261 Progress: wv.progress,
255262 CanGoBack: wv.canGoBack,
256263 CanGoFwd: wv.canGoFwd,
257- ZoomLevel: 1.0,
264+ ZoomLevel: wv.GetZoomLevel(),
258265 }
259266 }
260267
@@ -263,9 +270,9 @@ func (wv *WebView) IsFullscreen() bool {
263270 return wv.fullscreen.Load()
264271 }
265272
266-// IsPlayingAudio returns false (Phase 1 stub).
273+// IsPlayingAudio returns true if any audio stream is active.
267274 func (wv *WebView) IsPlayingAudio() bool {
268- return false
275+ return wv.audioPlaying.Load()
269276 }
270277
271278 // Generation returns a monotonic counter incremented on pool reuse.
@@ -279,16 +286,43 @@ func (wv *WebView) Favicon() port.Texture {
279286 }
280287
281288 // ---------------------------------------------------------------------------
282-// Zoom (Phase 1 stubs)
289+// Zoom
283290 // ---------------------------------------------------------------------------
284291
285-// GetZoomLevel returns 1.0 (Phase 1 stub).
292+// chromiumZoomBase is the base used by Chromium's logarithmic zoom level.
293+// CEF zoom level = log(factor) / log(1.2), where factor is the linear multiplier.
294+const chromiumZoomBase = 1.2
295+
296+// cefZoomFromFactor converts a linear zoom factor (1.0 = 100%) to CEF's
297+// logarithmic zoom level.
298+func cefZoomFromFactor(factor float64) float64 {
299+ if factor <= 0 {
300+ return 0
301+ }
302+ return math.Log(factor) / math.Log(chromiumZoomBase)
303+}
304+
305+// GetZoomLevel returns the current linear zoom factor.
286306 func (wv *WebView) GetZoomLevel() float64 {
307+ if v := wv.zoomFactor.Load(); v != nil {
308+ return v.(float64)
309+ }
287310 return 1.0
288311 }
289312
290-// SetZoomLevel is a no-op (Phase 1 stub).
291-func (wv *WebView) SetZoomLevel(_ context.Context, _ float64) error {
313+// SetZoomLevel sets the zoom level using a linear factor (1.0 = 100%).
314+func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
315+ if wv.destroyed.Load() {
316+ return errDestroyed
317+ }
318+ wv.mu.RLock()
319+ host := wv.host
320+ wv.mu.RUnlock()
321+ if host == nil {
322+ return errNoBrowser
323+ }
324+ host.SetZoomLevel(cefZoomFromFactor(factor))
325+ wv.zoomFactor.Store(factor)
292326 return nil
293327 }
294328
@@ -330,11 +364,23 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
330364 browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
331365 }
332366
333-// SetBackgroundColor is a no-op in Phase 1.
334-func (wv *WebView) SetBackgroundColor(_, _, _, _ float64) {}
367+// colorScale converts a 0.0–1.0 color component to an 8-bit integer.
368+const colorScale = 255
335369
336-// ResetBackgroundToDefault is a no-op in Phase 1.
337-func (wv *WebView) ResetBackgroundToDefault() {}
370+// SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
371+func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
372+ script := fmt.Sprintf(
373+ `document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'`,
374+ int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
375+ )
376+ wv.RunJavaScript(context.Background(), script)
377+}
378+
379+// ResetBackgroundToDefault clears the injected background color.
380+func (wv *WebView) ResetBackgroundToDefault() {
381+ wv.RunJavaScript(context.Background(),
382+ `document.documentElement.style.backgroundColor=''`)
383+}
338384
339385 // ---------------------------------------------------------------------------
340386 // Lifecycle
@@ -424,6 +470,24 @@ func (wv *WebView) updateLoadState(loading, back, fwd bool) {
424470 wv.mu.Unlock()
425471 }
426472
473+func (wv *WebView) setAudioPlaying(playing bool) {
474+ wv.audioPlaying.Store(playing)
475+ wv.mu.RLock()
476+ cb := wv.callbacks
477+ wv.mu.RUnlock()
478+ if cb != nil && cb.OnAudioStateChanged != nil {
479+ wv.runOnGTK(func() {
480+ cb.OnAudioStateChanged(playing)
481+ })
482+ }
483+}
484+
485+func (wv *WebView) updateHoverURI(uri string) {
486+ wv.mu.Lock()
487+ wv.lastHoverURI = uri
488+ wv.mu.Unlock()
489+}
490+
427491 func (wv *WebView) runOnGTK(fn func()) {
428492 if fn == nil {
429493 return
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7+ "fmt"
8+ "math"
7 "sync"9 "sync"
8 "sync/atomic"10 "sync/atomic"
9 "unsafe"11 "unsafe"
@@ -61,10 +63,15 @@ type WebView struct {
61 canGoFwd bool63 canGoFwd bool
62 isLoading bool64 isLoading bool
63 65
66+ // Last known hover URI for middle-click → new tab.
67+ lastHoverURI string
68+
64 // Atomic state.69 // Atomic state.
65- destroyed atomic.Bool70+ destroyed atomic.Bool
66- fullscreen atomic.Bool71+ fullscreen atomic.Bool
67- generation atomic.Uint6472+ generation atomic.Uint64
73+ audioPlaying atomic.Bool
74+ zoomFactor atomic.Value // float64, initialized to 1.0
68 }75 }
69 76
70 // pendingBrowserCreate holds the parameters needed to create a CEF browser,77 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
@@ -254,7 +261,7 @@ func (wv *WebView) State() port.WebViewState {
254 Progress: wv.progress,261 Progress: wv.progress,
255 CanGoBack: wv.canGoBack,262 CanGoBack: wv.canGoBack,
256 CanGoFwd: wv.canGoFwd,263 CanGoFwd: wv.canGoFwd,
257- ZoomLevel: 1.0,264+ ZoomLevel: wv.GetZoomLevel(),
258 }265 }
259 }266 }
260 267
@@ -263,9 +270,9 @@ func (wv *WebView) IsFullscreen() bool {
263 return wv.fullscreen.Load()270 return wv.fullscreen.Load()
264 }271 }
265 272
266-// IsPlayingAudio returns false (Phase 1 stub).273+// IsPlayingAudio returns true if any audio stream is active.
267 func (wv *WebView) IsPlayingAudio() bool {274 func (wv *WebView) IsPlayingAudio() bool {
268- return false275+ return wv.audioPlaying.Load()
269 }276 }
270 277
271 // Generation returns a monotonic counter incremented on pool reuse.278 // Generation returns a monotonic counter incremented on pool reuse.
@@ -279,16 +286,43 @@ func (wv *WebView) Favicon() port.Texture {
279 }286 }
280 287
281 // ---------------------------------------------------------------------------288 // ---------------------------------------------------------------------------
282-// Zoom (Phase 1 stubs)289+// Zoom
283 // ---------------------------------------------------------------------------290 // ---------------------------------------------------------------------------
284 291
285-// GetZoomLevel returns 1.0 (Phase 1 stub).292+// chromiumZoomBase is the base used by Chromium's logarithmic zoom level.
293+// CEF zoom level = log(factor) / log(1.2), where factor is the linear multiplier.
294+const chromiumZoomBase = 1.2
295+
296+// cefZoomFromFactor converts a linear zoom factor (1.0 = 100%) to CEF's
297+// logarithmic zoom level.
298+func cefZoomFromFactor(factor float64) float64 {
299+ if factor <= 0 {
300+ return 0
301+ }
302+ return math.Log(factor) / math.Log(chromiumZoomBase)
303+}
304+
305+// GetZoomLevel returns the current linear zoom factor.
286 func (wv *WebView) GetZoomLevel() float64 {306 func (wv *WebView) GetZoomLevel() float64 {
307+ if v := wv.zoomFactor.Load(); v != nil {
308+ return v.(float64)
309+ }
287 return 1.0310 return 1.0
288 }311 }
289 312
290-// SetZoomLevel is a no-op (Phase 1 stub).313+// SetZoomLevel sets the zoom level using a linear factor (1.0 = 100%).
291-func (wv *WebView) SetZoomLevel(_ context.Context, _ float64) error {314+func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
315+ if wv.destroyed.Load() {
316+ return errDestroyed
317+ }
318+ wv.mu.RLock()
319+ host := wv.host
320+ wv.mu.RUnlock()
321+ if host == nil {
322+ return errNoBrowser
323+ }
324+ host.SetZoomLevel(cefZoomFromFactor(factor))
325+ wv.zoomFactor.Store(factor)
292 return nil326 return nil
293 }327 }
294 328
@@ -330,11 +364,23 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
330 browser.GetMainFrame().ExecuteJavaScript(script, "", 0)364 browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
331 }365 }
332 366
333-// SetBackgroundColor is a no-op in Phase 1.367+// colorScale converts a 0.0–1.0 color component to an 8-bit integer.
334-func (wv *WebView) SetBackgroundColor(_, _, _, _ float64) {}368+const colorScale = 255
335 369
336-// ResetBackgroundToDefault is a no-op in Phase 1.370+// SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
337-func (wv *WebView) ResetBackgroundToDefault() {}371+func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
372+ script := fmt.Sprintf(
373+ `document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'`,
374+ int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
375+ )
376+ wv.RunJavaScript(context.Background(), script)
377+}
378+
379+// ResetBackgroundToDefault clears the injected background color.
380+func (wv *WebView) ResetBackgroundToDefault() {
381+ wv.RunJavaScript(context.Background(),
382+ `document.documentElement.style.backgroundColor=''`)
383+}
338 384
339 // ---------------------------------------------------------------------------385 // ---------------------------------------------------------------------------
340 // Lifecycle386 // Lifecycle
@@ -424,6 +470,24 @@ func (wv *WebView) updateLoadState(loading, back, fwd bool) {
424 wv.mu.Unlock()470 wv.mu.Unlock()
425 }471 }
426 472
473+func (wv *WebView) setAudioPlaying(playing bool) {
474+ wv.audioPlaying.Store(playing)
475+ wv.mu.RLock()
476+ cb := wv.callbacks
477+ wv.mu.RUnlock()
478+ if cb != nil && cb.OnAudioStateChanged != nil {
479+ wv.runOnGTK(func() {
480+ cb.OnAudioStateChanged(playing)
481+ })
482+ }
483+}
484+
485+func (wv *WebView) updateHoverURI(uri string) {
486+ wv.mu.Lock()
487+ wv.lastHoverURI = uri
488+ wv.mu.Unlock()
489+}
490+
427 func (wv *WebView) runOnGTK(fn func()) {491 func (wv *WebView) runOnGTK(fn func()) {
428 if fn == nil {492 if fn == nil {
429 return493 return
M internal/infrastructure/cef/webview_handlers.go +49 -3
@@ -24,13 +24,14 @@ var (
2424 _ purecef.LoadHandler = (*handlerSet)(nil)
2525 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
2626 _ purecef.RequestHandler = (*handlerSet)(nil)
27+ _ purecef.AudioHandler = (*handlerSet)(nil)
2728 )
2829
2930 // ===========================================================================
3031 // Client
3132 // ===========================================================================
3233
33-func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return nil }
34+func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return h }
3435 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
3536 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return nil }
3637 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
@@ -92,7 +93,27 @@ func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ unsafe.P
9293 return 0
9394 }
9495
95-func (h *handlerSet) GetScreenInfo(_ purecef.Browser, _ *purecef.ScreenInfo) int32 { return 0 }
96+func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) int32 {
97+ if info == nil {
98+ return 0
99+ }
100+ h.wv.pipeline.mu.Lock()
101+ w := h.wv.pipeline.width
102+ ht := h.wv.pipeline.height
103+ s := h.wv.pipeline.scale
104+ h.wv.pipeline.mu.Unlock()
105+
106+ info.Size = unsafe.Sizeof(*info)
107+ info.DeviceScaleFactor = float32(s)
108+ info.Depth = 24
109+ info.DepthPerComponent = 8
110+ info.Rect.X = 0
111+ info.Rect.Y = 0
112+ info.Rect.Width = w
113+ info.Rect.Height = ht
114+ info.AvailableRect = info.Rect
115+ return 1
116+}
96117
97118 func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}
98119
@@ -199,8 +220,11 @@ func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32)
199220
200221 func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
201222
202-// OnStatusMessage fires the OnLinkHover callback with the status text.
223+// OnStatusMessage fires the OnLinkHover callback with the status text
224+// and caches the hover URI for middle-click interception.
203225 func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
226+ h.wv.updateHoverURI(value)
227+
204228 h.wv.mu.RLock()
205229 cb := h.wv.callbacks
206230 h.wv.mu.RUnlock()
@@ -473,6 +497,28 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
473497
474498 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
475499
500+// ===========================================================================
501+// AudioHandler (5 methods)
502+// ===========================================================================
503+
504+func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
505+ return 1 // proceed with defaults
506+}
507+
508+func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, _ *purecef.AudioParameters, _ int32) {
509+ h.wv.setAudioPlaying(true)
510+}
511+
512+func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, _ unsafe.Pointer, _ int32, _ int64) {}
513+
514+func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
515+ h.wv.setAudioPlaying(false)
516+}
517+
518+func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) {
519+ h.wv.setAudioPlaying(false)
520+}
521+
476522 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
477523 var cefCursorNames = map[purecef.CursorType]string{
478524 purecef.CursorTypeCtPointer: "default",
@@ -24,13 +24,14 @@ var (
24 _ purecef.LoadHandler = (*handlerSet)(nil)24 _ purecef.LoadHandler = (*handlerSet)(nil)
25 _ purecef.LifeSpanHandler = (*handlerSet)(nil)25 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
26 _ purecef.RequestHandler = (*handlerSet)(nil)26 _ purecef.RequestHandler = (*handlerSet)(nil)
27+ _ purecef.AudioHandler = (*handlerSet)(nil)
27 )28 )
28 29
29 // ===========================================================================30 // ===========================================================================
30 // Client31 // Client
31 // ===========================================================================32 // ===========================================================================
32 33
33-func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return nil }34+func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { return h }
34 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }35 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
35 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return nil }36 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return nil }
36 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }37 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
@@ -92,7 +93,27 @@ func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ unsafe.P
92 return 093 return 0
93 }94 }
94 95
95-func (h *handlerSet) GetScreenInfo(_ purecef.Browser, _ *purecef.ScreenInfo) int32 { return 0 }96+func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) int32 {
97+ if info == nil {
98+ return 0
99+ }
100+ h.wv.pipeline.mu.Lock()
101+ w := h.wv.pipeline.width
102+ ht := h.wv.pipeline.height
103+ s := h.wv.pipeline.scale
104+ h.wv.pipeline.mu.Unlock()
105+
106+ info.Size = unsafe.Sizeof(*info)
107+ info.DeviceScaleFactor = float32(s)
108+ info.Depth = 24
109+ info.DepthPerComponent = 8
110+ info.Rect.X = 0
111+ info.Rect.Y = 0
112+ info.Rect.Width = w
113+ info.Rect.Height = ht
114+ info.AvailableRect = info.Rect
115+ return 1
116+}
96 117
97 func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}118 func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}
98 119
@@ -199,8 +220,11 @@ func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32)
199 220
200 func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }221 func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
201 222
202-// OnStatusMessage fires the OnLinkHover callback with the status text.223+// OnStatusMessage fires the OnLinkHover callback with the status text
224+// and caches the hover URI for middle-click interception.
203 func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {225 func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
226+ h.wv.updateHoverURI(value)
227+
204 h.wv.mu.RLock()228 h.wv.mu.RLock()
205 cb := h.wv.callbacks229 cb := h.wv.callbacks
206 h.wv.mu.RUnlock()230 h.wv.mu.RUnlock()
@@ -473,6 +497,28 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
473 497
474 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}498 func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
475 499
500+// ===========================================================================
501+// AudioHandler (5 methods)
502+// ===========================================================================
503+
504+func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
505+ return 1 // proceed with defaults
506+}
507+
508+func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, _ *purecef.AudioParameters, _ int32) {
509+ h.wv.setAudioPlaying(true)
510+}
511+
512+func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, _ unsafe.Pointer, _ int32, _ int64) {}
513+
514+func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
515+ h.wv.setAudioPlaying(false)
516+}
517+
518+func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) {
519+ h.wv.setAudioPlaying(false)
520+}
521+
476 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.522 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
477 var cefCursorNames = map[purecef.CursorType]string{523 var cefCursorNames = map[purecef.CursorType]string{
478 purecef.CursorTypeCtPointer: "default",524 purecef.CursorTypeCtPointer: "default",

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