Merge branch 'main' into chore/diagnostic-window-launch-logs
19 files · +196 -227
M docs/config/index.md +4 -2
| @@ -195,14 +195,16 @@ border = "#363636" | |||
| 195 | 195 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default | | |
| 196 | 196 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF | | |
| 197 | 197 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | Mouse wheel scroll sensitivity multiplier | | |
| 198 | - | | `engine.cef.input.scroll_touchpad_multiplier` | float | `0.35` | > 0 | Touchpad/smooth scroll sensitivity multiplier | | |
| 198 | + | | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | Precise/surface scroll sensitivity multiplier | | |
| 199 | 199 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | Horizontal scroll sensitivity multiplier | | |
| 200 | 200 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | Vertical scroll sensitivity multiplier | | |
| 201 | 201 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | Maximum absolute scroll delta after scaling; 0 disables clamping | | |
| 202 | 202 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | |
| 203 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `80.0` | > 0 | Minimum accumulated horizontal swipe delta required for navigation | | |
| 203 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | Minimum accumulated horizontal swipe delta required for navigation | | |
| 204 | 204 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | |
| 205 | 205 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | |
| 206 | + | ||
| 207 | + | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `200.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | |
| 206 | 208 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | |
| 207 | 209 | ||
| 208 | 210 | ## Workspace Configuration | |
| @@ -195,14 +195,16 @@ border = "#363636" | |||||
| 195 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default | | 195 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default | | ||
| 196 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF | | 196 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF | | ||
| 197 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | Mouse wheel scroll sensitivity multiplier | | 197 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | Mouse wheel scroll sensitivity multiplier | | ||
| 198 | - | | `engine.cef.input.scroll_touchpad_multiplier` | float | `0.35` | > 0 | Touchpad/smooth scroll sensitivity multiplier | | 198 | + | | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | Precise/surface scroll sensitivity multiplier | |
| 199 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | Horizontal scroll sensitivity multiplier | | 199 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | Horizontal scroll sensitivity multiplier | | ||
| 200 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | Vertical scroll sensitivity multiplier | | 200 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | Vertical scroll sensitivity multiplier | | ||
| 201 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | Maximum absolute scroll delta after scaling; 0 disables clamping | | 201 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | Maximum absolute scroll delta after scaling; 0 disables clamping | | ||
| 202 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | 202 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | ||
| 203 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `80.0` | > 0 | Minimum accumulated horizontal swipe delta required for navigation | | 203 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | Minimum accumulated horizontal swipe delta required for navigation | |
| 204 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | 204 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | ||
| 205 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | 205 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | ||
| 206 | + | ||||
| 207 | + | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `200.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | |||
| 206 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | 208 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | ||
| 207 | 209 | ||||
| 208 | ## Workspace Configuration | 210 | ## Workspace Configuration | ||
M docs/reference/configuration.md +4 -2
| @@ -65,14 +65,16 @@ | |||
| 65 | 65 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |
| 66 | 66 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | |
| 67 | 67 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | | |
| 68 | - | | `engine.cef.input.scroll_touchpad_multiplier` | float | `0.35` | > 0 | | |
| 68 | + | | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | | |
| 69 | 69 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | | |
| 70 | 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | |
| 71 | 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | |
| 72 | 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | |
| 73 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `80.0` | > 0 | | |
| 73 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | | |
| 74 | 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | |
| 75 | 75 | | `default_ui_scale` | float | `1.0` | > 0 | | |
| 76 | + | ||
| 77 | + | `engine.cef.input.touchpad_navigation_min_delta` is measured in raw GTK touchpad surface units. The default `200.0` matches WebKit-style commit distance to reduce accidental back/forward navigation and can be overridden in `config.toml`. | |
| 76 | 78 | | `default_webpage_zoom` | float | `1.2` | > 0 | | |
| 77 | 79 | | `workspace.new_pane_url` | string | `about:blank` | | | |
| 78 | 80 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | |
| @@ -65,14 +65,16 @@ | |||||
| 65 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | 65 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | ||
| 66 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | 66 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | ||
| 67 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | | 67 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | | ||
| 68 | - | | `engine.cef.input.scroll_touchpad_multiplier` | float | `0.35` | > 0 | | 68 | + | | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | |
| 69 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | | 69 | | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 | | ||
| 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | ||
| 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | ||
| 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | ||
| 73 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `80.0` | > 0 | | 73 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | |
| 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | ||
| 75 | | `default_ui_scale` | float | `1.0` | > 0 | | 75 | | `default_ui_scale` | float | `1.0` | > 0 | | ||
| 76 | + | ||||
| 77 | + | `engine.cef.input.touchpad_navigation_min_delta` is measured in raw GTK touchpad surface units. The default `200.0` matches WebKit-style commit distance to reduce accidental back/forward navigation and can be overridden in `config.toml`. | |||
| 76 | | `default_webpage_zoom` | float | `1.2` | > 0 | | 78 | | `default_webpage_zoom` | float | `1.2` | > 0 | | ||
| 77 | | `workspace.new_pane_url` | string | `about:blank` | | | 79 | | `workspace.new_pane_url` | string | `about:blank` | | | ||
| 78 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | 80 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | ||
M go.mod +1 -1
| @@ -7,7 +7,7 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.3 | |
| 9 | 9 | github.com/bnema/purego-cef v0.13.2 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.7.0 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.8.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.4 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.3 | |
| 13 | 13 | github.com/bnema/puregotk v0.6.0 | |
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.3 | 8 | github.com/bnema/purego v0.11.0-bnema.3 | ||
| 9 | github.com/bnema/purego-cef v0.13.2 | 9 | github.com/bnema/purego-cef v0.13.2 | ||
| 10 | - | github.com/bnema/purego-cef2gtk v0.7.0 | 10 | + | github.com/bnema/purego-cef2gtk v0.8.0 |
| 11 | github.com/bnema/purego-pipewire v0.1.4 | 11 | github.com/bnema/purego-pipewire v0.1.4 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.3 | 12 | github.com/bnema/purego-sqlite v0.1.3 | ||
| 13 | github.com/bnema/puregotk v0.6.0 | 13 | github.com/bnema/puregotk v0.6.0 | ||
M go.sum +2 -2
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.3 h1:lpTXuapRIBH2OTgLJTjYlK1olHmZkEQLloqgP | |||
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.3/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | |
| 15 | 15 | github.com/bnema/purego-cef v0.13.2 h1:weSttuJVxjyzp/A5J15ZdzOAVWO/jvvd6nBf8/N4zsI= | |
| 16 | 16 | github.com/bnema/purego-cef v0.13.2/go.mod h1:Jbc4GgUvGakEOfbRnXJGYrDbXz03YEpVgTWKPk5ss9k= | |
| 17 | - | github.com/bnema/purego-cef2gtk v0.7.0 h1:sv8VUB8VIqwrwV9M5E2u6pZ24ly4+erCeooRyCMz1mc= | |
| 18 | - | github.com/bnema/purego-cef2gtk v0.7.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A= | |
| 17 | + | github.com/bnema/purego-cef2gtk v0.8.0 h1:Fd1umUyyPuIqmqyg252dChcv6XJtYVsudleEZ7we9LY= | |
| 18 | + | github.com/bnema/purego-cef2gtk v0.8.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A= | |
| 19 | 19 | github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk= | |
| 20 | 20 | github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk= | |
| 21 | 21 | github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY= | |
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.3 h1:lpTXuapRIBH2OTgLJTjYlK1olHmZkEQLloqgP | |||||
| 14 | github.com/bnema/purego v0.11.0-bnema.3/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | 14 | github.com/bnema/purego v0.11.0-bnema.3/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | ||
| 15 | github.com/bnema/purego-cef v0.13.2 h1:weSttuJVxjyzp/A5J15ZdzOAVWO/jvvd6nBf8/N4zsI= | 15 | github.com/bnema/purego-cef v0.13.2 h1:weSttuJVxjyzp/A5J15ZdzOAVWO/jvvd6nBf8/N4zsI= | ||
| 16 | github.com/bnema/purego-cef v0.13.2/go.mod h1:Jbc4GgUvGakEOfbRnXJGYrDbXz03YEpVgTWKPk5ss9k= | 16 | github.com/bnema/purego-cef v0.13.2/go.mod h1:Jbc4GgUvGakEOfbRnXJGYrDbXz03YEpVgTWKPk5ss9k= | ||
| 17 | - | github.com/bnema/purego-cef2gtk v0.7.0 h1:sv8VUB8VIqwrwV9M5E2u6pZ24ly4+erCeooRyCMz1mc= | 17 | + | github.com/bnema/purego-cef2gtk v0.8.0 h1:Fd1umUyyPuIqmqyg252dChcv6XJtYVsudleEZ7we9LY= |
| 18 | - | github.com/bnema/purego-cef2gtk v0.7.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A= | 18 | + | github.com/bnema/purego-cef2gtk v0.8.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A= |
| 19 | github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk= | 19 | github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk= | ||
| 20 | github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk= | 20 | github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk= | ||
| 21 | github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY= | 21 | github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY= | ||
M internal/application/usecase/favicon_test.go +50 -6
| @@ -238,6 +238,36 @@ func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) { | |||
| 238 | 238 | } | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | + | func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) { | |
| 242 | + | fx := newFaviconFixture() | |
| 243 | + | fx.fetcher.responses = map[string]fakeFetchedIcon{ | |
| 244 | + | "https://cdn.example.net/icon.svg": {bytes: []byte("svg"), contentType: "image/svg+xml"}, | |
| 245 | + | "https://cdn.example.net/icon.png": {bytes: []byte("png"), contentType: "image/png"}, | |
| 246 | + | } | |
| 247 | + | fx.converter.errByContentType = map[string]error{"image/svg+xml": ErrFaviconMiss} | |
| 248 | + | ||
| 249 | + | err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{ | |
| 250 | + | "https://cdn.example.net/icon.svg", | |
| 251 | + | "https://cdn.example.net/icon.png", | |
| 252 | + | }) | |
| 253 | + | if err != nil { | |
| 254 | + | t.Fatal(err) | |
| 255 | + | } | |
| 256 | + | if fx.fetcher.calls != 2 { | |
| 257 | + | t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls) | |
| 258 | + | } | |
| 259 | + | meta := fx.repo.byKey["docs.example.com"] | |
| 260 | + | if meta == nil { | |
| 261 | + | t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey) | |
| 262 | + | } | |
| 263 | + | if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" { | |
| 264 | + | t.Fatalf("stored meta = %+v, want png candidate", meta) | |
| 265 | + | } | |
| 266 | + | if string(fx.blobs.original["docs.example.com"]) != "png" { | |
| 267 | + | t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com"]) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 241 | 271 | func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) { | |
| 242 | 272 | fx := newFaviconFixture() | |
| 243 | 273 | fx.seedOriginal([]byte("old")) | |
| @@ -462,15 +492,19 @@ func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error | |||
| 462 | 492 | } | |
| 463 | 493 | ||
| 464 | 494 | type fakeConverter struct { | |
| 465 | - | calls int | |
| 466 | - | err error | |
| 495 | + | calls int | |
| 496 | + | err error | |
| 497 | + | errByContentType map[string]error | |
| 467 | 498 | } | |
| 468 | 499 | ||
| 469 | - | func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) { | |
| 500 | + | func (c *fakeConverter) Convert(_ context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) { | |
| 470 | 501 | c.calls++ | |
| 471 | 502 | if c.err != nil { | |
| 472 | 503 | return nil, c.err | |
| 473 | 504 | } | |
| 505 | + | if err := c.errByContentType[contentType]; err != nil { | |
| 506 | + | return nil, err | |
| 507 | + | } | |
| 474 | 508 | out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}} | |
| 475 | 509 | for _, s := range sizes { | |
| 476 | 510 | out.SizedPNG[s] = append([]byte("png32:"), original...) | |
| @@ -514,9 +548,15 @@ func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error | |||
| 514 | 548 | return nil | |
| 515 | 549 | } | |
| 516 | 550 | ||
| 551 | + | type fakeFetchedIcon struct { | |
| 552 | + | bytes []byte | |
| 553 | + | contentType string | |
| 554 | + | } | |
| 555 | + | ||
| 517 | 556 | type fakeFetcher struct { | |
| 518 | - | calls int | |
| 519 | - | err error | |
| 557 | + | calls int | |
| 558 | + | err error | |
| 559 | + | responses map[string]fakeFetchedIcon | |
| 520 | 560 | } | |
| 521 | 561 | ||
| 522 | 562 | func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) { | |
| @@ -528,5 +568,9 @@ func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) | |||
| 528 | 568 | if iconURL == "" { | |
| 529 | 569 | iconURL = "https://example.com/favicon.ico" | |
| 530 | 570 | } | |
| 531 | - | return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil | |
| 571 | + | response := f.responses[iconURL] | |
| 572 | + | if response.bytes == nil { | |
| 573 | + | response = fakeFetchedIcon{bytes: []byte("fetched"), contentType: "image/png"} | |
| 574 | + | } | |
| 575 | + | return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil | |
| 532 | 576 | } | |
| @@ -238,6 +238,36 @@ func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) { | |||||
| 238 | } | 238 | } | ||
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | + | func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) { | |||
| 242 | + | fx := newFaviconFixture() | |||
| 243 | + | fx.fetcher.responses = map[string]fakeFetchedIcon{ | |||
| 244 | + | "https://cdn.example.net/icon.svg": {bytes: []byte("svg"), contentType: "image/svg+xml"}, | |||
| 245 | + | "https://cdn.example.net/icon.png": {bytes: []byte("png"), contentType: "image/png"}, | |||
| 246 | + | } | |||
| 247 | + | fx.converter.errByContentType = map[string]error{"image/svg+xml": ErrFaviconMiss} | |||
| 248 | + | ||||
| 249 | + | err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{ | |||
| 250 | + | "https://cdn.example.net/icon.svg", | |||
| 251 | + | "https://cdn.example.net/icon.png", | |||
| 252 | + | }) | |||
| 253 | + | if err != nil { | |||
| 254 | + | t.Fatal(err) | |||
| 255 | + | } | |||
| 256 | + | if fx.fetcher.calls != 2 { | |||
| 257 | + | t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls) | |||
| 258 | + | } | |||
| 259 | + | meta := fx.repo.byKey["docs.example.com"] | |||
| 260 | + | if meta == nil { | |||
| 261 | + | t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey) | |||
| 262 | + | } | |||
| 263 | + | if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" { | |||
| 264 | + | t.Fatalf("stored meta = %+v, want png candidate", meta) | |||
| 265 | + | } | |||
| 266 | + | if string(fx.blobs.original["docs.example.com"]) != "png" { | |||
| 267 | + | t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com"]) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 241 | func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) { | 271 | func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) { | ||
| 242 | fx := newFaviconFixture() | 272 | fx := newFaviconFixture() | ||
| 243 | fx.seedOriginal([]byte("old")) | 273 | fx.seedOriginal([]byte("old")) | ||
| @@ -462,15 +492,19 @@ func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error | |||||
| 462 | } | 492 | } | ||
| 463 | 493 | ||||
| 464 | type fakeConverter struct { | 494 | type fakeConverter struct { | ||
| 465 | - | calls int | 495 | + | calls int |
| 466 | - | err error | 496 | + | err error |
| 497 | + | errByContentType map[string]error | |||
| 467 | } | 498 | } | ||
| 468 | 499 | ||||
| 469 | - | func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) { | 500 | + | func (c *fakeConverter) Convert(_ context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) { |
| 470 | c.calls++ | 501 | c.calls++ | ||
| 471 | if c.err != nil { | 502 | if c.err != nil { | ||
| 472 | return nil, c.err | 503 | return nil, c.err | ||
| 473 | } | 504 | } | ||
| 505 | + | if err := c.errByContentType[contentType]; err != nil { | |||
| 506 | + | return nil, err | |||
| 507 | + | } | |||
| 474 | out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}} | 508 | out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}} | ||
| 475 | for _, s := range sizes { | 509 | for _, s := range sizes { | ||
| 476 | out.SizedPNG[s] = append([]byte("png32:"), original...) | 510 | out.SizedPNG[s] = append([]byte("png32:"), original...) | ||
| @@ -514,9 +548,15 @@ func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error | |||||
| 514 | return nil | 548 | return nil | ||
| 515 | } | 549 | } | ||
| 516 | 550 | ||||
| 551 | + | type fakeFetchedIcon struct { | |||
| 552 | + | bytes []byte | |||
| 553 | + | contentType string | |||
| 554 | + | } | |||
| 555 | + | ||||
| 517 | type fakeFetcher struct { | 556 | type fakeFetcher struct { | ||
| 518 | - | calls int | 557 | + | calls int |
| 519 | - | err error | 558 | + | err error |
| 559 | + | responses map[string]fakeFetchedIcon | |||
| 520 | } | 560 | } | ||
| 521 | 561 | ||||
| 522 | func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) { | 562 | func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) { | ||
| @@ -528,5 +568,9 @@ func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) | |||||
| 528 | if iconURL == "" { | 568 | if iconURL == "" { | ||
| 529 | iconURL = "https://example.com/favicon.ico" | 569 | iconURL = "https://example.com/favicon.ico" | ||
| 530 | } | 570 | } | ||
| 531 | - | return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil | 571 | + | response := f.responses[iconURL] |
| 572 | + | if response.bytes == nil { | |||
| 573 | + | response = fakeFetchedIcon{bytes: []byte("fetched"), contentType: "image/png"} | |||
| 574 | + | } | |||
| 575 | + | return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil | |||
| 532 | } | 576 | } | ||
M internal/bootstrap/engine.go +1 -1
| @@ -86,7 +86,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 86 | 86 | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), | |
| 87 | 87 | Input: cef.RuntimeInputConfig{ | |
| 88 | 88 | ScrollWheelMultiplier: cfg.Engine.CEF.Input.ScrollWheelMultiplier, | |
| 89 | - | ScrollTouchpadMultiplier: cfg.Engine.CEF.Input.ScrollTouchpadMultiplier, | |
| 89 | + | ScrollPreciseMultiplier: cfg.Engine.CEF.Input.ScrollPreciseMultiplier, | |
| 90 | 90 | ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, | |
| 91 | 91 | ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier, | |
| 92 | 92 | ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta, | |
| @@ -86,7 +86,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 86 | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), | 86 | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), | ||
| 87 | Input: cef.RuntimeInputConfig{ | 87 | Input: cef.RuntimeInputConfig{ | ||
| 88 | ScrollWheelMultiplier: cfg.Engine.CEF.Input.ScrollWheelMultiplier, | 88 | ScrollWheelMultiplier: cfg.Engine.CEF.Input.ScrollWheelMultiplier, | ||
| 89 | - | ScrollTouchpadMultiplier: cfg.Engine.CEF.Input.ScrollTouchpadMultiplier, | 89 | + | ScrollPreciseMultiplier: cfg.Engine.CEF.Input.ScrollPreciseMultiplier, |
| 90 | ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, | 90 | ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, | ||
| 91 | ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier, | 91 | ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier, | ||
| 92 | ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta, | 92 | ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta, | ||
M internal/infrastructure/cef/runtime_config.go +1 -1
| @@ -30,7 +30,7 @@ type RuntimeConfig struct { | |||
| 30 | 30 | ||
| 31 | 31 | type RuntimeInputConfig struct { | |
| 32 | 32 | ScrollWheelMultiplier float64 | |
| 33 | - | ScrollTouchpadMultiplier float64 | |
| 33 | + | ScrollPreciseMultiplier float64 | |
| 34 | 34 | ScrollHorizontalMultiplier float64 | |
| 35 | 35 | ScrollVerticalMultiplier float64 | |
| 36 | 36 | ScrollMaxDelta int32 | |
| @@ -30,7 +30,7 @@ type RuntimeConfig struct { | |||||
| 30 | 30 | ||||
| 31 | type RuntimeInputConfig struct { | 31 | type RuntimeInputConfig struct { | ||
| 32 | ScrollWheelMultiplier float64 | 32 | ScrollWheelMultiplier float64 | ||
| 33 | - | ScrollTouchpadMultiplier float64 | 33 | + | ScrollPreciseMultiplier float64 |
| 34 | ScrollHorizontalMultiplier float64 | 34 | ScrollHorizontalMultiplier float64 | ||
| 35 | ScrollVerticalMultiplier float64 | 35 | ScrollVerticalMultiplier float64 | ||
| 36 | ScrollMaxDelta int32 | 36 | ScrollMaxDelta int32 | ||
M internal/infrastructure/cef/webview.go +82 -93
| @@ -225,8 +225,16 @@ type WebView struct { | |||
| 225 | 225 | windowlessFrameRate int32 | |
| 226 | 226 | backgroundColor uint32 | |
| 227 | 227 | inputConfig RuntimeInputConfig | |
| 228 | - | touchpadSwipeDX float64 | |
| 229 | - | touchpadSwipeDY float64 | |
| 228 | + | ||
| 229 | + | // Touchpad input diagnostics are debounced to avoid flooding logs while | |
| 230 | + | // debugging continuous scroll streams. | |
| 231 | + | inputDiagMu sync.Mutex | |
| 232 | + | inputDiagLastLog time.Time | |
| 233 | + | inputDiagEvents int | |
| 234 | + | inputDiagDX float64 | |
| 235 | + | inputDiagDY float64 | |
| 236 | + | inputDiagDeltaXSum int64 | |
| 237 | + | inputDiagDeltaYSum int64 | |
| 230 | 238 | ||
| 231 | 239 | // Audio output factory and active stream. | |
| 232 | 240 | audioOutputFactory port.AudioOutputFactory | |
| @@ -1462,7 +1470,7 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||
| 1462 | 1470 | Scale: 0, | |
| 1463 | 1471 | Scroll: cef2gtk.ScrollOptions{ | |
| 1464 | 1472 | WheelMultiplier: wv.inputConfig.ScrollWheelMultiplier, | |
| 1465 | - | TouchpadMultiplier: wv.inputConfig.ScrollTouchpadMultiplier, | |
| 1473 | + | PreciseMultiplier: wv.inputConfig.ScrollPreciseMultiplier, | |
| 1466 | 1474 | HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier, | |
| 1467 | 1475 | VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier, | |
| 1468 | 1476 | MaxDelta: wv.inputConfig.ScrollMaxDelta, | |
| @@ -1470,13 +1478,16 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||
| 1470 | 1478 | OnMiddleClick: func(_, _ float64) bool { | |
| 1471 | 1479 | return wv.handleMiddleClickFromBridge() | |
| 1472 | 1480 | }, | |
| 1473 | - | OnTouchpadSwipe: func(event cef2gtk.TouchpadSwipeEvent) cef2gtk.TouchpadSwipeDecision { | |
| 1474 | - | if wv.handleTouchpadNavigationSwipe(event) { | |
| 1475 | - | return cef2gtk.TouchpadSwipeConsume | |
| 1476 | - | } | |
| 1477 | - | return cef2gtk.TouchpadSwipePassthrough | |
| 1481 | + | OnScroll: wv.handleScrollInputDiagnostic, | |
| 1482 | + | NavigationSwipe: cef2gtk.NavigationSwipeOptions{ | |
| 1483 | + | Enabled: wv.inputConfig.TouchpadNavigationEnabled, | |
| 1484 | + | MinDelta: wv.inputConfig.TouchpadNavigationMinDelta, | |
| 1485 | + | MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio, | |
| 1478 | 1486 | }, | |
| 1479 | - | SelectionText: wv.selectedTextSnapshot, | |
| 1487 | + | CanNavigateBack: wv.CanGoBack, | |
| 1488 | + | CanNavigateForward: wv.CanGoForward, | |
| 1489 | + | OnNavigateSwipe: wv.handleNavigationSwipeAction, | |
| 1490 | + | SelectionText: wv.selectedTextSnapshot, | |
| 1480 | 1491 | OnClipboardShortcut: func(action, text string) { | |
| 1481 | 1492 | if wv.engine != nil { | |
| 1482 | 1493 | wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| @@ -1485,95 +1496,73 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||
| 1485 | 1496 | } | |
| 1486 | 1497 | } | |
| 1487 | 1498 | ||
| 1488 | - | type touchpadNavigationAction int | |
| 1489 | - | ||
| 1490 | - | const ( | |
| 1491 | - | touchpadNavigationNone touchpadNavigationAction = iota | |
| 1492 | - | touchpadNavigationBack | |
| 1493 | - | touchpadNavigationForward | |
| 1494 | - | ) | |
| 1495 | - | ||
| 1496 | - | func (wv *WebView) handleTouchpadNavigationSwipe(event cef2gtk.TouchpadSwipeEvent) bool { | |
| 1497 | - | cfg := wv.inputConfig | |
| 1498 | - | if !cfg.TouchpadNavigationEnabled || event.Fingers != 2 { | |
| 1499 | - | wv.resetTouchpadNavigationSwipe() | |
| 1500 | - | return false | |
| 1499 | + | func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision { | |
| 1500 | + | if event.Phase != cef2gtk.ScrollPhaseUpdate || math.Abs(event.DX) == 0 { | |
| 1501 | + | return cef2gtk.ScrollForwardToCEF | |
| 1501 | 1502 | } | |
| 1502 | 1503 | ||
| 1503 | - | switch event.Phase { | |
| 1504 | - | case cef2gtk.TouchpadGesturePhaseBegin: | |
| 1505 | - | wv.resetTouchpadNavigationSwipe() | |
| 1506 | - | return false | |
| 1507 | - | case cef2gtk.TouchpadGesturePhaseUpdate: | |
| 1508 | - | wv.accumulateTouchpadNavigationSwipe(event.DX, event.DY) | |
| 1509 | - | return false | |
| 1510 | - | case cef2gtk.TouchpadGesturePhaseEnd: | |
| 1511 | - | dx, dy := wv.finishTouchpadNavigationSwipe() | |
| 1512 | - | switch chooseTouchpadNavigationAction(cfg, dx, dy, wv.CanGoBack(), wv.CanGoForward()) { | |
| 1513 | - | case touchpadNavigationBack: | |
| 1514 | - | if err := wv.GoBack(wv.ctx); err != nil { | |
| 1515 | - | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad back navigation failed") | |
| 1516 | - | return false | |
| 1517 | - | } | |
| 1518 | - | return true | |
| 1519 | - | case touchpadNavigationForward: | |
| 1520 | - | if err := wv.GoForward(wv.ctx); err != nil { | |
| 1521 | - | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad forward navigation failed") | |
| 1522 | - | return false | |
| 1523 | - | } | |
| 1524 | - | return true | |
| 1525 | - | default: | |
| 1526 | - | return false | |
| 1527 | - | } | |
| 1528 | - | case cef2gtk.TouchpadGesturePhaseCancel: | |
| 1529 | - | wv.resetTouchpadNavigationSwipe() | |
| 1530 | - | return false | |
| 1531 | - | default: | |
| 1532 | - | return false | |
| 1504 | + | wv.inputDiagMu.Lock() | |
| 1505 | + | wv.inputDiagEvents++ | |
| 1506 | + | wv.inputDiagDX += event.DX | |
| 1507 | + | wv.inputDiagDY += event.DY | |
| 1508 | + | wv.inputDiagDeltaXSum += int64(event.DeltaX) | |
| 1509 | + | wv.inputDiagDeltaYSum += int64(event.DeltaY) | |
| 1510 | + | now := time.Now() | |
| 1511 | + | if !wv.inputDiagLastLog.IsZero() && now.Sub(wv.inputDiagLastLog) < time.Second { | |
| 1512 | + | wv.inputDiagMu.Unlock() | |
| 1513 | + | return cef2gtk.ScrollForwardToCEF | |
| 1514 | + | } | |
| 1515 | + | count := wv.inputDiagEvents | |
| 1516 | + | dx, dy := wv.inputDiagDX, wv.inputDiagDY | |
| 1517 | + | deltaX, deltaY := wv.inputDiagDeltaXSum, wv.inputDiagDeltaYSum | |
| 1518 | + | wv.inputDiagEvents = 0 | |
| 1519 | + | wv.inputDiagDX = 0 | |
| 1520 | + | wv.inputDiagDY = 0 | |
| 1521 | + | wv.inputDiagDeltaXSum = 0 | |
| 1522 | + | wv.inputDiagDeltaYSum = 0 | |
| 1523 | + | wv.inputDiagLastLog = now | |
| 1524 | + | wv.inputDiagMu.Unlock() | |
| 1525 | + | ||
| 1526 | + | logging.FromContext(wv.ctx).Trace(). | |
| 1527 | + | Uint64("webview_id", uint64(wv.id)). | |
| 1528 | + | Int("events", count). | |
| 1529 | + | Str("phase", fmt.Sprint(event.Phase)). | |
| 1530 | + | Str("unit", fmt.Sprint(event.Unit)). | |
| 1531 | + | Bool("unit_known", event.UnitKnown). | |
| 1532 | + | Float64("dx_sum", dx). | |
| 1533 | + | Float64("dy_sum", dy). | |
| 1534 | + | Int64("cef_delta_x_sum", deltaX). | |
| 1535 | + | Int64("cef_delta_y_sum", deltaY). | |
| 1536 | + | Bool("nav_enabled", wv.inputConfig.TouchpadNavigationEnabled). | |
| 1537 | + | Float64("nav_min_delta", wv.inputConfig.TouchpadNavigationMinDelta). | |
| 1538 | + | Float64("nav_max_vertical_ratio", wv.inputConfig.TouchpadNavigationMaxVerticalRatio). | |
| 1539 | + | Bool("can_go_back", wv.CanGoBack()). | |
| 1540 | + | Bool("can_go_forward", wv.CanGoForward()). | |
| 1541 | + | Msg("cef: touchpad horizontal scroll diagnostic") | |
| 1542 | + | ||
| 1543 | + | return cef2gtk.ScrollForwardToCEF | |
| 1544 | + | } | |
| 1545 | + | ||
| 1546 | + | func (wv *WebView) handleNavigationSwipeAction(action cef2gtk.NavigationSwipeAction) { | |
| 1547 | + | if wv.ctx != nil { | |
| 1548 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1549 | + | Uint64("webview_id", uint64(wv.id)). | |
| 1550 | + | Str("action", fmt.Sprint(action)). | |
| 1551 | + | Bool("can_go_back", wv.CanGoBack()). | |
| 1552 | + | Bool("can_go_forward", wv.CanGoForward()). | |
| 1553 | + | Msg("cef: touchpad navigation swipe recognized") | |
| 1533 | 1554 | } | |
| 1534 | - | } | |
| 1535 | - | ||
| 1536 | - | func (wv *WebView) resetTouchpadNavigationSwipe() { | |
| 1537 | - | wv.mu.Lock() | |
| 1538 | - | wv.touchpadSwipeDX = 0 | |
| 1539 | - | wv.touchpadSwipeDY = 0 | |
| 1540 | - | wv.mu.Unlock() | |
| 1541 | - | } | |
| 1542 | 1555 | ||
| 1543 | - | func (wv *WebView) accumulateTouchpadNavigationSwipe(dx, dy float64) { | |
| 1544 | - | wv.mu.Lock() | |
| 1545 | - | wv.touchpadSwipeDX += dx | |
| 1546 | - | wv.touchpadSwipeDY += dy | |
| 1547 | - | wv.mu.Unlock() | |
| 1548 | - | } | |
| 1549 | - | ||
| 1550 | - | func (wv *WebView) finishTouchpadNavigationSwipe() (float64, float64) { | |
| 1551 | - | wv.mu.Lock() | |
| 1552 | - | defer wv.mu.Unlock() | |
| 1553 | - | dx, dy := wv.touchpadSwipeDX, wv.touchpadSwipeDY | |
| 1554 | - | wv.touchpadSwipeDX = 0 | |
| 1555 | - | wv.touchpadSwipeDY = 0 | |
| 1556 | - | return dx, dy | |
| 1557 | - | } | |
| 1558 | - | ||
| 1559 | - | func chooseTouchpadNavigationAction(cfg RuntimeInputConfig, dx, dy float64, canGoBack, canGoForward bool) touchpadNavigationAction { | |
| 1560 | - | if !cfg.TouchpadNavigationEnabled { | |
| 1561 | - | return touchpadNavigationNone | |
| 1562 | - | } | |
| 1563 | - | absDX, absDY := math.Abs(dx), math.Abs(dy) | |
| 1564 | - | if absDX < cfg.TouchpadNavigationMinDelta { | |
| 1565 | - | return touchpadNavigationNone | |
| 1566 | - | } | |
| 1567 | - | if absDY > absDX*cfg.TouchpadNavigationMaxVerticalRatio { | |
| 1568 | - | return touchpadNavigationNone | |
| 1569 | - | } | |
| 1570 | - | if dx > 0 && canGoBack { | |
| 1571 | - | return touchpadNavigationBack | |
| 1572 | - | } | |
| 1573 | - | if dx < 0 && canGoForward { | |
| 1574 | - | return touchpadNavigationForward | |
| 1556 | + | switch action { | |
| 1557 | + | case cef2gtk.NavigationSwipeBack: | |
| 1558 | + | if err := wv.GoBack(wv.ctx); err != nil && wv.ctx != nil { | |
| 1559 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad back navigation failed") | |
| 1560 | + | } | |
| 1561 | + | case cef2gtk.NavigationSwipeForward: | |
| 1562 | + | if err := wv.GoForward(wv.ctx); err != nil && wv.ctx != nil { | |
| 1563 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad forward navigation failed") | |
| 1564 | + | } | |
| 1575 | 1565 | } | |
| 1576 | - | return touchpadNavigationNone | |
| 1577 | 1566 | } | |
| 1578 | 1567 | ||
| 1579 | 1568 | func (wv *WebView) viewBridgeScale() float64 { | |
| @@ -225,8 +225,16 @@ type WebView struct { | |||||
| 225 | windowlessFrameRate int32 | 225 | windowlessFrameRate int32 | ||
| 226 | backgroundColor uint32 | 226 | backgroundColor uint32 | ||
| 227 | inputConfig RuntimeInputConfig | 227 | inputConfig RuntimeInputConfig | ||
| 228 | - | touchpadSwipeDX float64 | 228 | + | |
| 229 | - | touchpadSwipeDY float64 | 229 | + | // Touchpad input diagnostics are debounced to avoid flooding logs while |
| 230 | + | // debugging continuous scroll streams. | |||
| 231 | + | inputDiagMu sync.Mutex | |||
| 232 | + | inputDiagLastLog time.Time | |||
| 233 | + | inputDiagEvents int | |||
| 234 | + | inputDiagDX float64 | |||
| 235 | + | inputDiagDY float64 | |||
| 236 | + | inputDiagDeltaXSum int64 | |||
| 237 | + | inputDiagDeltaYSum int64 | |||
| 230 | 238 | ||||
| 231 | // Audio output factory and active stream. | 239 | // Audio output factory and active stream. | ||
| 232 | audioOutputFactory port.AudioOutputFactory | 240 | audioOutputFactory port.AudioOutputFactory | ||
| @@ -1462,7 +1470,7 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||||
| 1462 | Scale: 0, | 1470 | Scale: 0, | ||
| 1463 | Scroll: cef2gtk.ScrollOptions{ | 1471 | Scroll: cef2gtk.ScrollOptions{ | ||
| 1464 | WheelMultiplier: wv.inputConfig.ScrollWheelMultiplier, | 1472 | WheelMultiplier: wv.inputConfig.ScrollWheelMultiplier, | ||
| 1465 | - | TouchpadMultiplier: wv.inputConfig.ScrollTouchpadMultiplier, | 1473 | + | PreciseMultiplier: wv.inputConfig.ScrollPreciseMultiplier, |
| 1466 | HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier, | 1474 | HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier, | ||
| 1467 | VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier, | 1475 | VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier, | ||
| 1468 | MaxDelta: wv.inputConfig.ScrollMaxDelta, | 1476 | MaxDelta: wv.inputConfig.ScrollMaxDelta, | ||
| @@ -1470,13 +1478,16 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||||
| 1470 | OnMiddleClick: func(_, _ float64) bool { | 1478 | OnMiddleClick: func(_, _ float64) bool { | ||
| 1471 | return wv.handleMiddleClickFromBridge() | 1479 | return wv.handleMiddleClickFromBridge() | ||
| 1472 | }, | 1480 | }, | ||
| 1473 | - | OnTouchpadSwipe: func(event cef2gtk.TouchpadSwipeEvent) cef2gtk.TouchpadSwipeDecision { | 1481 | + | OnScroll: wv.handleScrollInputDiagnostic, |
| 1474 | - | if wv.handleTouchpadNavigationSwipe(event) { | 1482 | + | NavigationSwipe: cef2gtk.NavigationSwipeOptions{ |
| 1475 | - | return cef2gtk.TouchpadSwipeConsume | 1483 | + | Enabled: wv.inputConfig.TouchpadNavigationEnabled, |
| 1476 | - | } | 1484 | + | MinDelta: wv.inputConfig.TouchpadNavigationMinDelta, |
| 1477 | - | return cef2gtk.TouchpadSwipePassthrough | 1485 | + | MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio, |
| 1478 | }, | 1486 | }, | ||
| 1479 | - | SelectionText: wv.selectedTextSnapshot, | 1487 | + | CanNavigateBack: wv.CanGoBack, |
| 1488 | + | CanNavigateForward: wv.CanGoForward, | |||
| 1489 | + | OnNavigateSwipe: wv.handleNavigationSwipeAction, | |||
| 1490 | + | SelectionText: wv.selectedTextSnapshot, | |||
| 1480 | OnClipboardShortcut: func(action, text string) { | 1491 | OnClipboardShortcut: func(action, text string) { | ||
| 1481 | if wv.engine != nil { | 1492 | if wv.engine != nil { | ||
| 1482 | wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | 1493 | wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | ||
| @@ -1485,95 +1496,73 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||||
| 1485 | } | 1496 | } | ||
| 1486 | } | 1497 | } | ||
| 1487 | 1498 | ||||
| 1488 | - | type touchpadNavigationAction int | 1499 | + | func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision { |
| 1489 | - | 1500 | + | if event.Phase != cef2gtk.ScrollPhaseUpdate || math.Abs(event.DX) == 0 { | |
| 1490 | - | const ( | 1501 | + | return cef2gtk.ScrollForwardToCEF |
| 1491 | - | touchpadNavigationNone touchpadNavigationAction = iota | |||
| 1492 | - | touchpadNavigationBack | |||
| 1493 | - | touchpadNavigationForward | |||
| 1494 | - | ) | |||
| 1495 | - | ||||
| 1496 | - | func (wv *WebView) handleTouchpadNavigationSwipe(event cef2gtk.TouchpadSwipeEvent) bool { | |||
| 1497 | - | cfg := wv.inputConfig | |||
| 1498 | - | if !cfg.TouchpadNavigationEnabled || event.Fingers != 2 { | |||
| 1499 | - | wv.resetTouchpadNavigationSwipe() | |||
| 1500 | - | return false | |||
| 1501 | } | 1502 | } | ||
| 1502 | 1503 | ||||
| 1503 | - | switch event.Phase { | 1504 | + | wv.inputDiagMu.Lock() |
| 1504 | - | case cef2gtk.TouchpadGesturePhaseBegin: | 1505 | + | wv.inputDiagEvents++ |
| 1505 | - | wv.resetTouchpadNavigationSwipe() | 1506 | + | wv.inputDiagDX += event.DX |
| 1506 | - | return false | 1507 | + | wv.inputDiagDY += event.DY |
| 1507 | - | case cef2gtk.TouchpadGesturePhaseUpdate: | 1508 | + | wv.inputDiagDeltaXSum += int64(event.DeltaX) |
| 1508 | - | wv.accumulateTouchpadNavigationSwipe(event.DX, event.DY) | 1509 | + | wv.inputDiagDeltaYSum += int64(event.DeltaY) |
| 1509 | - | return false | 1510 | + | now := time.Now() |
| 1510 | - | case cef2gtk.TouchpadGesturePhaseEnd: | 1511 | + | if !wv.inputDiagLastLog.IsZero() && now.Sub(wv.inputDiagLastLog) < time.Second { |
| 1511 | - | dx, dy := wv.finishTouchpadNavigationSwipe() | 1512 | + | wv.inputDiagMu.Unlock() |
| 1512 | - | switch chooseTouchpadNavigationAction(cfg, dx, dy, wv.CanGoBack(), wv.CanGoForward()) { | 1513 | + | return cef2gtk.ScrollForwardToCEF |
| 1513 | - | case touchpadNavigationBack: | 1514 | + | } |
| 1514 | - | if err := wv.GoBack(wv.ctx); err != nil { | 1515 | + | count := wv.inputDiagEvents |
| 1515 | - | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad back navigation failed") | 1516 | + | dx, dy := wv.inputDiagDX, wv.inputDiagDY |
| 1516 | - | return false | 1517 | + | deltaX, deltaY := wv.inputDiagDeltaXSum, wv.inputDiagDeltaYSum |
| 1517 | - | } | 1518 | + | wv.inputDiagEvents = 0 |
| 1518 | - | return true | 1519 | + | wv.inputDiagDX = 0 |
| 1519 | - | case touchpadNavigationForward: | 1520 | + | wv.inputDiagDY = 0 |
| 1520 | - | if err := wv.GoForward(wv.ctx); err != nil { | 1521 | + | wv.inputDiagDeltaXSum = 0 |
| 1521 | - | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad forward navigation failed") | 1522 | + | wv.inputDiagDeltaYSum = 0 |
| 1522 | - | return false | 1523 | + | wv.inputDiagLastLog = now |
| 1523 | - | } | 1524 | + | wv.inputDiagMu.Unlock() |
| 1524 | - | return true | 1525 | + | |
| 1525 | - | default: | 1526 | + | logging.FromContext(wv.ctx).Trace(). |
| 1526 | - | return false | 1527 | + | Uint64("webview_id", uint64(wv.id)). |
| 1527 | - | } | 1528 | + | Int("events", count). |
| 1528 | - | case cef2gtk.TouchpadGesturePhaseCancel: | 1529 | + | Str("phase", fmt.Sprint(event.Phase)). |
| 1529 | - | wv.resetTouchpadNavigationSwipe() | 1530 | + | Str("unit", fmt.Sprint(event.Unit)). |
| 1530 | - | return false | 1531 | + | Bool("unit_known", event.UnitKnown). |
| 1531 | - | default: | 1532 | + | Float64("dx_sum", dx). |
| 1532 | - | return false | 1533 | + | Float64("dy_sum", dy). |
| 1534 | + | Int64("cef_delta_x_sum", deltaX). | |||
| 1535 | + | Int64("cef_delta_y_sum", deltaY). | |||
| 1536 | + | Bool("nav_enabled", wv.inputConfig.TouchpadNavigationEnabled). | |||
| 1537 | + | Float64("nav_min_delta", wv.inputConfig.TouchpadNavigationMinDelta). | |||
| 1538 | + | Float64("nav_max_vertical_ratio", wv.inputConfig.TouchpadNavigationMaxVerticalRatio). | |||
| 1539 | + | Bool("can_go_back", wv.CanGoBack()). | |||
| 1540 | + | Bool("can_go_forward", wv.CanGoForward()). | |||
| 1541 | + | Msg("cef: touchpad horizontal scroll diagnostic") | |||
| 1542 | + | ||||
| 1543 | + | return cef2gtk.ScrollForwardToCEF | |||
| 1544 | + | } | |||
| 1545 | + | ||||
| 1546 | + | func (wv *WebView) handleNavigationSwipeAction(action cef2gtk.NavigationSwipeAction) { | |||
| 1547 | + | if wv.ctx != nil { | |||
| 1548 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 1549 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 1550 | + | Str("action", fmt.Sprint(action)). | |||
| 1551 | + | Bool("can_go_back", wv.CanGoBack()). | |||
| 1552 | + | Bool("can_go_forward", wv.CanGoForward()). | |||
| 1553 | + | Msg("cef: touchpad navigation swipe recognized") | |||
| 1533 | } | 1554 | } | ||
| 1534 | - | } | |||
| 1535 | - | ||||
| 1536 | - | func (wv *WebView) resetTouchpadNavigationSwipe() { | |||
| 1537 | - | wv.mu.Lock() | |||
| 1538 | - | wv.touchpadSwipeDX = 0 | |||
| 1539 | - | wv.touchpadSwipeDY = 0 | |||
| 1540 | - | wv.mu.Unlock() | |||
| 1541 | - | } | |||
| 1542 | 1555 | ||||
| 1543 | - | func (wv *WebView) accumulateTouchpadNavigationSwipe(dx, dy float64) { | 1556 | + | switch action { |
| 1544 | - | wv.mu.Lock() | 1557 | + | case cef2gtk.NavigationSwipeBack: |
| 1545 | - | wv.touchpadSwipeDX += dx | 1558 | + | if err := wv.GoBack(wv.ctx); err != nil && wv.ctx != nil { |
| 1546 | - | wv.touchpadSwipeDY += dy | 1559 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad back navigation failed") |
| 1547 | - | wv.mu.Unlock() | 1560 | + | } |
| 1548 | - | } | 1561 | + | case cef2gtk.NavigationSwipeForward: |
| 1549 | - | 1562 | + | if err := wv.GoForward(wv.ctx); err != nil && wv.ctx != nil { | |
| 1550 | - | func (wv *WebView) finishTouchpadNavigationSwipe() (float64, float64) { | 1563 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad forward navigation failed") |
| 1551 | - | wv.mu.Lock() | 1564 | + | } |
| 1552 | - | defer wv.mu.Unlock() | |||
| 1553 | - | dx, dy := wv.touchpadSwipeDX, wv.touchpadSwipeDY | |||
| 1554 | - | wv.touchpadSwipeDX = 0 | |||
| 1555 | - | wv.touchpadSwipeDY = 0 | |||
| 1556 | - | return dx, dy | |||
| 1557 | - | } | |||
| 1558 | - | ||||
| 1559 | - | func chooseTouchpadNavigationAction(cfg RuntimeInputConfig, dx, dy float64, canGoBack, canGoForward bool) touchpadNavigationAction { | |||
| 1560 | - | if !cfg.TouchpadNavigationEnabled { | |||
| 1561 | - | return touchpadNavigationNone | |||
| 1562 | - | } | |||
| 1563 | - | absDX, absDY := math.Abs(dx), math.Abs(dy) | |||
| 1564 | - | if absDX < cfg.TouchpadNavigationMinDelta { | |||
| 1565 | - | return touchpadNavigationNone | |||
| 1566 | - | } | |||
| 1567 | - | if absDY > absDX*cfg.TouchpadNavigationMaxVerticalRatio { | |||
| 1568 | - | return touchpadNavigationNone | |||
| 1569 | - | } | |||
| 1570 | - | if dx > 0 && canGoBack { | |||
| 1571 | - | return touchpadNavigationBack | |||
| 1572 | - | } | |||
| 1573 | - | if dx < 0 && canGoForward { | |||
| 1574 | - | return touchpadNavigationForward | |||
| 1575 | } | 1565 | } | ||
| 1576 | - | return touchpadNavigationNone | |||
| 1577 | } | 1566 | } | ||
| 1578 | 1567 | ||||
| 1579 | func (wv *WebView) viewBridgeScale() float64 { | 1568 | func (wv *WebView) viewBridgeScale() float64 { | ||
M internal/infrastructure/cef/webview_input_test.go +9 -40
| @@ -13,7 +13,7 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) | |||
| 13 | 13 | nativeWidget: nativeWidget, | |
| 14 | 14 | inputConfig: RuntimeInputConfig{ | |
| 15 | 15 | ScrollWheelMultiplier: 1.25, | |
| 16 | - | ScrollTouchpadMultiplier: 0.35, | |
| 16 | + | ScrollPreciseMultiplier: 2.5, | |
| 17 | 17 | ScrollHorizontalMultiplier: 0.75, | |
| 18 | 18 | ScrollVerticalMultiplier: 1.5, | |
| 19 | 19 | ScrollMaxDelta: 120, | |
| @@ -27,50 +27,19 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) | |||
| 27 | 27 | ||
| 28 | 28 | require.Zero(t, opts.Scale) | |
| 29 | 29 | require.InDelta(t, 1.25, opts.Scroll.WheelMultiplier, 0.001) | |
| 30 | - | require.InDelta(t, 0.35, opts.Scroll.TouchpadMultiplier, 0.001) | |
| 30 | + | require.InDelta(t, 2.5, opts.Scroll.PreciseMultiplier, 0.001) | |
| 31 | 31 | require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001) | |
| 32 | 32 | require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001) | |
| 33 | 33 | require.Equal(t, int32(120), opts.Scroll.MaxDelta) | |
| 34 | 34 | require.NotNil(t, opts.OnMiddleClick) | |
| 35 | - | require.NotNil(t, opts.OnTouchpadSwipe) | |
| 35 | + | require.NotNil(t, opts.OnScroll) | |
| 36 | + | require.True(t, opts.NavigationSwipe.Enabled) | |
| 37 | + | require.InDelta(t, 80.0, opts.NavigationSwipe.MinDelta, 0.001) | |
| 38 | + | require.InDelta(t, 0.5, opts.NavigationSwipe.MaxVerticalRatio, 0.001) | |
| 39 | + | require.NotNil(t, opts.CanNavigateBack) | |
| 40 | + | require.NotNil(t, opts.CanNavigateForward) | |
| 41 | + | require.NotNil(t, opts.OnNavigateSwipe) | |
| 36 | 42 | require.NotNil(t, opts.SelectionText) | |
| 37 | 43 | require.NotNil(t, opts.OnClipboardShortcut) | |
| 38 | 44 | require.Same(t, nativeWidget, wv.nativeWidget) | |
| 39 | 45 | } | |
| 40 | - | ||
| 41 | - | func TestChooseTouchpadNavigationAction(t *testing.T) { | |
| 42 | - | cfg := RuntimeInputConfig{ | |
| 43 | - | TouchpadNavigationEnabled: true, | |
| 44 | - | TouchpadNavigationMinDelta: 80, | |
| 45 | - | TouchpadNavigationMaxVerticalRatio: 0.5, | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | tests := []struct { | |
| 49 | - | name string | |
| 50 | - | cfg *RuntimeInputConfig | |
| 51 | - | dx, dy float64 | |
| 52 | - | canGoBack bool | |
| 53 | - | canGoForward bool | |
| 54 | - | want touchpadNavigationAction | |
| 55 | - | }{ | |
| 56 | - | {name: "right swipe goes back", dx: 100, dy: 10, canGoBack: true, want: touchpadNavigationBack}, | |
| 57 | - | {name: "left swipe goes forward", dx: -100, dy: 10, canGoForward: true, want: touchpadNavigationForward}, | |
| 58 | - | {name: "below threshold ignored", dx: 40, dy: 0, canGoBack: true, want: touchpadNavigationNone}, | |
| 59 | - | {name: "disabled ignored", cfg: &RuntimeInputConfig{TouchpadNavigationEnabled: false, TouchpadNavigationMinDelta: 80, TouchpadNavigationMaxVerticalRatio: 0.5}, dx: 100, dy: 0, canGoBack: true, want: touchpadNavigationNone}, | |
| 60 | - | {name: "exact threshold goes back", dx: 80, dy: 0, canGoBack: true, want: touchpadNavigationBack}, | |
| 61 | - | {name: "vertical diagonal ignored", dx: 100, dy: 80, canGoBack: true, want: touchpadNavigationNone}, | |
| 62 | - | {name: "unavailable back ignored", dx: 100, dy: 0, canGoBack: false, want: touchpadNavigationNone}, | |
| 63 | - | {name: "unavailable forward ignored", dx: -100, dy: 0, canGoForward: false, want: touchpadNavigationNone}, | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | for _, tt := range tests { | |
| 67 | - | t.Run(tt.name, func(t *testing.T) { | |
| 68 | - | testCfg := cfg | |
| 69 | - | if tt.cfg != nil { | |
| 70 | - | testCfg = *tt.cfg | |
| 71 | - | } | |
| 72 | - | got := chooseTouchpadNavigationAction(testCfg, tt.dx, tt.dy, tt.canGoBack, tt.canGoForward) | |
| 73 | - | require.Equal(t, tt.want, got) | |
| 74 | - | }) | |
| 75 | - | } | |
| 76 | - | } | |
| @@ -13,7 +13,7 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) | |||||
| 13 | nativeWidget: nativeWidget, | 13 | nativeWidget: nativeWidget, | ||
| 14 | inputConfig: RuntimeInputConfig{ | 14 | inputConfig: RuntimeInputConfig{ | ||
| 15 | ScrollWheelMultiplier: 1.25, | 15 | ScrollWheelMultiplier: 1.25, | ||
| 16 | - | ScrollTouchpadMultiplier: 0.35, | 16 | + | ScrollPreciseMultiplier: 2.5, |
| 17 | ScrollHorizontalMultiplier: 0.75, | 17 | ScrollHorizontalMultiplier: 0.75, | ||
| 18 | ScrollVerticalMultiplier: 1.5, | 18 | ScrollVerticalMultiplier: 1.5, | ||
| 19 | ScrollMaxDelta: 120, | 19 | ScrollMaxDelta: 120, | ||
| @@ -27,50 +27,19 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) | |||||
| 27 | 27 | ||||
| 28 | require.Zero(t, opts.Scale) | 28 | require.Zero(t, opts.Scale) | ||
| 29 | require.InDelta(t, 1.25, opts.Scroll.WheelMultiplier, 0.001) | 29 | require.InDelta(t, 1.25, opts.Scroll.WheelMultiplier, 0.001) | ||
| 30 | - | require.InDelta(t, 0.35, opts.Scroll.TouchpadMultiplier, 0.001) | 30 | + | require.InDelta(t, 2.5, opts.Scroll.PreciseMultiplier, 0.001) |
| 31 | require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001) | 31 | require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001) | ||
| 32 | require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001) | 32 | require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001) | ||
| 33 | require.Equal(t, int32(120), opts.Scroll.MaxDelta) | 33 | require.Equal(t, int32(120), opts.Scroll.MaxDelta) | ||
| 34 | require.NotNil(t, opts.OnMiddleClick) | 34 | require.NotNil(t, opts.OnMiddleClick) | ||
| 35 | - | require.NotNil(t, opts.OnTouchpadSwipe) | 35 | + | require.NotNil(t, opts.OnScroll) |
| 36 | + | require.True(t, opts.NavigationSwipe.Enabled) | |||
| 37 | + | require.InDelta(t, 80.0, opts.NavigationSwipe.MinDelta, 0.001) | |||
| 38 | + | require.InDelta(t, 0.5, opts.NavigationSwipe.MaxVerticalRatio, 0.001) | |||
| 39 | + | require.NotNil(t, opts.CanNavigateBack) | |||
| 40 | + | require.NotNil(t, opts.CanNavigateForward) | |||
| 41 | + | require.NotNil(t, opts.OnNavigateSwipe) | |||
| 36 | require.NotNil(t, opts.SelectionText) | 42 | require.NotNil(t, opts.SelectionText) | ||
| 37 | require.NotNil(t, opts.OnClipboardShortcut) | 43 | require.NotNil(t, opts.OnClipboardShortcut) | ||
| 38 | require.Same(t, nativeWidget, wv.nativeWidget) | 44 | require.Same(t, nativeWidget, wv.nativeWidget) | ||
| 39 | } | 45 | } | ||
| 40 | - | ||||
| 41 | - | func TestChooseTouchpadNavigationAction(t *testing.T) { | |||
| 42 | - | cfg := RuntimeInputConfig{ | |||
| 43 | - | TouchpadNavigationEnabled: true, | |||
| 44 | - | TouchpadNavigationMinDelta: 80, | |||
| 45 | - | TouchpadNavigationMaxVerticalRatio: 0.5, | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | tests := []struct { | |||
| 49 | - | name string | |||
| 50 | - | cfg *RuntimeInputConfig | |||
| 51 | - | dx, dy float64 | |||
| 52 | - | canGoBack bool | |||
| 53 | - | canGoForward bool | |||
| 54 | - | want touchpadNavigationAction | |||
| 55 | - | }{ | |||
| 56 | - | {name: "right swipe goes back", dx: 100, dy: 10, canGoBack: true, want: touchpadNavigationBack}, | |||
| 57 | - | {name: "left swipe goes forward", dx: -100, dy: 10, canGoForward: true, want: touchpadNavigationForward}, | |||
| 58 | - | {name: "below threshold ignored", dx: 40, dy: 0, canGoBack: true, want: touchpadNavigationNone}, | |||
| 59 | - | {name: "disabled ignored", cfg: &RuntimeInputConfig{TouchpadNavigationEnabled: false, TouchpadNavigationMinDelta: 80, TouchpadNavigationMaxVerticalRatio: 0.5}, dx: 100, dy: 0, canGoBack: true, want: touchpadNavigationNone}, | |||
| 60 | - | {name: "exact threshold goes back", dx: 80, dy: 0, canGoBack: true, want: touchpadNavigationBack}, | |||
| 61 | - | {name: "vertical diagonal ignored", dx: 100, dy: 80, canGoBack: true, want: touchpadNavigationNone}, | |||
| 62 | - | {name: "unavailable back ignored", dx: 100, dy: 0, canGoBack: false, want: touchpadNavigationNone}, | |||
| 63 | - | {name: "unavailable forward ignored", dx: -100, dy: 0, canGoForward: false, want: touchpadNavigationNone}, | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | for _, tt := range tests { | |||
| 67 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | - | testCfg := cfg | |||
| 69 | - | if tt.cfg != nil { | |||
| 70 | - | testCfg = *tt.cfg | |||
| 71 | - | } | |||
| 72 | - | got := chooseTouchpadNavigationAction(testCfg, tt.dx, tt.dy, tt.canGoBack, tt.canGoForward) | |||
| 73 | - | require.Equal(t, tt.want, got) | |||
| 74 | - | }) | |||
| 75 | - | } | |||
| 76 | - | } | |||
M internal/infrastructure/config/defaults.go +11 -11
| @@ -67,16 +67,16 @@ const ( | |||
| 67 | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| 68 | 68 | ||
| 69 | 69 | // Performance defaults | |
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled | |
| 73 | - | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |
| 74 | - | defaultCEFScrollMultiplier = 1.0 | |
| 75 | - | defaultCEFScrollTouchpadMultiplier = 0.35 | |
| 76 | - | defaultCEFScrollMaxDelta = 0 | |
| 77 | - | defaultCEFTouchpadNavigation = true | |
| 78 | - | defaultCEFTouchpadNavigationDelta = 80.0 | |
| 79 | - | defaultCEFTouchpadNavigationRatio = 0.5 | |
| 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | + | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled | |
| 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |
| 74 | + | defaultCEFScrollMultiplier = 1.0 | |
| 75 | + | defaultCEFScrollPreciseMultiplier = 2.5 | |
| 76 | + | defaultCEFScrollMaxDelta = 0 | |
| 77 | + | defaultCEFTouchpadNavigation = true | |
| 78 | + | defaultCEFTouchpadNavigationDelta = 200.0 | |
| 79 | + | defaultCEFTouchpadNavigationRatio = 0.5 | |
| 80 | 80 | ||
| 81 | 81 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 82 | 82 | defaultSkiaCPUPaintingThreads = 0 | |
| @@ -186,7 +186,7 @@ func DefaultConfig() *Config { | |||
| 186 | 186 | EnableAudioHandler: true, | |
| 187 | 187 | Input: CEFInputConfig{ | |
| 188 | 188 | ScrollWheelMultiplier: defaultCEFScrollMultiplier, | |
| 189 | - | ScrollTouchpadMultiplier: defaultCEFScrollTouchpadMultiplier, | |
| 189 | + | ScrollPreciseMultiplier: defaultCEFScrollPreciseMultiplier, | |
| 190 | 190 | ScrollHorizontalMultiplier: defaultCEFScrollMultiplier, | |
| 191 | 191 | ScrollVerticalMultiplier: defaultCEFScrollMultiplier, | |
| 192 | 192 | ScrollMaxDelta: defaultCEFScrollMaxDelta, | |
| @@ -67,16 +67,16 @@ const ( | |||||
| 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | ||
| 68 | 68 | ||||
| 69 | // Performance defaults | 69 | // Performance defaults | ||
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled | 72 | + | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled |
| 73 | - | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap |
| 74 | - | defaultCEFScrollMultiplier = 1.0 | 74 | + | defaultCEFScrollMultiplier = 1.0 |
| 75 | - | defaultCEFScrollTouchpadMultiplier = 0.35 | 75 | + | defaultCEFScrollPreciseMultiplier = 2.5 |
| 76 | - | defaultCEFScrollMaxDelta = 0 | 76 | + | defaultCEFScrollMaxDelta = 0 |
| 77 | - | defaultCEFTouchpadNavigation = true | 77 | + | defaultCEFTouchpadNavigation = true |
| 78 | - | defaultCEFTouchpadNavigationDelta = 80.0 | 78 | + | defaultCEFTouchpadNavigationDelta = 200.0 |
| 79 | - | defaultCEFTouchpadNavigationRatio = 0.5 | 79 | + | defaultCEFTouchpadNavigationRatio = 0.5 |
| 80 | 80 | ||||
| 81 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | 81 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | ||
| 82 | defaultSkiaCPUPaintingThreads = 0 | 82 | defaultSkiaCPUPaintingThreads = 0 | ||
| @@ -186,7 +186,7 @@ func DefaultConfig() *Config { | |||||
| 186 | EnableAudioHandler: true, | 186 | EnableAudioHandler: true, | ||
| 187 | Input: CEFInputConfig{ | 187 | Input: CEFInputConfig{ | ||
| 188 | ScrollWheelMultiplier: defaultCEFScrollMultiplier, | 188 | ScrollWheelMultiplier: defaultCEFScrollMultiplier, | ||
| 189 | - | ScrollTouchpadMultiplier: defaultCEFScrollTouchpadMultiplier, | 189 | + | ScrollPreciseMultiplier: defaultCEFScrollPreciseMultiplier, |
| 190 | ScrollHorizontalMultiplier: defaultCEFScrollMultiplier, | 190 | ScrollHorizontalMultiplier: defaultCEFScrollMultiplier, | ||
| 191 | ScrollVerticalMultiplier: defaultCEFScrollMultiplier, | 191 | ScrollVerticalMultiplier: defaultCEFScrollMultiplier, | ||
| 192 | ScrollMaxDelta: defaultCEFScrollMaxDelta, | 192 | ScrollMaxDelta: defaultCEFScrollMaxDelta, | ||
M internal/infrastructure/config/defaults_test.go +1 -1
| @@ -26,7 +26,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 26 | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 27 | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| 28 | 28 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollWheelMultiplier, 0.001) | |
| 29 | - | assert.InDelta(t, defaultCEFScrollTouchpadMultiplier, cfg.Engine.CEF.Input.ScrollTouchpadMultiplier, 0.001) | |
| 29 | + | assert.InDelta(t, defaultCEFScrollPreciseMultiplier, cfg.Engine.CEF.Input.ScrollPreciseMultiplier, 0.001) | |
| 30 | 30 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, 0.001) | |
| 31 | 31 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollVerticalMultiplier, 0.001) | |
| 32 | 32 | assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta) | |
| @@ -26,7 +26,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | ||
| 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
| 28 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollWheelMultiplier, 0.001) | 28 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollWheelMultiplier, 0.001) | ||
| 29 | - | assert.InDelta(t, defaultCEFScrollTouchpadMultiplier, cfg.Engine.CEF.Input.ScrollTouchpadMultiplier, 0.001) | 29 | + | assert.InDelta(t, defaultCEFScrollPreciseMultiplier, cfg.Engine.CEF.Input.ScrollPreciseMultiplier, 0.001) |
| 30 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, 0.001) | 30 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollHorizontalMultiplier, 0.001) | ||
| 31 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollVerticalMultiplier, 0.001) | 31 | assert.InDelta(t, defaultCEFScrollMultiplier, cfg.Engine.CEF.Input.ScrollVerticalMultiplier, 0.001) | ||
| 32 | assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta) | 32 | assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta) | ||
M internal/infrastructure/config/engine.go +1 -1
| @@ -121,7 +121,7 @@ type CEFEngineConfig struct { | |||
| 121 | 121 | // CEFInputConfig holds CEF-specific input translation options. | |
| 122 | 122 | type CEFInputConfig struct { | |
| 123 | 123 | ScrollWheelMultiplier float64 `mapstructure:"scroll_wheel_multiplier" toml:"scroll_wheel_multiplier" yaml:"scroll_wheel_multiplier"` //nolint:lll // struct tags exceed lll limit | |
| 124 | - | ScrollTouchpadMultiplier float64 `mapstructure:"scroll_touchpad_multiplier" toml:"scroll_touchpad_multiplier" yaml:"scroll_touchpad_multiplier"` //nolint:lll // struct tags exceed lll limit | |
| 124 | + | ScrollPreciseMultiplier float64 `mapstructure:"scroll_precise_multiplier" toml:"scroll_precise_multiplier" yaml:"scroll_precise_multiplier"` //nolint:lll // struct tags exceed lll limit | |
| 125 | 125 | ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit | |
| 126 | 126 | ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit | |
| 127 | 127 | ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit | |
| @@ -121,7 +121,7 @@ type CEFEngineConfig struct { | |||||
| 121 | // CEFInputConfig holds CEF-specific input translation options. | 121 | // CEFInputConfig holds CEF-specific input translation options. | ||
| 122 | type CEFInputConfig struct { | 122 | type CEFInputConfig struct { | ||
| 123 | ScrollWheelMultiplier float64 `mapstructure:"scroll_wheel_multiplier" toml:"scroll_wheel_multiplier" yaml:"scroll_wheel_multiplier"` //nolint:lll // struct tags exceed lll limit | 123 | ScrollWheelMultiplier float64 `mapstructure:"scroll_wheel_multiplier" toml:"scroll_wheel_multiplier" yaml:"scroll_wheel_multiplier"` //nolint:lll // struct tags exceed lll limit | ||
| 124 | - | ScrollTouchpadMultiplier float64 `mapstructure:"scroll_touchpad_multiplier" toml:"scroll_touchpad_multiplier" yaml:"scroll_touchpad_multiplier"` //nolint:lll // struct tags exceed lll limit | 124 | + | ScrollPreciseMultiplier float64 `mapstructure:"scroll_precise_multiplier" toml:"scroll_precise_multiplier" yaml:"scroll_precise_multiplier"` //nolint:lll // struct tags exceed lll limit |
| 125 | ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit | 125 | ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit | ||
| 126 | ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit | 126 | ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit | ||
| 127 | ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit | 127 | ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -783,7 +783,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 783 | 783 | ||
| 784 | 784 | func (m *Manager) setCEFInputDefaults(input CEFInputConfig) { | |
| 785 | 785 | m.viper.SetDefault("engine.cef.input.scroll_wheel_multiplier", input.ScrollWheelMultiplier) | |
| 786 | - | m.viper.SetDefault("engine.cef.input.scroll_touchpad_multiplier", input.ScrollTouchpadMultiplier) | |
| 786 | + | m.viper.SetDefault("engine.cef.input.scroll_precise_multiplier", input.ScrollPreciseMultiplier) | |
| 787 | 787 | m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier) | |
| 788 | 788 | m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier) | |
| 789 | 789 | m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta) | |
| @@ -783,7 +783,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 783 | 783 | ||||
| 784 | func (m *Manager) setCEFInputDefaults(input CEFInputConfig) { | 784 | func (m *Manager) setCEFInputDefaults(input CEFInputConfig) { | ||
| 785 | m.viper.SetDefault("engine.cef.input.scroll_wheel_multiplier", input.ScrollWheelMultiplier) | 785 | m.viper.SetDefault("engine.cef.input.scroll_wheel_multiplier", input.ScrollWheelMultiplier) | ||
| 786 | - | m.viper.SetDefault("engine.cef.input.scroll_touchpad_multiplier", input.ScrollTouchpadMultiplier) | 786 | + | m.viper.SetDefault("engine.cef.input.scroll_precise_multiplier", input.ScrollPreciseMultiplier) |
| 787 | m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier) | 787 | m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier) | ||
| 788 | m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier) | 788 | m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier) | ||
| 789 | m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta) | 789 | m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta) | ||
M internal/infrastructure/config/loader_test.go +1 -1
| @@ -18,7 +18,7 @@ func TestSetEngineDefaults(t *testing.T) { | |||
| 18 | 18 | assert.Equal(t, 4, mgr.viper.GetInt("engine.pool_prewarm_count")) | |
| 19 | 19 | assert.Equal(t, 256, mgr.viper.GetInt("engine.zoom_cache_size")) | |
| 20 | 20 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_wheel_multiplier"), 0.001) | |
| 21 | - | assert.InDelta(t, defaultCEFScrollTouchpadMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_touchpad_multiplier"), 0.001) | |
| 21 | + | assert.InDelta(t, defaultCEFScrollPreciseMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_precise_multiplier"), 0.001) | |
| 22 | 22 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001) | |
| 23 | 23 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001) | |
| 24 | 24 | assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta")) | |
| @@ -18,7 +18,7 @@ func TestSetEngineDefaults(t *testing.T) { | |||||
| 18 | assert.Equal(t, 4, mgr.viper.GetInt("engine.pool_prewarm_count")) | 18 | assert.Equal(t, 4, mgr.viper.GetInt("engine.pool_prewarm_count")) | ||
| 19 | assert.Equal(t, 256, mgr.viper.GetInt("engine.zoom_cache_size")) | 19 | assert.Equal(t, 256, mgr.viper.GetInt("engine.zoom_cache_size")) | ||
| 20 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_wheel_multiplier"), 0.001) | 20 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_wheel_multiplier"), 0.001) | ||
| 21 | - | assert.InDelta(t, defaultCEFScrollTouchpadMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_touchpad_multiplier"), 0.001) | 21 | + | assert.InDelta(t, defaultCEFScrollPreciseMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_precise_multiplier"), 0.001) |
| 22 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001) | 22 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001) | ||
| 23 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001) | 23 | assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001) | ||
| 24 | assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta")) | 24 | assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta")) | ||
M internal/infrastructure/config/schema_provider.go +3 -3
| @@ -971,10 +971,10 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 971 | 971 | Section: SectionPerformance, | |
| 972 | 972 | }, | |
| 973 | 973 | { | |
| 974 | - | Key: "engine.cef.input.scroll_touchpad_multiplier", | |
| 974 | + | Key: "engine.cef.input.scroll_precise_multiplier", | |
| 975 | 975 | Type: "float64", | |
| 976 | - | Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.ScrollTouchpadMultiplier), | |
| 977 | - | Description: "CEF touchpad/smooth scroll delta multiplier", | |
| 976 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.ScrollPreciseMultiplier), | |
| 977 | + | Description: "CEF precise/surface scroll delta multiplier", | |
| 978 | 978 | Range: ">0", | |
| 979 | 979 | Section: SectionPerformance, | |
| 980 | 980 | }, | |
| @@ -971,10 +971,10 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 971 | Section: SectionPerformance, | 971 | Section: SectionPerformance, | ||
| 972 | }, | 972 | }, | ||
| 973 | { | 973 | { | ||
| 974 | - | Key: "engine.cef.input.scroll_touchpad_multiplier", | 974 | + | Key: "engine.cef.input.scroll_precise_multiplier", |
| 975 | Type: "float64", | 975 | Type: "float64", | ||
| 976 | - | Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.ScrollTouchpadMultiplier), | 976 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.ScrollPreciseMultiplier), |
| 977 | - | Description: "CEF touchpad/smooth scroll delta multiplier", | 977 | + | Description: "CEF precise/surface scroll delta multiplier", |
| 978 | Range: ">0", | 978 | Range: ">0", | ||
| 979 | Section: SectionPerformance, | 979 | Section: SectionPerformance, | ||
| 980 | }, | 980 | }, | ||
M internal/infrastructure/config/validation.go +2 -2
| @@ -367,8 +367,8 @@ func validateCEF(config *Config) []string { | |||
| 367 | 367 | if invalidPositiveFloat(input.ScrollWheelMultiplier) { | |
| 368 | 368 | validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0") | |
| 369 | 369 | } | |
| 370 | - | if invalidPositiveFloat(input.ScrollTouchpadMultiplier) { | |
| 371 | - | validationErrors = append(validationErrors, "engine.cef.input.scroll_touchpad_multiplier must be > 0") | |
| 370 | + | if invalidPositiveFloat(input.ScrollPreciseMultiplier) { | |
| 371 | + | validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0") | |
| 372 | 372 | } | |
| 373 | 373 | if invalidPositiveFloat(input.ScrollHorizontalMultiplier) { | |
| 374 | 374 | validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0") | |
| @@ -367,8 +367,8 @@ func validateCEF(config *Config) []string { | |||||
| 367 | if invalidPositiveFloat(input.ScrollWheelMultiplier) { | 367 | if invalidPositiveFloat(input.ScrollWheelMultiplier) { | ||
| 368 | validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0") | 368 | validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0") | ||
| 369 | } | 369 | } | ||
| 370 | - | if invalidPositiveFloat(input.ScrollTouchpadMultiplier) { | 370 | + | if invalidPositiveFloat(input.ScrollPreciseMultiplier) { |
| 371 | - | validationErrors = append(validationErrors, "engine.cef.input.scroll_touchpad_multiplier must be > 0") | 371 | + | validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0") |
| 372 | } | 372 | } | ||
| 373 | if invalidPositiveFloat(input.ScrollHorizontalMultiplier) { | 373 | if invalidPositiveFloat(input.ScrollHorizontalMultiplier) { | ||
| 374 | validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0") | 374 | validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0") | ||
M internal/infrastructure/config/validation_test.go +3 -3
| @@ -81,12 +81,12 @@ func TestValidateConfig_CEFConfig(t *testing.T) { | |||
| 81 | 81 | wantText: "engine.cef.input.scroll_wheel_multiplier", | |
| 82 | 82 | }, | |
| 83 | 83 | { | |
| 84 | - | name: "negative touchpad multiplier", | |
| 84 | + | name: "negative precise multiplier", | |
| 85 | 85 | mutate: func(cfg *Config) { | |
| 86 | - | cfg.Engine.CEF.Input.ScrollTouchpadMultiplier = -0.1 | |
| 86 | + | cfg.Engine.CEF.Input.ScrollPreciseMultiplier = -0.1 | |
| 87 | 87 | }, | |
| 88 | 88 | wantErr: true, | |
| 89 | - | wantText: "engine.cef.input.scroll_touchpad_multiplier", | |
| 89 | + | wantText: "engine.cef.input.scroll_precise_multiplier", | |
| 90 | 90 | }, | |
| 91 | 91 | { | |
| 92 | 92 | name: "zero horizontal multiplier", | |
| @@ -81,12 +81,12 @@ func TestValidateConfig_CEFConfig(t *testing.T) { | |||||
| 81 | wantText: "engine.cef.input.scroll_wheel_multiplier", | 81 | wantText: "engine.cef.input.scroll_wheel_multiplier", | ||
| 82 | }, | 82 | }, | ||
| 83 | { | 83 | { | ||
| 84 | - | name: "negative touchpad multiplier", | 84 | + | name: "negative precise multiplier", |
| 85 | mutate: func(cfg *Config) { | 85 | mutate: func(cfg *Config) { | ||
| 86 | - | cfg.Engine.CEF.Input.ScrollTouchpadMultiplier = -0.1 | 86 | + | cfg.Engine.CEF.Input.ScrollPreciseMultiplier = -0.1 |
| 87 | }, | 87 | }, | ||
| 88 | wantErr: true, | 88 | wantErr: true, | ||
| 89 | - | wantText: "engine.cef.input.scroll_touchpad_multiplier", | 89 | + | wantText: "engine.cef.input.scroll_precise_multiplier", |
| 90 | }, | 90 | }, | ||
| 91 | { | 91 | { | ||
| 92 | name: "zero horizontal multiplier", | 92 | name: "zero horizontal multiplier", | ||
M internal/infrastructure/favicon/converter.go +2 -54
| @@ -9,8 +9,6 @@ import ( | |||
| 9 | 9 | "image/color" | |
| 10 | 10 | "image/png" | |
| 11 | 11 | "mime" | |
| 12 | - | "regexp" | |
| 13 | - | "strconv" | |
| 14 | 12 | "strings" | |
| 15 | 13 | ||
| 16 | 14 | appport "github.com/bnema/dumber/internal/application/port" | |
| @@ -53,7 +51,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType | |||
| 53 | 51 | func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | |
| 54 | 52 | switch ct { | |
| 55 | 53 | case "image/svg+xml": | |
| 56 | - | return renderSimpleSVG(data) | |
| 54 | + | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) | |
| 57 | 55 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | |
| 58 | 56 | img, err := decodeICO(data) | |
| 59 | 57 | if err != nil { | |
| @@ -61,7 +59,7 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | |||
| 61 | 59 | } | |
| 62 | 60 | return img, nil | |
| 63 | 61 | case "image/webp": | |
| 64 | - | return renderWEBPPlaceholder(data) | |
| 62 | + | return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss) | |
| 65 | 63 | } | |
| 66 | 64 | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" { | |
| 67 | 65 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | |
| @@ -209,53 +207,3 @@ func decodeICODIB(data []byte, bitCountHint int) (image.Image, error) { | |||
| 209 | 207 | } | |
| 210 | 208 | return img, nil | |
| 211 | 209 | } | |
| 212 | - | ||
| 213 | - | var svgSizeRe = regexp.MustCompile(`(?i)\b(width|height)=["']?(\d+)`) // intentionally small safe subset | |
| 214 | - | var svgFillRe = regexp.MustCompile(`(?i)\bfill=["']#?([0-9a-f]{6})`) | |
| 215 | - | ||
| 216 | - | func renderWEBPPlaceholder(data []byte) (image.Image, error) { | |
| 217 | - | if len(data) < 12 || !bytes.HasPrefix(data, []byte("RIFF")) || string(data[8:12]) != "WEBP" { | |
| 218 | - | return nil, fmt.Errorf("%w: malformed webp", appport.ErrFaviconMiss) | |
| 219 | - | } | |
| 220 | - | return solidImage(64, color.RGBA{R: 128, G: 128, B: 128, A: 255}), nil | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | func renderSimpleSVG(data []byte) (image.Image, error) { | |
| 224 | - | trimmed := bytes.TrimSpace(data) | |
| 225 | - | if !bytes.Contains(trimmed, []byte("<svg")) { | |
| 226 | - | return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss) | |
| 227 | - | } | |
| 228 | - | size := 64 | |
| 229 | - | for _, m := range svgSizeRe.FindAllSubmatch(trimmed, -1) { | |
| 230 | - | if v, err := strconv.Atoi(string(m[2])); err == nil && v > 0 && v <= 1024 { | |
| 231 | - | size = v | |
| 232 | - | break | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | fill := color.RGBA{R: 128, G: 128, B: 128, A: 255} | |
| 236 | - | if m := svgFillRe.FindSubmatch(trimmed); len(m) == 2 { | |
| 237 | - | if parsed, ok := parseHexColor(string(m[1])); ok { | |
| 238 | - | fill = parsed | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | return solidImage(size, fill), nil | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | func solidImage(size int, fill color.RGBA) image.Image { | |
| 245 | - | img := image.NewRGBA(image.Rect(0, 0, size, size)) | |
| 246 | - | for y := 0; y < size; y++ { | |
| 247 | - | for x := 0; x < size; x++ { | |
| 248 | - | img.SetRGBA(x, y, fill) | |
| 249 | - | } | |
| 250 | - | } | |
| 251 | - | return img | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | func parseHexColor(hex string) (color.RGBA, bool) { | |
| 255 | - | v, err := strconv.ParseUint(hex, 16, 32) | |
| 256 | - | if err != nil { | |
| 257 | - | return color.RGBA{}, false | |
| 258 | - | } | |
| 259 | - | const greenShift = 8 | |
| 260 | - | return color.RGBA{R: uint8(v >> 16), G: uint8(v >> greenShift), B: uint8(v), A: 255}, true | |
| 261 | - | } | |
| @@ -9,8 +9,6 @@ import ( | |||||
| 9 | "image/color" | 9 | "image/color" | ||
| 10 | "image/png" | 10 | "image/png" | ||
| 11 | "mime" | 11 | "mime" | ||
| 12 | - | "regexp" | |||
| 13 | - | "strconv" | |||
| 14 | "strings" | 12 | "strings" | ||
| 15 | 13 | ||||
| 16 | appport "github.com/bnema/dumber/internal/application/port" | 14 | appport "github.com/bnema/dumber/internal/application/port" | ||
| @@ -53,7 +51,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType | |||||
| 53 | func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | 51 | func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | ||
| 54 | switch ct { | 52 | switch ct { | ||
| 55 | case "image/svg+xml": | 53 | case "image/svg+xml": | ||
| 56 | - | return renderSimpleSVG(data) | 54 | + | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) |
| 57 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | 55 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | ||
| 58 | img, err := decodeICO(data) | 56 | img, err := decodeICO(data) | ||
| 59 | if err != nil { | 57 | if err != nil { | ||
| @@ -61,7 +59,7 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | |||||
| 61 | } | 59 | } | ||
| 62 | return img, nil | 60 | return img, nil | ||
| 63 | case "image/webp": | 61 | case "image/webp": | ||
| 64 | - | return renderWEBPPlaceholder(data) | 62 | + | return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss) |
| 65 | } | 63 | } | ||
| 66 | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" { | 64 | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" { | ||
| 67 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | 65 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | ||
| @@ -209,53 +207,3 @@ func decodeICODIB(data []byte, bitCountHint int) (image.Image, error) { | |||||
| 209 | } | 207 | } | ||
| 210 | return img, nil | 208 | return img, nil | ||
| 211 | } | 209 | } | ||
| 212 | - | ||||
| 213 | - | var svgSizeRe = regexp.MustCompile(`(?i)\b(width|height)=["']?(\d+)`) // intentionally small safe subset | |||
| 214 | - | var svgFillRe = regexp.MustCompile(`(?i)\bfill=["']#?([0-9a-f]{6})`) | |||
| 215 | - | ||||
| 216 | - | func renderWEBPPlaceholder(data []byte) (image.Image, error) { | |||
| 217 | - | if len(data) < 12 || !bytes.HasPrefix(data, []byte("RIFF")) || string(data[8:12]) != "WEBP" { | |||
| 218 | - | return nil, fmt.Errorf("%w: malformed webp", appport.ErrFaviconMiss) | |||
| 219 | - | } | |||
| 220 | - | return solidImage(64, color.RGBA{R: 128, G: 128, B: 128, A: 255}), nil | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | func renderSimpleSVG(data []byte) (image.Image, error) { | |||
| 224 | - | trimmed := bytes.TrimSpace(data) | |||
| 225 | - | if !bytes.Contains(trimmed, []byte("<svg")) { | |||
| 226 | - | return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss) | |||
| 227 | - | } | |||
| 228 | - | size := 64 | |||
| 229 | - | for _, m := range svgSizeRe.FindAllSubmatch(trimmed, -1) { | |||
| 230 | - | if v, err := strconv.Atoi(string(m[2])); err == nil && v > 0 && v <= 1024 { | |||
| 231 | - | size = v | |||
| 232 | - | break | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | fill := color.RGBA{R: 128, G: 128, B: 128, A: 255} | |||
| 236 | - | if m := svgFillRe.FindSubmatch(trimmed); len(m) == 2 { | |||
| 237 | - | if parsed, ok := parseHexColor(string(m[1])); ok { | |||
| 238 | - | fill = parsed | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | return solidImage(size, fill), nil | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | func solidImage(size int, fill color.RGBA) image.Image { | |||
| 245 | - | img := image.NewRGBA(image.Rect(0, 0, size, size)) | |||
| 246 | - | for y := 0; y < size; y++ { | |||
| 247 | - | for x := 0; x < size; x++ { | |||
| 248 | - | img.SetRGBA(x, y, fill) | |||
| 249 | - | } | |||
| 250 | - | } | |||
| 251 | - | return img | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | func parseHexColor(hex string) (color.RGBA, bool) { | |||
| 255 | - | v, err := strconv.ParseUint(hex, 16, 32) | |||
| 256 | - | if err != nil { | |||
| 257 | - | return color.RGBA{}, false | |||
| 258 | - | } | |||
| 259 | - | const greenShift = 8 | |||
| 260 | - | return color.RGBA{R: uint8(v >> 16), G: uint8(v >> greenShift), B: uint8(v), A: 255}, true | |||
| 261 | - | } | |||
M internal/infrastructure/favicon/converter_test.go +17 -2
| @@ -39,8 +39,6 @@ func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) { | |||
| 39 | 39 | {"png", "image/png", testPNG(t, 16, 16)}, | |
| 40 | 40 | {"ico with png payload", "image/x-icon", testICOWithPNG(t, testPNG(t, 16, 16))}, | |
| 41 | 41 | {"ico with bitmap payload", "image/x-icon", testICOWithDIB(t)}, | |
| 42 | - | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |
| 43 | - | {"webp placeholder", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |
| 44 | 42 | } { | |
| 45 | 43 | t.Run(tc.name, func(t *testing.T) { | |
| 46 | 44 | out, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}) | |
| @@ -61,6 +59,23 @@ func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) { | |||
| 61 | 59 | } | |
| 62 | 60 | } | |
| 63 | 61 | ||
| 62 | + | func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |
| 63 | + | conv := NewImageConverter() | |
| 64 | + | for _, tc := range []struct { | |
| 65 | + | name, ct string | |
| 66 | + | data []byte | |
| 67 | + | }{ | |
| 68 | + | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |
| 69 | + | {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |
| 70 | + | } { | |
| 71 | + | t.Run(tc.name, func(t *testing.T) { | |
| 72 | + | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 73 | + | t.Fatalf("err = %v, want favicon miss", err) | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 64 | 79 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | |
| 65 | 80 | conv := NewImageConverter() | |
| 66 | 81 | for _, tc := range []struct { | |
| @@ -39,8 +39,6 @@ func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) { | |||||
| 39 | {"png", "image/png", testPNG(t, 16, 16)}, | 39 | {"png", "image/png", testPNG(t, 16, 16)}, | ||
| 40 | {"ico with png payload", "image/x-icon", testICOWithPNG(t, testPNG(t, 16, 16))}, | 40 | {"ico with png payload", "image/x-icon", testICOWithPNG(t, testPNG(t, 16, 16))}, | ||
| 41 | {"ico with bitmap payload", "image/x-icon", testICOWithDIB(t)}, | 41 | {"ico with bitmap payload", "image/x-icon", testICOWithDIB(t)}, | ||
| 42 | - | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |||
| 43 | - | {"webp placeholder", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |||
| 44 | } { | 42 | } { | ||
| 45 | t.Run(tc.name, func(t *testing.T) { | 43 | t.Run(tc.name, func(t *testing.T) { | ||
| 46 | out, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}) | 44 | out, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}) | ||
| @@ -61,6 +59,23 @@ func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) { | |||||
| 61 | } | 59 | } | ||
| 62 | } | 60 | } | ||
| 63 | 61 | ||||
| 62 | + | func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |||
| 63 | + | conv := NewImageConverter() | |||
| 64 | + | for _, tc := range []struct { | |||
| 65 | + | name, ct string | |||
| 66 | + | data []byte | |||
| 67 | + | }{ | |||
| 68 | + | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |||
| 69 | + | {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |||
| 70 | + | } { | |||
| 71 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 72 | + | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |||
| 73 | + | t.Fatalf("err = %v, want favicon miss", err) | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 64 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | 79 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | ||
| 65 | conv := NewImageConverter() | 80 | conv := NewImageConverter() | ||
| 66 | for _, tc := range []struct { | 81 | for _, tc := range []struct { | ||