detached a732180

feat(zoom): implement DOM-level zoom system with CSS transforms

commit
a7321803c9c744fc8d0a916791e8d5cd77091bbc
author
bamen · · 2025-09-16 18:37 UTC
committer
bamen · · 2025-09-16 20:52 UTC
parent
3b38f32
signature
unsigned
8 files · +430 -105
M internal/config/config.go +39 -1
@@ -38,6 +38,8 @@ type Config struct {
3838 APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`
3939 // RenderingMode controls GPU/CPU rendering selection for WebKit
4040 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
41+ // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
42+ UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
4143 }
4244
4345 // RenderingMode selects GPU vs CPU rendering.
@@ -288,6 +290,9 @@ func NewManager() (*Manager, error) {
288290 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
289291 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
290292 }
293+ if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
294+ return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
295+ }
291296
292297 // Video acceleration environment variable bindings
293298 videoAccelEnvBindings := map[string]string{
@@ -415,6 +420,8 @@ func (m *Manager) Load() error {
415420 // Validate and configure codec preferences
416421 config = m.validateAndConfigureCodecPreferences(config)
417422
423+ m.ensurePersistedDefaults(config)
424+
418425 m.config = config
419426 return nil
420427 }
@@ -513,6 +520,8 @@ func (m *Manager) reload() error {
513520 // Validate and configure codec preferences
514521 config = m.validateAndConfigureCodecPreferences(config)
515522
523+ m.ensurePersistedDefaults(config)
524+
516525 m.config = config
517526 return nil
518527 }
@@ -601,7 +610,36 @@ func (m *Manager) setDefaults() {
601610 m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring)
602611
603612 // Rendering defaults
604- m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
613+ m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
614+ m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
615+}
616+
617+func (m *Manager) ensurePersistedDefaults(cfg *Config) {
618+ if cfg == nil {
619+ return
620+ }
621+
622+ if m.viper == nil {
623+ return
624+ }
625+
626+ // Only persist when the key is missing from the user config file.
627+ if m.viper.InConfig("use_dom_zoom") {
628+ return
629+ }
630+
631+ cfgFile := m.viper.ConfigFileUsed()
632+ if cfgFile == "" {
633+ return
634+ }
635+
636+ m.viper.Set("use_dom_zoom", cfg.UseDomZoom)
637+ if err := m.viper.WriteConfig(); err != nil {
638+ logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err))
639+ return
640+ }
641+
642+ logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
605643 }
606644
607645 // createDefaultConfig creates a default configuration file.
@@ -38,6 +38,8 @@ type Config struct {
38 APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`38 APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`
39 // RenderingMode controls GPU/CPU rendering selection for WebKit39 // RenderingMode controls GPU/CPU rendering selection for WebKit
40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
41+ // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
42+ UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
41 }43 }
42 44
43 // RenderingMode selects GPU vs CPU rendering.45 // RenderingMode selects GPU vs CPU rendering.
@@ -288,6 +290,9 @@ func NewManager() (*Manager, error) {
288 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {290 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
289 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)291 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
290 }292 }
293+ if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
294+ return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
295+ }
291 296
292 // Video acceleration environment variable bindings297 // Video acceleration environment variable bindings
293 videoAccelEnvBindings := map[string]string{298 videoAccelEnvBindings := map[string]string{
@@ -415,6 +420,8 @@ func (m *Manager) Load() error {
415 // Validate and configure codec preferences420 // Validate and configure codec preferences
416 config = m.validateAndConfigureCodecPreferences(config)421 config = m.validateAndConfigureCodecPreferences(config)
417 422
423+ m.ensurePersistedDefaults(config)
424+
418 m.config = config425 m.config = config
419 return nil426 return nil
420 }427 }
@@ -513,6 +520,8 @@ func (m *Manager) reload() error {
513 // Validate and configure codec preferences520 // Validate and configure codec preferences
514 config = m.validateAndConfigureCodecPreferences(config)521 config = m.validateAndConfigureCodecPreferences(config)
515 522
523+ m.ensurePersistedDefaults(config)
524+
516 m.config = config525 m.config = config
517 return nil526 return nil
518 }527 }
@@ -601,7 +610,36 @@ func (m *Manager) setDefaults() {
601 m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring)610 m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring)
602 611
603 // Rendering defaults612 // Rendering defaults
604- m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))613+ m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
614+ m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
615+}
616+
617+func (m *Manager) ensurePersistedDefaults(cfg *Config) {
618+ if cfg == nil {
619+ return
620+ }
621+
622+ if m.viper == nil {
623+ return
624+ }
625+
626+ // Only persist when the key is missing from the user config file.
627+ if m.viper.InConfig("use_dom_zoom") {
628+ return
629+ }
630+
631+ cfgFile := m.viper.ConfigFileUsed()
632+ if cfgFile == "" {
633+ return
634+ }
635+
636+ m.viper.Set("use_dom_zoom", cfg.UseDomZoom)
637+ if err := m.viper.WriteConfig(); err != nil {
638+ logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err))
639+ return
640+ }
641+
642+ logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
605 }643 }
606 644
607 // createDefaultConfig creates a default configuration file.645 // createDefaultConfig creates a default configuration file.
M internal/config/defaults.go +2 -1
@@ -144,7 +144,8 @@ func DefaultConfig() *Config {
144144 Token: "",
145145 RequireToken: false,
146146 },
147- RenderingMode: RenderingModeAuto,
147+ RenderingMode: RenderingModeGPU,
148+ UseDomZoom: false,
148149 }
149150 }
150151
@@ -144,7 +144,8 @@ func DefaultConfig() *Config {
144 Token: "",144 Token: "",
145 RequireToken: false,145 RequireToken: false,
146 },146 },
147- RenderingMode: RenderingModeAuto,147+ RenderingMode: RenderingModeGPU,
148+ UseDomZoom: false,
148 }149 }
149 }150 }
150 151
M pkg/webkit/settings.go +3 -0
@@ -44,6 +44,9 @@ type Config struct {
4444 // APIToken holds the per-session token used to authenticate dumb://api requests
4545 // It's optional; if empty, API token enforcement may be disabled by the app.
4646 APIToken string
47+
48+ // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
49+ UseDomZoom bool
4750 }
4851
4952 // CodecPreferencesConfig holds codec preferences for WebKit media playback
@@ -44,6 +44,9 @@ type Config struct {
44 // APIToken holds the per-session token used to authenticate dumb://api requests44 // APIToken holds the per-session token used to authenticate dumb://api requests
45 // It's optional; if empty, API token enforcement may be disabled by the app.45 // It's optional; if empty, API token enforcement may be disabled by the app.
46 APIToken string46 APIToken string
47+
48+ // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
49+ UseDomZoom bool
47 }50 }
48 51
49 // CodecPreferencesConfig holds codec preferences for WebKit media playback52 // CodecPreferencesConfig holds codec preferences for WebKit media playback
M pkg/webkit/webview.go +29 -1
@@ -20,6 +20,8 @@ type WebView struct {
2020 titleHandler func(title string)
2121 uriHandler func(uri string)
2222 zoomHandler func(level float64)
23+ useDomZoom bool
24+ domZoomSeed float64
2325 }
2426
2527 // NewWebView constructs a new WebView instance.
@@ -28,12 +30,13 @@ func NewWebView(cfg *Config) (*WebView, error) {
2830 cfg = &Config{}
2931 }
3032 log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
31- wv := &WebView{config: cfg}
33+ wv := &WebView{config: cfg, useDomZoom: cfg.UseDomZoom}
3234 if cfg.ZoomDefault <= 0 {
3335 wv.zoom = 1.0
3436 } else {
3537 wv.zoom = cfg.ZoomDefault
3638 }
39+ wv.domZoomSeed = wv.zoom
3740 // Construction succeeds; create a logical window placeholder.
3841 wv.window = &Window{Title: "Dumber Browser"}
3942 return wv, nil
@@ -141,6 +144,31 @@ func (w *WebView) dispatchZoomChanged(level float64) {
141144 }
142145 }
143146
147+// RunOnMainThread executes fn immediately in non-CGO builds.
148+func (w *WebView) RunOnMainThread(fn func()) {
149+ if fn != nil {
150+ fn()
151+ }
152+}
153+
154+// UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.
155+func (w *WebView) UsesDomZoom() bool {
156+ return w != nil && w.useDomZoom
157+}
158+
159+// SeedDomZoom stores the desired DOM zoom level for the next navigation (stub implementation).
160+func (w *WebView) SeedDomZoom(level float64) {
161+ if w == nil || !w.useDomZoom {
162+ return
163+ }
164+ if level < 0.25 {
165+ level = 0.25
166+ } else if level > 5.0 {
167+ level = 5.0
168+ }
169+ w.domZoomSeed = level
170+}
171+
144172 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)
145173 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {
146174 return ErrNotImplemented
@@ -20,6 +20,8 @@ type WebView struct {
20 titleHandler func(title string)20 titleHandler func(title string)
21 uriHandler func(uri string)21 uriHandler func(uri string)
22 zoomHandler func(level float64)22 zoomHandler func(level float64)
23+ useDomZoom bool
24+ domZoomSeed float64
23 }25 }
24 26
25 // NewWebView constructs a new WebView instance.27 // NewWebView constructs a new WebView instance.
@@ -28,12 +30,13 @@ func NewWebView(cfg *Config) (*WebView, error) {
28 cfg = &Config{}30 cfg = &Config{}
29 }31 }
30 log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")32 log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
31- wv := &WebView{config: cfg}33+ wv := &WebView{config: cfg, useDomZoom: cfg.UseDomZoom}
32 if cfg.ZoomDefault <= 0 {34 if cfg.ZoomDefault <= 0 {
33 wv.zoom = 1.035 wv.zoom = 1.0
34 } else {36 } else {
35 wv.zoom = cfg.ZoomDefault37 wv.zoom = cfg.ZoomDefault
36 }38 }
39+ wv.domZoomSeed = wv.zoom
37 // Construction succeeds; create a logical window placeholder.40 // Construction succeeds; create a logical window placeholder.
38 wv.window = &Window{Title: "Dumber Browser"}41 wv.window = &Window{Title: "Dumber Browser"}
39 return wv, nil42 return wv, nil
@@ -141,6 +144,31 @@ func (w *WebView) dispatchZoomChanged(level float64) {
141 }144 }
142 }145 }
143 146
147+// RunOnMainThread executes fn immediately in non-CGO builds.
148+func (w *WebView) RunOnMainThread(fn func()) {
149+ if fn != nil {
150+ fn()
151+ }
152+}
153+
154+// UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.
155+func (w *WebView) UsesDomZoom() bool {
156+ return w != nil && w.useDomZoom
157+}
158+
159+// SeedDomZoom stores the desired DOM zoom level for the next navigation (stub implementation).
160+func (w *WebView) SeedDomZoom(level float64) {
161+ if w == nil || !w.useDomZoom {
162+ return
163+ }
164+ if level < 0.25 {
165+ level = 0.25
166+ } else if level > 5.0 {
167+ level = 5.0
168+ }
169+ w.domZoomSeed = level
170+}
171+
144 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)172 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)
145 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {173 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {
146 return ErrNotImplemented174 return ErrNotImplemented
M pkg/webkit/webview_cgo.go +324 -95
@@ -7,6 +7,7 @@ package webkit
77 #cgo CFLAGS: -I/usr/include/webkitgtk-6.0
88 #include <stdlib.h>
99 #include <string.h>
10+#include <stdint.h>
1011 #include <gtk/gtk.h>
1112 #include <webkit/webkit.h>
1213 #include <glib-object.h>
@@ -20,6 +21,16 @@ static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
2021
2122 // Forward declaration
2223 extern void goQuitMainLoop();
24+extern void goInvokeHandle(uintptr_t handle);
25+
26+static gboolean invoke_handle_cb(gpointer data) {
27+ goInvokeHandle((uintptr_t)data);
28+ return G_SOURCE_REMOVE;
29+}
30+
31+static void schedule_on_main_thread(uintptr_t handle) {
32+ g_idle_add_full(G_PRIORITY_DEFAULT, invoke_handle_cb, (gpointer)handle, NULL);
33+}
2334
2435 // Connect window close signal to quit main loop
2536 static void connect_destroy_quit(GtkWidget* w) {
@@ -321,6 +332,15 @@ static void maybe_set_media_user_gesture(WebKitSettings* settings, int require)
321332 #endif
322333 }
323334
335+static void maybe_set_smooth_scrolling(WebKitSettings* settings, int enable) {
336+#if WEBKIT_CHECK_VERSION(2,10,0)
337+ if (!settings) return;
338+ webkit_settings_set_enable_smooth_scrolling(settings, enable ? TRUE : FALSE);
339+#else
340+ (void)settings; (void)enable;
341+#endif
342+}
343+
324344 // Enable two-finger swipe back/forward navigation gestures when supported
325345 static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {
326346 #if WEBKIT_CHECK_VERSION(2,38,0)
@@ -628,9 +648,12 @@ import (
628648 "errors"
629649 "fmt"
630650 "log"
651+ "math"
631652 "os"
632653 "path/filepath"
633654 "runtime"
655+ "runtime/cgo"
656+ "strconv"
634657 "strings"
635658 "time"
636659 "unsafe"
@@ -648,6 +671,126 @@ type FilterManager interface {
648671 GetCosmeticScriptForDomain(domain string) string
649672 }
650673
674+const domBridgeTemplate = `(() => { try {
675+ if (window.__dumber_page_bridge_installed) return;
676+ window.__dumber_page_bridge_installed = true;
677+
678+ const __dumberInitialZoom = __DOM_ZOOM_DEFAULT__;
679+ window.__dumber_dom_zoom_seed = __dumberInitialZoom;
680+
681+ // Theme setter function for GTK theme integration
682+ window.__dumber_setTheme = (theme) => {
683+ window.__dumber_initial_theme = theme;
684+ console.log('[dumber] Setting theme to:', theme);
685+ if (theme === 'dark') {
686+ document.documentElement.classList.add('dark');
687+ } else {
688+ document.documentElement.classList.remove('dark');
689+ }
690+ };
691+
692+ // Ensure unified API object
693+ window.__dumber = window.__dumber || {};
694+ // Toast namespace
695+ window.__dumber.toast = window.__dumber.toast || {
696+ show: function(message, duration, type) {
697+ try {
698+ document.dispatchEvent(new CustomEvent('dumber:toast:show', { detail: { message, duration, type } }));
699+ // Legacy compatibility
700+ document.dispatchEvent(new CustomEvent('dumber:showToast', { detail: { message, duration, type } }));
701+ } catch(e) { /* ignore */ }
702+ },
703+ zoom: function(level) {
704+ try {
705+ document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { detail: { level } }));
706+ // Legacy compatibility
707+ document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { detail: { level } }));
708+ } catch(e) { /* ignore */ }
709+ }
710+ };
711+ // Back-compat global helpers
712+ window.__dumber_showToast = function(message, duration, type) { window.__dumber.toast.show(message, duration, type); };
713+ window.__dumber_showZoomToast = function(level) { window.__dumber.toast.zoom(level); };
714+
715+ // DOM zoom helpers (optional native override)
716+ if (typeof window.__dumber_dom_zoom_level !== 'number') {
717+ window.__dumber_dom_zoom_level = __dumberInitialZoom;
718+ }
719+ const __dumberApplyZoomStyles = function(node, level) {
720+ if (!node) return;
721+ if (Math.abs(level - 1.0) < 1e-6) {
722+ node.style.removeProperty('zoom');
723+ node.style.removeProperty('transform');
724+ node.style.removeProperty('transform-origin');
725+ node.style.removeProperty('width');
726+ node.style.removeProperty('min-width');
727+ node.style.removeProperty('height');
728+ node.style.removeProperty('min-height');
729+ return;
730+ }
731+ const scale = level;
732+ const inversePercent = 100 / scale;
733+ const widthValue = inversePercent + '%';
734+ node.style.removeProperty('zoom');
735+ node.style.transform = 'scale(' + scale + ')';
736+ node.style.transformOrigin = '0 0';
737+ node.style.width = widthValue;
738+ node.style.minWidth = widthValue;
739+ node.style.minHeight = '100%';
740+ };
741+ window.__dumber_applyDomZoom = function(level) {
742+ try {
743+ window.__dumber_dom_zoom_level = level;
744+ window.__dumber_dom_zoom_seed = level;
745+ __dumberApplyZoomStyles(document.documentElement, level);
746+ if (document.body) {
747+ __dumberApplyZoomStyles(document.body, level);
748+ }
749+ } catch (e) {
750+ console.error('[dumber] DOM zoom error', e);
751+ }
752+ };
753+ // Apply immediately so first paint uses the desired zoom, then reapply when body exists.
754+ window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
755+ if (!document.body) {
756+ document.addEventListener('DOMContentLoaded', function() {
757+ if (typeof window.__dumber_dom_zoom_level === 'number') {
758+ window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
759+ }
760+ }, { once: true });
761+ }
762+
763+ // Omnibox suggestions bridge (with queue before ready)
764+ let __omniboxQueue = [];
765+ let __omniboxReady = false;
766+ function __omniboxDispatch(suggestions) {
767+ try {
768+ document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { detail: { suggestions } }));
769+ // Legacy compatibility event name
770+ document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { detail: { suggestions } }));
771+ } catch (e) { /* ignore */ }
772+ }
773+ window.__dumber_omnibox_suggestions = function(suggestions) {
774+ if (__omniboxReady) {
775+ __omniboxDispatch(suggestions);
776+ } else {
777+ try { __omniboxQueue.push(suggestions); } catch (_) { /* ignore */ }
778+ }
779+ };
780+ document.addEventListener('dumber:omnibox-ready', function() {
781+ __omniboxReady = true;
782+ if (__omniboxQueue && __omniboxQueue.length) {
783+ const items = __omniboxQueue.slice();
784+ __omniboxQueue.length = 0;
785+ for (const s of items) __omniboxDispatch(s);
786+ }
787+ });
788+ // Unified omnibox API for page-world callers
789+ window.__dumber.omnibox = window.__dumber.omnibox || {
790+ suggestions: function(suggestions) { window.__dumber_omnibox_suggestions(suggestions); }
791+ };
792+ } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
793+
651794 // Helper function to convert bool to int for C interop
652795 func boolToInt(b bool) int {
653796 if b {
@@ -656,6 +799,15 @@ func boolToInt(b bool) int {
656799 return 0
657800 }
658801
802+//export goInvokeHandle
803+func goInvokeHandle(handle C.uintptr_t) {
804+ h := cgo.Handle(handle)
805+ if fn, ok := h.Value().(func()); ok {
806+ fn()
807+ }
808+ h.Delete()
809+}
810+
659811 // getCertificateHash generates a SHA256 hash of the certificate info for storage
660812 func getCertificateHash(certificateInfo string) string {
661813 hash := sha256.Sum256([]byte(certificateInfo))
@@ -756,21 +908,24 @@ type memoryStats struct {
756908
757909 // WebView represents a browser view powered by WebKit2GTK.
758910 type WebView struct {
759- config *Config
760- zoom float64
761- url string
762- destroyed bool
763- native *nativeView
764- window *Window
765- id uintptr
766- msgHandler func(payload string)
767- titleHandler func(title string)
768- uriHandler func(uri string)
769- zoomHandler func(level float64)
770- memStats *memoryStats
771- gcTicker *time.Ticker
772- gcDone chan struct{}
773- tlsExceptions map[string]bool // host -> whether user allowed certificate exception
911+ config *Config
912+ zoom float64
913+ url string
914+ destroyed bool
915+ native *nativeView
916+ window *Window
917+ id uintptr
918+ msgHandler func(payload string)
919+ titleHandler func(title string)
920+ uriHandler func(uri string)
921+ zoomHandler func(level float64)
922+ memStats *memoryStats
923+ gcTicker *time.Ticker
924+ gcDone chan struct{}
925+ tlsExceptions map[string]bool // host -> whether user allowed certificate exception
926+ useDomZoom bool
927+ domZoomSeed float64
928+ domBridgeScript *C.WebKitUserScript
774929 }
775930
776931 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -891,11 +1046,18 @@ func NewWebView(cfg *Config) (*WebView, error) {
8911046 C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
8921047 C.connect_destroy_quit(win)
8931048
1049+ seed := 1.0
1050+ if cfg.ZoomDefault > 0 {
1051+ seed = cfg.ZoomDefault
1052+ }
1053+
8941054 v := &WebView{
895- config: cfg,
896- zoom: 1.0,
897- native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
898- window: &Window{win: win},
1055+ config: cfg,
1056+ zoom: 1.0,
1057+ useDomZoom: cfg.UseDomZoom,
1058+ domZoomSeed: seed,
1059+ native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1060+ window: &Window{win: win},
8991061 memStats: &memoryStats{
9001062 pageLoadCount: 0,
9011063 lastGCTime: time.Now(),
@@ -996,17 +1158,22 @@ func NewWebView(cfg *Config) (*WebView, error) {
9961158 // Hardware acceleration policy (guarded by version)
9971159 switch cfg.Rendering.Mode {
9981160 case "gpu":
1161+ log.Printf("[webkit] Applying GPU rendering mode")
9991162 C.maybe_set_hw_policy(settings, 1)
10001163 C.maybe_set_webgl(settings, 1)
10011164 C.maybe_set_canvas_accel(settings, 1)
10021165 case "cpu":
1166+ log.Printf("[webkit] Applying CPU rendering mode")
10031167 C.maybe_set_hw_policy(settings, 2)
10041168 C.maybe_set_webgl(settings, 0)
10051169 C.maybe_set_canvas_accel(settings, 0)
10061170 default: // auto
1171+ log.Printf("[webkit] Applying AUTO rendering mode - detecting actual backend...")
10071172 C.maybe_set_hw_policy(settings, 0)
10081173 C.maybe_set_webgl(settings, 1)
10091174 C.maybe_set_canvas_accel(settings, 1)
1175+ // Detect actual rendering backend after applying auto settings
1176+ v.detectAndLogRenderingBackend()
10101177 }
10111178 // Optional compositing indicators for debugging (guarded by version)
10121179 if cfg.Rendering.DebugGPU {
@@ -1016,6 +1183,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
10161183 C.maybe_set_media_user_gesture(settings, 1)
10171184 // Enable trackpad back/forward gestures when available
10181185 C.maybe_set_back_forward_gestures(settings, 1)
1186+ // Smooth scrolling for wheel/pinch gestures when supported
1187+ C.maybe_set_smooth_scrolling(settings, 1)
10191188
10201189 // Keep page cache enabled by default for performance
10211190 enablePageCache := true
@@ -1174,6 +1343,12 @@ func (w *WebView) Destroy() error {
11741343 globalMemoryManager.UnregisterWebView(w.id)
11751344 }
11761345
1346+ if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1347+ C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1348+ C.webkit_user_script_unref(w.domBridgeScript)
1349+ w.domBridgeScript = nil
1350+ }
1351+
11771352 // GTK4: destroy windows via gtk_window_destroy
11781353 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
11791354 w.destroyed = true
@@ -1258,6 +1433,15 @@ func (w *WebView) dispatchURIChanged(uri string) {
12581433 }
12591434 }
12601435
1436+// RunOnMainThread schedules fn to execute on the GTK main thread.
1437+func (w *WebView) RunOnMainThread(fn func()) {
1438+ if w == nil || fn == nil {
1439+ return
1440+ }
1441+ h := cgo.NewHandle(fn)
1442+ C.schedule_on_main_thread(C.uintptr_t(h))
1443+}
1444+
12611445 // RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
12621446 func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
12631447
@@ -1349,82 +1533,7 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
13491533 }
13501534
13511535 // Inject a unified page-world bridge to dispatch standard events and expose unified API
1352- bridge := `(() => { try {
1353- if (window.__dumber_page_bridge_installed) return;
1354- window.__dumber_page_bridge_installed = true;
1355-
1356- // Theme setter function for GTK theme integration
1357- window.__dumber_setTheme = (theme) => {
1358- window.__dumber_initial_theme = theme;
1359- console.log('[dumber] Setting theme to:', theme);
1360- if (theme === 'dark') {
1361- document.documentElement.classList.add('dark');
1362- } else {
1363- document.documentElement.classList.remove('dark');
1364- }
1365- };
1366-
1367- // Ensure unified API object
1368- window.__dumber = window.__dumber || {};
1369- // Toast namespace
1370- window.__dumber.toast = window.__dumber.toast || {
1371- show: function(message, duration, type) {
1372- try {
1373- document.dispatchEvent(new CustomEvent('dumber:toast:show', { detail: { message, duration, type } }));
1374- // Legacy compatibility
1375- document.dispatchEvent(new CustomEvent('dumber:showToast', { detail: { message, duration, type } }));
1376- } catch(e) { /* ignore */ }
1377- },
1378- zoom: function(level) {
1379- try {
1380- document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { detail: { level } }));
1381- // Legacy compatibility
1382- document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { detail: { level } }));
1383- } catch(e) { /* ignore */ }
1384- }
1385- };
1386- // Back-compat global helpers
1387- window.__dumber_showToast = function(message, duration, type) { window.__dumber.toast.show(message, duration, type); };
1388- window.__dumber_showZoomToast = function(level) { window.__dumber.toast.zoom(level); };
1389-
1390- // Omnibox suggestions bridge (with queue before ready)
1391- let __omniboxQueue = [];
1392- let __omniboxReady = false;
1393- function __omniboxDispatch(suggestions) {
1394- try {
1395- document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { detail: { suggestions } }));
1396- // Legacy compatibility event name
1397- document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { detail: { suggestions } }));
1398- } catch (e) { /* ignore */ }
1399- }
1400- window.__dumber_omnibox_suggestions = function(suggestions) {
1401- if (__omniboxReady) {
1402- __omniboxDispatch(suggestions);
1403- } else {
1404- try { __omniboxQueue.push(suggestions); } catch (_) { /* ignore */ }
1405- }
1406- };
1407- document.addEventListener('dumber:omnibox-ready', function() {
1408- __omniboxReady = true;
1409- if (__omniboxQueue && __omniboxQueue.length) {
1410- const items = __omniboxQueue.slice();
1411- __omniboxQueue.length = 0;
1412- for (const s of items) __omniboxDispatch(s);
1413- }
1414- });
1415- // Unified omnibox API for page-world callers
1416- window.__dumber.omnibox = window.__dumber.omnibox || {
1417- suggestions: function(suggestions) { window.__dumber_omnibox_suggestions(suggestions); }
1418- };
1419- } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
1420- cBridge := C.CString(bridge)
1421- defer C.free(unsafe.Pointer(cBridge))
1422- bridgeScript := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1423- if bridgeScript != nil {
1424- C.webkit_user_content_manager_add_script(w.native.ucm, bridgeScript)
1425- C.webkit_user_script_unref(bridgeScript)
1426- log.Printf("[webkit] Page-world toast bridge injected")
1427- }
1536+ w.installDomBridgeScript()
14281537
14291538 // If an API token is provided, inject a fetch wrapper in the isolated GUI world
14301539 if cfg.APIToken != "" {
@@ -1502,6 +1611,70 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
15021611 // No JS fallback bridge — native UCM is active
15031612 }
15041613
1614+func clampDomZoom(level float64) float64 {
1615+ if level < 0.25 {
1616+ return 0.25
1617+ }
1618+ if level > 5.0 {
1619+ return 5.0
1620+ }
1621+ return level
1622+}
1623+
1624+func buildDomBridgeScript(initialZoom float64) string {
1625+ zoom := clampDomZoom(initialZoom)
1626+ zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64)
1627+ return strings.ReplaceAll(domBridgeTemplate, "__DOM_ZOOM_DEFAULT__", zoomStr)
1628+}
1629+
1630+func (w *WebView) UsesDomZoom() bool {
1631+ return w != nil && w.useDomZoom
1632+}
1633+
1634+func (w *WebView) installDomBridgeScript() {
1635+ if w == nil || w.native == nil || w.native.ucm == nil {
1636+ return
1637+ }
1638+
1639+ w.domZoomSeed = clampDomZoom(w.domZoomSeed)
1640+
1641+ if w.domBridgeScript != nil {
1642+ C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1643+ C.webkit_user_script_unref(w.domBridgeScript)
1644+ w.domBridgeScript = nil
1645+ }
1646+
1647+ source := buildDomBridgeScript(w.domZoomSeed)
1648+ cBridge := C.CString(source)
1649+ defer C.free(unsafe.Pointer(cBridge))
1650+
1651+ script := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1652+ if script == nil {
1653+ log.Printf("[webkit] ERROR: Failed to create GUI user script")
1654+ return
1655+ }
1656+
1657+ C.webkit_user_content_manager_add_script(w.native.ucm, script)
1658+ w.domBridgeScript = script
1659+ log.Printf("[webkit] Page-world toast bridge injected (dom zoom seed=%.2f)", w.domZoomSeed)
1660+}
1661+
1662+// SeedDomZoom updates the document-start bridge so the upcoming page paints with the saved DOM zoom level.
1663+func (w *WebView) SeedDomZoom(level float64) {
1664+ if w == nil || w.destroyed || !w.useDomZoom {
1665+ return
1666+ }
1667+
1668+ seed := clampDomZoom(level)
1669+ if math.Abs(w.domZoomSeed-seed) < 1e-6 {
1670+ return
1671+ }
1672+ w.domZoomSeed = seed
1673+ w.RunOnMainThread(func() {
1674+ w.installDomBridgeScript()
1675+ })
1676+}
1677+
15051678 // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
15061679 func (w *WebView) startPeriodicGC(interval time.Duration) {
15071680 if w == nil || w.gcTicker != nil {
@@ -1895,3 +2068,59 @@ func extractDomain(url string) string {
18952068
18962069 return url
18972070 }
2071+
2072+// detectAndLogRenderingBackend detects and logs the actual rendering backend being used
2073+func (w *WebView) detectAndLogRenderingBackend() {
2074+ if w == nil {
2075+ return
2076+ }
2077+
2078+ // Use a small delay to let WebKit initialize
2079+ go func() {
2080+ time.Sleep(500 * time.Millisecond)
2081+
2082+ // Inject a script to detect rendering capabilities
2083+ detectionScript := `
2084+ (() => {
2085+ try {
2086+ const canvas = document.createElement('canvas');
2087+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
2088+ const info = {
2089+ webgl_available: !!gl,
2090+ renderer: gl ? gl.getParameter(gl.RENDERER) : 'N/A',
2091+ vendor: gl ? gl.getParameter(gl.VENDOR) : 'N/A',
2092+ version: gl ? gl.getParameter(gl.VERSION) : 'N/A',
2093+ shading_language_version: gl ? gl.getParameter(gl.SHADING_LANGUAGE_VERSION) : 'N/A',
2094+ max_texture_size: gl ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : 0,
2095+ hardware_accelerated: false
2096+ };
2097+
2098+ // Check if renderer indicates hardware acceleration
2099+ if (gl && info.renderer) {
2100+ const renderer = info.renderer.toLowerCase();
2101+ // Look for GPU indicators
2102+ info.hardware_accelerated = (
2103+ renderer.includes('nvidia') ||
2104+ renderer.includes('amd') ||
2105+ renderer.includes('intel') ||
2106+ renderer.includes('radeon') ||
2107+ renderer.includes('geforce') ||
2108+ renderer.includes('quadro') ||
2109+ (renderer.includes('mesa') && !renderer.includes('software')) ||
2110+ !renderer.includes('software')
2111+ );
2112+ }
2113+
2114+ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({
2115+ type: 'rendering_backend_detection',
2116+ data: info
2117+ }));
2118+ } catch (e) {
2119+ console.error('[dumber] Rendering backend detection failed:', e);
2120+ }
2121+ })();
2122+ `
2123+
2124+ w.InjectScript(detectionScript)
2125+ }()
2126+}
@@ -7,6 +7,7 @@ package webkit
7 #cgo CFLAGS: -I/usr/include/webkitgtk-6.07 #cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8 #include <stdlib.h>8 #include <stdlib.h>
9 #include <string.h>9 #include <string.h>
10+#include <stdint.h>
10 #include <gtk/gtk.h>11 #include <gtk/gtk.h>
11 #include <webkit/webkit.h>12 #include <webkit/webkit.h>
12 #include <glib-object.h>13 #include <glib-object.h>
@@ -20,6 +21,16 @@ static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
20 21
21 // Forward declaration22 // Forward declaration
22 extern void goQuitMainLoop();23 extern void goQuitMainLoop();
24+extern void goInvokeHandle(uintptr_t handle);
25+
26+static gboolean invoke_handle_cb(gpointer data) {
27+ goInvokeHandle((uintptr_t)data);
28+ return G_SOURCE_REMOVE;
29+}
30+
31+static void schedule_on_main_thread(uintptr_t handle) {
32+ g_idle_add_full(G_PRIORITY_DEFAULT, invoke_handle_cb, (gpointer)handle, NULL);
33+}
23 34
24 // Connect window close signal to quit main loop35 // Connect window close signal to quit main loop
25 static void connect_destroy_quit(GtkWidget* w) {36 static void connect_destroy_quit(GtkWidget* w) {
@@ -321,6 +332,15 @@ static void maybe_set_media_user_gesture(WebKitSettings* settings, int require)
321 #endif332 #endif
322 }333 }
323 334
335+static void maybe_set_smooth_scrolling(WebKitSettings* settings, int enable) {
336+#if WEBKIT_CHECK_VERSION(2,10,0)
337+ if (!settings) return;
338+ webkit_settings_set_enable_smooth_scrolling(settings, enable ? TRUE : FALSE);
339+#else
340+ (void)settings; (void)enable;
341+#endif
342+}
343+
324 // Enable two-finger swipe back/forward navigation gestures when supported344 // Enable two-finger swipe back/forward navigation gestures when supported
325 static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {345 static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {
326 #if WEBKIT_CHECK_VERSION(2,38,0)346 #if WEBKIT_CHECK_VERSION(2,38,0)
@@ -628,9 +648,12 @@ import (
628 "errors"648 "errors"
629 "fmt"649 "fmt"
630 "log"650 "log"
651+ "math"
631 "os"652 "os"
632 "path/filepath"653 "path/filepath"
633 "runtime"654 "runtime"
655+ "runtime/cgo"
656+ "strconv"
634 "strings"657 "strings"
635 "time"658 "time"
636 "unsafe"659 "unsafe"
@@ -648,6 +671,126 @@ type FilterManager interface {
648 GetCosmeticScriptForDomain(domain string) string671 GetCosmeticScriptForDomain(domain string) string
649 }672 }
650 673
674+const domBridgeTemplate = `(() => { try {
675+ if (window.__dumber_page_bridge_installed) return;
676+ window.__dumber_page_bridge_installed = true;
677+
678+ const __dumberInitialZoom = __DOM_ZOOM_DEFAULT__;
679+ window.__dumber_dom_zoom_seed = __dumberInitialZoom;
680+
681+ // Theme setter function for GTK theme integration
682+ window.__dumber_setTheme = (theme) => {
683+ window.__dumber_initial_theme = theme;
684+ console.log('[dumber] Setting theme to:', theme);
685+ if (theme === 'dark') {
686+ document.documentElement.classList.add('dark');
687+ } else {
688+ document.documentElement.classList.remove('dark');
689+ }
690+ };
691+
692+ // Ensure unified API object
693+ window.__dumber = window.__dumber || {};
694+ // Toast namespace
695+ window.__dumber.toast = window.__dumber.toast || {
696+ show: function(message, duration, type) {
697+ try {
698+ document.dispatchEvent(new CustomEvent('dumber:toast:show', { detail: { message, duration, type } }));
699+ // Legacy compatibility
700+ document.dispatchEvent(new CustomEvent('dumber:showToast', { detail: { message, duration, type } }));
701+ } catch(e) { /* ignore */ }
702+ },
703+ zoom: function(level) {
704+ try {
705+ document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { detail: { level } }));
706+ // Legacy compatibility
707+ document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { detail: { level } }));
708+ } catch(e) { /* ignore */ }
709+ }
710+ };
711+ // Back-compat global helpers
712+ window.__dumber_showToast = function(message, duration, type) { window.__dumber.toast.show(message, duration, type); };
713+ window.__dumber_showZoomToast = function(level) { window.__dumber.toast.zoom(level); };
714+
715+ // DOM zoom helpers (optional native override)
716+ if (typeof window.__dumber_dom_zoom_level !== 'number') {
717+ window.__dumber_dom_zoom_level = __dumberInitialZoom;
718+ }
719+ const __dumberApplyZoomStyles = function(node, level) {
720+ if (!node) return;
721+ if (Math.abs(level - 1.0) < 1e-6) {
722+ node.style.removeProperty('zoom');
723+ node.style.removeProperty('transform');
724+ node.style.removeProperty('transform-origin');
725+ node.style.removeProperty('width');
726+ node.style.removeProperty('min-width');
727+ node.style.removeProperty('height');
728+ node.style.removeProperty('min-height');
729+ return;
730+ }
731+ const scale = level;
732+ const inversePercent = 100 / scale;
733+ const widthValue = inversePercent + '%';
734+ node.style.removeProperty('zoom');
735+ node.style.transform = 'scale(' + scale + ')';
736+ node.style.transformOrigin = '0 0';
737+ node.style.width = widthValue;
738+ node.style.minWidth = widthValue;
739+ node.style.minHeight = '100%';
740+ };
741+ window.__dumber_applyDomZoom = function(level) {
742+ try {
743+ window.__dumber_dom_zoom_level = level;
744+ window.__dumber_dom_zoom_seed = level;
745+ __dumberApplyZoomStyles(document.documentElement, level);
746+ if (document.body) {
747+ __dumberApplyZoomStyles(document.body, level);
748+ }
749+ } catch (e) {
750+ console.error('[dumber] DOM zoom error', e);
751+ }
752+ };
753+ // Apply immediately so first paint uses the desired zoom, then reapply when body exists.
754+ window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
755+ if (!document.body) {
756+ document.addEventListener('DOMContentLoaded', function() {
757+ if (typeof window.__dumber_dom_zoom_level === 'number') {
758+ window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
759+ }
760+ }, { once: true });
761+ }
762+
763+ // Omnibox suggestions bridge (with queue before ready)
764+ let __omniboxQueue = [];
765+ let __omniboxReady = false;
766+ function __omniboxDispatch(suggestions) {
767+ try {
768+ document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { detail: { suggestions } }));
769+ // Legacy compatibility event name
770+ document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { detail: { suggestions } }));
771+ } catch (e) { /* ignore */ }
772+ }
773+ window.__dumber_omnibox_suggestions = function(suggestions) {
774+ if (__omniboxReady) {
775+ __omniboxDispatch(suggestions);
776+ } else {
777+ try { __omniboxQueue.push(suggestions); } catch (_) { /* ignore */ }
778+ }
779+ };
780+ document.addEventListener('dumber:omnibox-ready', function() {
781+ __omniboxReady = true;
782+ if (__omniboxQueue && __omniboxQueue.length) {
783+ const items = __omniboxQueue.slice();
784+ __omniboxQueue.length = 0;
785+ for (const s of items) __omniboxDispatch(s);
786+ }
787+ });
788+ // Unified omnibox API for page-world callers
789+ window.__dumber.omnibox = window.__dumber.omnibox || {
790+ suggestions: function(suggestions) { window.__dumber_omnibox_suggestions(suggestions); }
791+ };
792+ } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
793+
651 // Helper function to convert bool to int for C interop794 // Helper function to convert bool to int for C interop
652 func boolToInt(b bool) int {795 func boolToInt(b bool) int {
653 if b {796 if b {
@@ -656,6 +799,15 @@ func boolToInt(b bool) int {
656 return 0799 return 0
657 }800 }
658 801
802+//export goInvokeHandle
803+func goInvokeHandle(handle C.uintptr_t) {
804+ h := cgo.Handle(handle)
805+ if fn, ok := h.Value().(func()); ok {
806+ fn()
807+ }
808+ h.Delete()
809+}
810+
659 // getCertificateHash generates a SHA256 hash of the certificate info for storage811 // getCertificateHash generates a SHA256 hash of the certificate info for storage
660 func getCertificateHash(certificateInfo string) string {812 func getCertificateHash(certificateInfo string) string {
661 hash := sha256.Sum256([]byte(certificateInfo))813 hash := sha256.Sum256([]byte(certificateInfo))
@@ -756,21 +908,24 @@ type memoryStats struct {
756 908
757 // WebView represents a browser view powered by WebKit2GTK.909 // WebView represents a browser view powered by WebKit2GTK.
758 type WebView struct {910 type WebView struct {
759- config *Config911+ config *Config
760- zoom float64912+ zoom float64
761- url string913+ url string
762- destroyed bool914+ destroyed bool
763- native *nativeView915+ native *nativeView
764- window *Window916+ window *Window
765- id uintptr917+ id uintptr
766- msgHandler func(payload string)918+ msgHandler func(payload string)
767- titleHandler func(title string)919+ titleHandler func(title string)
768- uriHandler func(uri string)920+ uriHandler func(uri string)
769- zoomHandler func(level float64)921+ zoomHandler func(level float64)
770- memStats *memoryStats922+ memStats *memoryStats
771- gcTicker *time.Ticker923+ gcTicker *time.Ticker
772- gcDone chan struct{}924+ gcDone chan struct{}
773- tlsExceptions map[string]bool // host -> whether user allowed certificate exception925+ tlsExceptions map[string]bool // host -> whether user allowed certificate exception
926+ useDomZoom bool
927+ domZoomSeed float64
928+ domBridgeScript *C.WebKitUserScript
774 }929 }
775 930
776 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.931 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -891,11 +1046,18 @@ func NewWebView(cfg *Config) (*WebView, error) {
891 C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)1046 C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
892 C.connect_destroy_quit(win)1047 C.connect_destroy_quit(win)
893 1048
1049+ seed := 1.0
1050+ if cfg.ZoomDefault > 0 {
1051+ seed = cfg.ZoomDefault
1052+ }
1053+
894 v := &WebView{1054 v := &WebView{
895- config: cfg,1055+ config: cfg,
896- zoom: 1.0,1056+ zoom: 1.0,
897- native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},1057+ useDomZoom: cfg.UseDomZoom,
898- window: &Window{win: win},1058+ domZoomSeed: seed,
1059+ native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1060+ window: &Window{win: win},
899 memStats: &memoryStats{1061 memStats: &memoryStats{
900 pageLoadCount: 0,1062 pageLoadCount: 0,
901 lastGCTime: time.Now(),1063 lastGCTime: time.Now(),
@@ -996,17 +1158,22 @@ func NewWebView(cfg *Config) (*WebView, error) {
996 // Hardware acceleration policy (guarded by version)1158 // Hardware acceleration policy (guarded by version)
997 switch cfg.Rendering.Mode {1159 switch cfg.Rendering.Mode {
998 case "gpu":1160 case "gpu":
1161+ log.Printf("[webkit] Applying GPU rendering mode")
999 C.maybe_set_hw_policy(settings, 1)1162 C.maybe_set_hw_policy(settings, 1)
1000 C.maybe_set_webgl(settings, 1)1163 C.maybe_set_webgl(settings, 1)
1001 C.maybe_set_canvas_accel(settings, 1)1164 C.maybe_set_canvas_accel(settings, 1)
1002 case "cpu":1165 case "cpu":
1166+ log.Printf("[webkit] Applying CPU rendering mode")
1003 C.maybe_set_hw_policy(settings, 2)1167 C.maybe_set_hw_policy(settings, 2)
1004 C.maybe_set_webgl(settings, 0)1168 C.maybe_set_webgl(settings, 0)
1005 C.maybe_set_canvas_accel(settings, 0)1169 C.maybe_set_canvas_accel(settings, 0)
1006 default: // auto1170 default: // auto
1171+ log.Printf("[webkit] Applying AUTO rendering mode - detecting actual backend...")
1007 C.maybe_set_hw_policy(settings, 0)1172 C.maybe_set_hw_policy(settings, 0)
1008 C.maybe_set_webgl(settings, 1)1173 C.maybe_set_webgl(settings, 1)
1009 C.maybe_set_canvas_accel(settings, 1)1174 C.maybe_set_canvas_accel(settings, 1)
1175+ // Detect actual rendering backend after applying auto settings
1176+ v.detectAndLogRenderingBackend()
1010 }1177 }
1011 // Optional compositing indicators for debugging (guarded by version)1178 // Optional compositing indicators for debugging (guarded by version)
1012 if cfg.Rendering.DebugGPU {1179 if cfg.Rendering.DebugGPU {
@@ -1016,6 +1183,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
1016 C.maybe_set_media_user_gesture(settings, 1)1183 C.maybe_set_media_user_gesture(settings, 1)
1017 // Enable trackpad back/forward gestures when available1184 // Enable trackpad back/forward gestures when available
1018 C.maybe_set_back_forward_gestures(settings, 1)1185 C.maybe_set_back_forward_gestures(settings, 1)
1186+ // Smooth scrolling for wheel/pinch gestures when supported
1187+ C.maybe_set_smooth_scrolling(settings, 1)
1019 1188
1020 // Keep page cache enabled by default for performance1189 // Keep page cache enabled by default for performance
1021 enablePageCache := true1190 enablePageCache := true
@@ -1174,6 +1343,12 @@ func (w *WebView) Destroy() error {
1174 globalMemoryManager.UnregisterWebView(w.id)1343 globalMemoryManager.UnregisterWebView(w.id)
1175 }1344 }
1176 1345
1346+ if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1347+ C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1348+ C.webkit_user_script_unref(w.domBridgeScript)
1349+ w.domBridgeScript = nil
1350+ }
1351+
1177 // GTK4: destroy windows via gtk_window_destroy1352 // GTK4: destroy windows via gtk_window_destroy
1178 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))1353 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1179 w.destroyed = true1354 w.destroyed = true
@@ -1258,6 +1433,15 @@ func (w *WebView) dispatchURIChanged(uri string) {
1258 }1433 }
1259 }1434 }
1260 1435
1436+// RunOnMainThread schedules fn to execute on the GTK main thread.
1437+func (w *WebView) RunOnMainThread(fn func()) {
1438+ if w == nil || fn == nil {
1439+ return
1440+ }
1441+ h := cgo.NewHandle(fn)
1442+ C.schedule_on_main_thread(C.uintptr_t(h))
1443+}
1444+
1261 // RegisterZoomChangedHandler registers a callback invoked when zoom level changes.1445 // RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
1262 func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }1446 func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
1263 1447
@@ -1349,82 +1533,7 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
1349 }1533 }
1350 1534
1351 // Inject a unified page-world bridge to dispatch standard events and expose unified API1535 // Inject a unified page-world bridge to dispatch standard events and expose unified API
1352- bridge := `(() => { try {1536+ w.installDomBridgeScript()
1353- if (window.__dumber_page_bridge_installed) return;
1354- window.__dumber_page_bridge_installed = true;
1355-
1356- // Theme setter function for GTK theme integration
1357- window.__dumber_setTheme = (theme) => {
1358- window.__dumber_initial_theme = theme;
1359- console.log('[dumber] Setting theme to:', theme);
1360- if (theme === 'dark') {
1361- document.documentElement.classList.add('dark');
1362- } else {
1363- document.documentElement.classList.remove('dark');
1364- }
1365- };
1366-
1367- // Ensure unified API object
1368- window.__dumber = window.__dumber || {};
1369- // Toast namespace
1370- window.__dumber.toast = window.__dumber.toast || {
1371- show: function(message, duration, type) {
1372- try {
1373- document.dispatchEvent(new CustomEvent('dumber:toast:show', { detail: { message, duration, type } }));
1374- // Legacy compatibility
1375- document.dispatchEvent(new CustomEvent('dumber:showToast', { detail: { message, duration, type } }));
1376- } catch(e) { /* ignore */ }
1377- },
1378- zoom: function(level) {
1379- try {
1380- document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { detail: { level } }));
1381- // Legacy compatibility
1382- document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { detail: { level } }));
1383- } catch(e) { /* ignore */ }
1384- }
1385- };
1386- // Back-compat global helpers
1387- window.__dumber_showToast = function(message, duration, type) { window.__dumber.toast.show(message, duration, type); };
1388- window.__dumber_showZoomToast = function(level) { window.__dumber.toast.zoom(level); };
1389-
1390- // Omnibox suggestions bridge (with queue before ready)
1391- let __omniboxQueue = [];
1392- let __omniboxReady = false;
1393- function __omniboxDispatch(suggestions) {
1394- try {
1395- document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { detail: { suggestions } }));
1396- // Legacy compatibility event name
1397- document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { detail: { suggestions } }));
1398- } catch (e) { /* ignore */ }
1399- }
1400- window.__dumber_omnibox_suggestions = function(suggestions) {
1401- if (__omniboxReady) {
1402- __omniboxDispatch(suggestions);
1403- } else {
1404- try { __omniboxQueue.push(suggestions); } catch (_) { /* ignore */ }
1405- }
1406- };
1407- document.addEventListener('dumber:omnibox-ready', function() {
1408- __omniboxReady = true;
1409- if (__omniboxQueue && __omniboxQueue.length) {
1410- const items = __omniboxQueue.slice();
1411- __omniboxQueue.length = 0;
1412- for (const s of items) __omniboxDispatch(s);
1413- }
1414- });
1415- // Unified omnibox API for page-world callers
1416- window.__dumber.omnibox = window.__dumber.omnibox || {
1417- suggestions: function(suggestions) { window.__dumber_omnibox_suggestions(suggestions); }
1418- };
1419- } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
1420- cBridge := C.CString(bridge)
1421- defer C.free(unsafe.Pointer(cBridge))
1422- bridgeScript := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1423- if bridgeScript != nil {
1424- C.webkit_user_content_manager_add_script(w.native.ucm, bridgeScript)
1425- C.webkit_user_script_unref(bridgeScript)
1426- log.Printf("[webkit] Page-world toast bridge injected")
1427- }
1428 1537
1429 // If an API token is provided, inject a fetch wrapper in the isolated GUI world1538 // If an API token is provided, inject a fetch wrapper in the isolated GUI world
1430 if cfg.APIToken != "" {1539 if cfg.APIToken != "" {
@@ -1502,6 +1611,70 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
1502 // No JS fallback bridge — native UCM is active1611 // No JS fallback bridge — native UCM is active
1503 }1612 }
1504 1613
1614+func clampDomZoom(level float64) float64 {
1615+ if level < 0.25 {
1616+ return 0.25
1617+ }
1618+ if level > 5.0 {
1619+ return 5.0
1620+ }
1621+ return level
1622+}
1623+
1624+func buildDomBridgeScript(initialZoom float64) string {
1625+ zoom := clampDomZoom(initialZoom)
1626+ zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64)
1627+ return strings.ReplaceAll(domBridgeTemplate, "__DOM_ZOOM_DEFAULT__", zoomStr)
1628+}
1629+
1630+func (w *WebView) UsesDomZoom() bool {
1631+ return w != nil && w.useDomZoom
1632+}
1633+
1634+func (w *WebView) installDomBridgeScript() {
1635+ if w == nil || w.native == nil || w.native.ucm == nil {
1636+ return
1637+ }
1638+
1639+ w.domZoomSeed = clampDomZoom(w.domZoomSeed)
1640+
1641+ if w.domBridgeScript != nil {
1642+ C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1643+ C.webkit_user_script_unref(w.domBridgeScript)
1644+ w.domBridgeScript = nil
1645+ }
1646+
1647+ source := buildDomBridgeScript(w.domZoomSeed)
1648+ cBridge := C.CString(source)
1649+ defer C.free(unsafe.Pointer(cBridge))
1650+
1651+ script := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1652+ if script == nil {
1653+ log.Printf("[webkit] ERROR: Failed to create GUI user script")
1654+ return
1655+ }
1656+
1657+ C.webkit_user_content_manager_add_script(w.native.ucm, script)
1658+ w.domBridgeScript = script
1659+ log.Printf("[webkit] Page-world toast bridge injected (dom zoom seed=%.2f)", w.domZoomSeed)
1660+}
1661+
1662+// SeedDomZoom updates the document-start bridge so the upcoming page paints with the saved DOM zoom level.
1663+func (w *WebView) SeedDomZoom(level float64) {
1664+ if w == nil || w.destroyed || !w.useDomZoom {
1665+ return
1666+ }
1667+
1668+ seed := clampDomZoom(level)
1669+ if math.Abs(w.domZoomSeed-seed) < 1e-6 {
1670+ return
1671+ }
1672+ w.domZoomSeed = seed
1673+ w.RunOnMainThread(func() {
1674+ w.installDomBridgeScript()
1675+ })
1676+}
1677+
1505 // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically1678 // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
1506 func (w *WebView) startPeriodicGC(interval time.Duration) {1679 func (w *WebView) startPeriodicGC(interval time.Duration) {
1507 if w == nil || w.gcTicker != nil {1680 if w == nil || w.gcTicker != nil {
@@ -1895,3 +2068,59 @@ func extractDomain(url string) string {
1895 2068
1896 return url2069 return url
1897 }2070 }
2071+
2072+// detectAndLogRenderingBackend detects and logs the actual rendering backend being used
2073+func (w *WebView) detectAndLogRenderingBackend() {
2074+ if w == nil {
2075+ return
2076+ }
2077+
2078+ // Use a small delay to let WebKit initialize
2079+ go func() {
2080+ time.Sleep(500 * time.Millisecond)
2081+
2082+ // Inject a script to detect rendering capabilities
2083+ detectionScript := `
2084+ (() => {
2085+ try {
2086+ const canvas = document.createElement('canvas');
2087+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
2088+ const info = {
2089+ webgl_available: !!gl,
2090+ renderer: gl ? gl.getParameter(gl.RENDERER) : 'N/A',
2091+ vendor: gl ? gl.getParameter(gl.VENDOR) : 'N/A',
2092+ version: gl ? gl.getParameter(gl.VERSION) : 'N/A',
2093+ shading_language_version: gl ? gl.getParameter(gl.SHADING_LANGUAGE_VERSION) : 'N/A',
2094+ max_texture_size: gl ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : 0,
2095+ hardware_accelerated: false
2096+ };
2097+
2098+ // Check if renderer indicates hardware acceleration
2099+ if (gl && info.renderer) {
2100+ const renderer = info.renderer.toLowerCase();
2101+ // Look for GPU indicators
2102+ info.hardware_accelerated = (
2103+ renderer.includes('nvidia') ||
2104+ renderer.includes('amd') ||
2105+ renderer.includes('intel') ||
2106+ renderer.includes('radeon') ||
2107+ renderer.includes('geforce') ||
2108+ renderer.includes('quadro') ||
2109+ (renderer.includes('mesa') && !renderer.includes('software')) ||
2110+ !renderer.includes('software')
2111+ );
2112+ }
2113+
2114+ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({
2115+ type: 'rendering_backend_detection',
2116+ data: info
2117+ }));
2118+ } catch (e) {
2119+ console.error('[dumber] Rendering backend detection failed:', e);
2120+ }
2121+ })();
2122+ `
2123+
2124+ w.InjectScript(detectionScript)
2125+ }()
2126+}
M pkg/webkit/zoom.go +3 -0
@@ -19,6 +19,9 @@ func (w *WebView) SetZoom(level float64) error {
1919 level = zoomMax
2020 }
2121 w.zoom = level
22+ if w.useDomZoom {
23+ w.SeedDomZoom(level)
24+ }
2225 w.dispatchZoomChanged(w.zoom)
2326 return nil
2427 }
@@ -19,6 +19,9 @@ func (w *WebView) SetZoom(level float64) error {
19 level = zoomMax19 level = zoomMax
20 }20 }
21 w.zoom = level21 w.zoom = level
22+ if w.useDomZoom {
23+ w.SeedDomZoom(level)
24+ }
22 w.dispatchZoomChanged(w.zoom)25 w.dispatchZoomChanged(w.zoom)
23 return nil26 return nil
24 }27 }
M pkg/webkit/zoom_cgo.go +15 -7
@@ -9,18 +9,23 @@ package webkit
99 */
1010 import "C"
1111
12+import "fmt"
13+
1214 func (w *WebView) SetZoom(level float64) error {
1315 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1416 return ErrNotImplemented
1517 }
16- if level < 0.25 {
17- level = 0.25
18- }
19- if level > 5.0 {
20- level = 5.0
21- }
18+ level = clampDomZoom(level)
2219 w.zoom = level
23- C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
20+ if w.useDomZoom {
21+ w.SeedDomZoom(level)
22+ script := fmt.Sprintf(`(function(){try{const lvl=%[1]f;window.__dumber_dom_zoom_level=lvl;if(window.__dumber_applyDomZoom){window.__dumber_applyDomZoom(lvl);}else{if(Math.abs(lvl-1.0)<1e-6){document.documentElement.style.zoom='';if(document.body){document.body.style.zoom='';}}else{const value=lvl.toString();document.documentElement.style.zoom=value;if(document.body){document.body.style.zoom=value;}}}}catch(e){console.error('[dumber] DOM zoom error',e);}})();`, level)
23+ if err := w.InjectScript(script); err != nil {
24+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
25+ }
26+ } else {
27+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
28+ }
2429 w.dispatchZoomChanged(w.zoom)
2530 return nil
2631 }
@@ -29,6 +34,9 @@ func (w *WebView) GetZoom() (float64, error) {
2934 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
3035 return 1.0, ErrNotImplemented
3136 }
37+ if w.useDomZoom {
38+ return w.zoom, nil
39+ }
3240 z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
3341 w.zoom = z
3442 return z, nil
@@ -9,18 +9,23 @@ package webkit
9 */9 */
10 import "C"10 import "C"
11 11
12+import "fmt"
13+
12 func (w *WebView) SetZoom(level float64) error {14 func (w *WebView) SetZoom(level float64) error {
13 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {15 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
14 return ErrNotImplemented16 return ErrNotImplemented
15 }17 }
16- if level < 0.25 {18+ level = clampDomZoom(level)
17- level = 0.25
18- }
19- if level > 5.0 {
20- level = 5.0
21- }
22 w.zoom = level19 w.zoom = level
23- C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))20+ if w.useDomZoom {
21+ w.SeedDomZoom(level)
22+ script := fmt.Sprintf(`(function(){try{const lvl=%[1]f;window.__dumber_dom_zoom_level=lvl;if(window.__dumber_applyDomZoom){window.__dumber_applyDomZoom(lvl);}else{if(Math.abs(lvl-1.0)<1e-6){document.documentElement.style.zoom='';if(document.body){document.body.style.zoom='';}}else{const value=lvl.toString();document.documentElement.style.zoom=value;if(document.body){document.body.style.zoom=value;}}}}catch(e){console.error('[dumber] DOM zoom error',e);}})();`, level)
23+ if err := w.InjectScript(script); err != nil {
24+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
25+ }
26+ } else {
27+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
28+ }
24 w.dispatchZoomChanged(w.zoom)29 w.dispatchZoomChanged(w.zoom)
25 return nil30 return nil
26 }31 }
@@ -29,6 +34,9 @@ func (w *WebView) GetZoom() (float64, error) {
29 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {34 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
30 return 1.0, ErrNotImplemented35 return 1.0, ErrNotImplemented
31 }36 }
37+ if w.useDomZoom {
38+ return w.zoom, nil
39+ }
32 z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))40 z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
33 w.zoom = z41 w.zoom = z
34 return z, nil42 return z, nil
M services/config_service.go +15 -0
@@ -25,6 +25,7 @@ type ConfigInfo struct {
2525 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
2626 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
2727 RenderingMode string `json:"rendering_mode"`
28+ UseDomZoom bool `json:"use_dom_zoom"`
2829 }
2930
3031 // NewConfigService creates a new ConfigService instance.
@@ -44,6 +45,7 @@ func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error)
4445 SearchShortcuts: s.config.SearchShortcuts,
4546 DmenuSettings: &s.config.Dmenu,
4647 RenderingMode: string(s.config.RenderingMode),
48+ UseDomZoom: s.config.UseDomZoom,
4749 }, nil
4850 }
4951
@@ -272,6 +274,19 @@ func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error
272274 }
273275 }
274276
277+// GetUseDomZoom reports whether DOM-based zoom is enabled.
278+func (s *ConfigService) GetUseDomZoom(ctx context.Context) (bool, error) {
279+ _ = ctx
280+ return s.config.UseDomZoom, nil
281+}
282+
283+// SetUseDomZoom toggles DOM-based zoom.
284+func (s *ConfigService) SetUseDomZoom(ctx context.Context, enabled bool) error {
285+ _ = ctx
286+ s.config.UseDomZoom = enabled
287+ return s.saveConfig()
288+}
289+
275290 // saveConfig saves the current configuration to file.
276291 func (s *ConfigService) saveConfig() error {
277292 // For now, return success - implementing config saving would require
@@ -25,6 +25,7 @@ type ConfigInfo struct {
25 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`25 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
26 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`26 DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
27 RenderingMode string `json:"rendering_mode"`27 RenderingMode string `json:"rendering_mode"`
28+ UseDomZoom bool `json:"use_dom_zoom"`
28 }29 }
29 30
30 // NewConfigService creates a new ConfigService instance.31 // NewConfigService creates a new ConfigService instance.
@@ -44,6 +45,7 @@ func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error)
44 SearchShortcuts: s.config.SearchShortcuts,45 SearchShortcuts: s.config.SearchShortcuts,
45 DmenuSettings: &s.config.Dmenu,46 DmenuSettings: &s.config.Dmenu,
46 RenderingMode: string(s.config.RenderingMode),47 RenderingMode: string(s.config.RenderingMode),
48+ UseDomZoom: s.config.UseDomZoom,
47 }, nil49 }, nil
48 }50 }
49 51
@@ -272,6 +274,19 @@ func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error
272 }274 }
273 }275 }
274 276
277+// GetUseDomZoom reports whether DOM-based zoom is enabled.
278+func (s *ConfigService) GetUseDomZoom(ctx context.Context) (bool, error) {
279+ _ = ctx
280+ return s.config.UseDomZoom, nil
281+}
282+
283+// SetUseDomZoom toggles DOM-based zoom.
284+func (s *ConfigService) SetUseDomZoom(ctx context.Context, enabled bool) error {
285+ _ = ctx
286+ s.config.UseDomZoom = enabled
287+ return s.saveConfig()
288+}
289+
275 // saveConfig saves the current configuration to file.290 // saveConfig saves the current configuration to file.
276 func (s *ConfigService) saveConfig() error {291 func (s *ConfigService) saveConfig() error {
277 // For now, return success - implementing config saving would require292 // For now, return success - implementing config saving would require

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