fix(ui): scale omnibox and session modal widths (#417)
* fix(ui): scale omnibox and session modal widths * fix(ui): preserve selection and normalize scale on runtime updates
- commit
- b12471245a19b174459235d54baac9a2691a7cf5
- author
- bnema · · 2026-09-09 06:15 UTC
- committer
- GitHub · · 2026-09-09 06:15 UTC
- parent
- 17ea0b8
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
9 files · +317 -17
M internal/ui/app.go +14 -0
| @@ -5513,10 +5513,24 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||
| 5513 | 5513 | sessionCfg := runtimeCfg.Session | |
| 5514 | 5514 | a.syncExternalThemeWatcher(ctx) | |
| 5515 | 5515 | a.applyAppearanceConfig(ctx) | |
| 5516 | + | a.omniboxCfg.UIScale = runtimeCfg.DefaultUIScale | |
| 5517 | + | for _, view := range a.workspaceViews { | |
| 5518 | + | if view != nil { | |
| 5519 | + | view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale) | |
| 5520 | + | } | |
| 5521 | + | } | |
| 5522 | + | for _, session := range a.floatingSessions { | |
| 5523 | + | if session != nil && session.omnibox != nil { | |
| 5524 | + | session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale) | |
| 5525 | + | } | |
| 5526 | + | } | |
| 5516 | 5527 | for _, bw := range a.browserWindows { | |
| 5517 | 5528 | if bw == nil { | |
| 5518 | 5529 | continue | |
| 5519 | 5530 | } | |
| 5531 | + | if bw.sessionManager != nil { | |
| 5532 | + | bw.sessionManager.SetUIScale(runtimeCfg.DefaultUIScale) | |
| 5533 | + | } | |
| 5520 | 5534 | // Reapply sidebar width from live config (reloads after sidebar_width | |
| 5521 | 5535 | // changes in the config file). | |
| 5522 | 5536 | bw.applySidebarWidthConfig(a) | |
| @@ -5513,10 +5513,24 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||||
| 5513 | sessionCfg := runtimeCfg.Session | 5513 | sessionCfg := runtimeCfg.Session | ||
| 5514 | a.syncExternalThemeWatcher(ctx) | 5514 | a.syncExternalThemeWatcher(ctx) | ||
| 5515 | a.applyAppearanceConfig(ctx) | 5515 | a.applyAppearanceConfig(ctx) | ||
| 5516 | + | a.omniboxCfg.UIScale = runtimeCfg.DefaultUIScale | |||
| 5517 | + | for _, view := range a.workspaceViews { | |||
| 5518 | + | if view != nil { | |||
| 5519 | + | view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale) | |||
| 5520 | + | } | |||
| 5521 | + | } | |||
| 5522 | + | for _, session := range a.floatingSessions { | |||
| 5523 | + | if session != nil && session.omnibox != nil { | |||
| 5524 | + | session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale) | |||
| 5525 | + | } | |||
| 5526 | + | } | |||
| 5516 | for _, bw := range a.browserWindows { | 5527 | for _, bw := range a.browserWindows { | ||
| 5517 | if bw == nil { | 5528 | if bw == nil { | ||
| 5518 | continue | 5529 | continue | ||
| 5519 | } | 5530 | } | ||
| 5531 | + | if bw.sessionManager != nil { | |||
| 5532 | + | bw.sessionManager.SetUIScale(runtimeCfg.DefaultUIScale) | |||
| 5533 | + | } | |||
| 5520 | // Reapply sidebar width from live config (reloads after sidebar_width | 5534 | // Reapply sidebar width from live config (reloads after sidebar_width | ||
| 5521 | // changes in the config file). | 5535 | // changes in the config file). | ||
| 5522 | bw.applySidebarWidthConfig(a) | 5536 | bw.applySidebarWidthConfig(a) | ||
A internal/ui/component/modal_scale_update_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestModalUIScaleUpdate(t *testing.T) { | |
| 11 | + | o := &Omnibox{uiScale: 1, sizeCfg: OmniboxSizeDefaults} | |
| 12 | + | o.measuredHeights.valid = true | |
| 13 | + | sm := &SessionManager{uiScale: 1} | |
| 14 | + | sm.measuredHeights.valid = true | |
| 15 | + | for _, scale := range []float64{1.5, 2, 1} { | |
| 16 | + | o.SetUIScale(scale) | |
| 17 | + | sm.SetUIScale(scale) | |
| 18 | + | assert.InDelta(t, scale, o.uiScale, 0.000001) | |
| 19 | + | assert.InDelta(t, scale, sm.uiScale, 0.000001) | |
| 20 | + | assert.False(t, o.measuredHeights.valid) | |
| 21 | + | assert.False(t, sm.measuredHeights.valid) | |
| 22 | + | width, _ := o.requestedDimensions() | |
| 23 | + | assert.Equal(t, int(640*scale), width) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestWorkspaceOmniboxUIScaleUpdatePreservesConfig(t *testing.T) { | |
| 28 | + | o := &Omnibox{uiScale: 1} | |
| 29 | + | wv := &WorkspaceView{omnibox: o, omniboxCfg: OmniboxConfig{UIScale: 1, SizeConfig: ModalSizeConfig{FixedWidth: 800}}} | |
| 30 | + | wv.SetOmniboxUIScale(1.5) | |
| 31 | + | assert.InDelta(t, 1.5, o.uiScale, 0.000001) | |
| 32 | + | assert.InDelta(t, 1.5, wv.omniboxCfg.UIScale, 0.000001) | |
| 33 | + | assert.Equal(t, 800, wv.omniboxCfg.SizeConfig.FixedWidth) | |
| 34 | + | wv.omnibox = nil | |
| 35 | + | wv.SetOmniboxUIScale(2) | |
| 36 | + | assert.InDelta(t, 2.0, wv.omniboxCfg.UIScale, 0.000001) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestModalUIScaleUpdateNormalizesNonFinite(t *testing.T) { | |
| 40 | + | o := &Omnibox{uiScale: 1.5} | |
| 41 | + | sm := &SessionManager{uiScale: 1.5} | |
| 42 | + | for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} { | |
| 43 | + | o.SetUIScale(scale) | |
| 44 | + | sm.SetUIScale(scale) | |
| 45 | + | assert.InDelta(t, 1.0, o.uiScale, 0.000001) | |
| 46 | + | assert.InDelta(t, 1.0, sm.uiScale, 0.000001) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestWorkspaceOmniboxUIScaleUpdateNormalizesBeforePersist(t *testing.T) { | |
| 51 | + | o := &Omnibox{uiScale: 1} | |
| 52 | + | wv := &WorkspaceView{omnibox: o, omniboxCfg: OmniboxConfig{UIScale: 1}} | |
| 53 | + | for _, scale := range []float64{0, -2, math.NaN(), math.Inf(1)} { | |
| 54 | + | wv.SetOmniboxUIScale(scale) | |
| 55 | + | assert.InDelta(t, 1.0, wv.omniboxCfg.UIScale, 0.000001) | |
| 56 | + | assert.InDelta(t, 1.0, o.uiScale, 0.000001) | |
| 57 | + | } | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestModalUIScaleUpdate(t *testing.T) { | |||
| 11 | + | o := &Omnibox{uiScale: 1, sizeCfg: OmniboxSizeDefaults} | |||
| 12 | + | o.measuredHeights.valid = true | |||
| 13 | + | sm := &SessionManager{uiScale: 1} | |||
| 14 | + | sm.measuredHeights.valid = true | |||
| 15 | + | for _, scale := range []float64{1.5, 2, 1} { | |||
| 16 | + | o.SetUIScale(scale) | |||
| 17 | + | sm.SetUIScale(scale) | |||
| 18 | + | assert.InDelta(t, scale, o.uiScale, 0.000001) | |||
| 19 | + | assert.InDelta(t, scale, sm.uiScale, 0.000001) | |||
| 20 | + | assert.False(t, o.measuredHeights.valid) | |||
| 21 | + | assert.False(t, sm.measuredHeights.valid) | |||
| 22 | + | width, _ := o.requestedDimensions() | |||
| 23 | + | assert.Equal(t, int(640*scale), width) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestWorkspaceOmniboxUIScaleUpdatePreservesConfig(t *testing.T) { | |||
| 28 | + | o := &Omnibox{uiScale: 1} | |||
| 29 | + | wv := &WorkspaceView{omnibox: o, omniboxCfg: OmniboxConfig{UIScale: 1, SizeConfig: ModalSizeConfig{FixedWidth: 800}}} | |||
| 30 | + | wv.SetOmniboxUIScale(1.5) | |||
| 31 | + | assert.InDelta(t, 1.5, o.uiScale, 0.000001) | |||
| 32 | + | assert.InDelta(t, 1.5, wv.omniboxCfg.UIScale, 0.000001) | |||
| 33 | + | assert.Equal(t, 800, wv.omniboxCfg.SizeConfig.FixedWidth) | |||
| 34 | + | wv.omnibox = nil | |||
| 35 | + | wv.SetOmniboxUIScale(2) | |||
| 36 | + | assert.InDelta(t, 2.0, wv.omniboxCfg.UIScale, 0.000001) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestModalUIScaleUpdateNormalizesNonFinite(t *testing.T) { | |||
| 40 | + | o := &Omnibox{uiScale: 1.5} | |||
| 41 | + | sm := &SessionManager{uiScale: 1.5} | |||
| 42 | + | for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} { | |||
| 43 | + | o.SetUIScale(scale) | |||
| 44 | + | sm.SetUIScale(scale) | |||
| 45 | + | assert.InDelta(t, 1.0, o.uiScale, 0.000001) | |||
| 46 | + | assert.InDelta(t, 1.0, sm.uiScale, 0.000001) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestWorkspaceOmniboxUIScaleUpdateNormalizesBeforePersist(t *testing.T) { | |||
| 51 | + | o := &Omnibox{uiScale: 1} | |||
| 52 | + | wv := &WorkspaceView{omnibox: o, omniboxCfg: OmniboxConfig{UIScale: 1}} | |||
| 53 | + | for _, scale := range []float64{0, -2, math.NaN(), math.Inf(1)} { | |||
| 54 | + | wv.SetOmniboxUIScale(scale) | |||
| 55 | + | assert.InDelta(t, 1.0, wv.omniboxCfg.UIScale, 0.000001) | |||
| 56 | + | assert.InDelta(t, 1.0, o.uiScale, 0.000001) | |||
| 57 | + | } | |||
| 58 | + | } | |||
M internal/ui/component/modal_sizing.go +26 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package component | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/dumber/internal/ui/layout" | |
| 5 | 7 | "github.com/bnema/puregotk/v4/gtk" | |
| 6 | 8 | ) | |
| @@ -146,6 +148,24 @@ func ResolveModalSizeConfig(cfg, defaults ModalSizeConfig) ModalSizeConfig { | |||
| 146 | 148 | // CalculateModalDimensions computes width and top margin based on parent overlay. | |
| 147 | 149 | // Returns calculated width and top margin in pixels. | |
| 148 | 150 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | |
| 151 | + | return CalculateModalDimensionsWithScale(parent, cfg, 1) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // normalizeUIScale maps nonpositive or nonfinite scale factors to 1. | |
| 155 | + | func normalizeUIScale(uiScale float64) float64 { | |
| 156 | + | if math.IsNaN(uiScale) || math.IsInf(uiScale, 0) || uiScale <= 0 { | |
| 157 | + | return 1.0 | |
| 158 | + | } | |
| 159 | + | return uiScale | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // CalculateModalDimensionsWithScale computes width and top margin based on | |
| 163 | + | // parent overlay, scaling the requested width by the UI scale factor while | |
| 164 | + | // preserving scale=1 geometry, top placement, and fallback height. | |
| 165 | + | // Returns calculated width and top margin in pixels. | |
| 166 | + | func CalculateModalDimensionsWithScale(parent layout.OverlayWidget, cfg ModalSizeConfig, uiScale float64) (width, marginTop int) { | |
| 167 | + | scale := normalizeUIScale(uiScale) | |
| 168 | + | ||
| 149 | 169 | var parentWidth, parentHeight int | |
| 150 | 170 | ||
| 151 | 171 | if parent != nil { | |
| @@ -156,16 +176,19 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) | |||
| 156 | 176 | // Use fallback if parent not allocated or too small to be useful | |
| 157 | 177 | // A width < 100 is too small for any meaningful modal | |
| 158 | 178 | if parentWidth < 100 { | |
| 159 | - | parentWidth = cfg.FallbackWidth | |
| 179 | + | parentWidth = ScaleValue(cfg.FallbackWidth, scale) | |
| 160 | 180 | } | |
| 161 | 181 | if parentHeight < 100 { | |
| 162 | 182 | parentHeight = cfg.FallbackHeight | |
| 163 | 183 | } | |
| 164 | 184 | ||
| 165 | 185 | if cfg.FixedWidth > 0 { | |
| 166 | - | width = cfg.FixedWidth | |
| 186 | + | // Fixed widths (e.g. standalone omnibox) are content-sized requests: | |
| 187 | + | // scale them directly without clamping to the host allocation, which | |
| 188 | + | // is itself driven by the child and would prevent growth. | |
| 189 | + | width = ScaleValue(cfg.FixedWidth, scale) | |
| 167 | 190 | } else { | |
| 168 | - | width = min(int(float64(parentWidth)*cfg.WidthPct), cfg.MaxWidth) | |
| 191 | + | width = min(int(float64(parentWidth)*cfg.WidthPct), ScaleValue(cfg.MaxWidth, scale)) | |
| 169 | 192 | } | |
| 170 | 193 | ||
| 171 | 194 | if cfg.UseFixedTopMargin { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package component | 1 | package component | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/dumber/internal/ui/layout" | 6 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 5 | "github.com/bnema/puregotk/v4/gtk" | 7 | "github.com/bnema/puregotk/v4/gtk" | ||
| 6 | ) | 8 | ) | ||
| @@ -146,6 +148,24 @@ func ResolveModalSizeConfig(cfg, defaults ModalSizeConfig) ModalSizeConfig { | |||||
| 146 | // CalculateModalDimensions computes width and top margin based on parent overlay. | 148 | // CalculateModalDimensions computes width and top margin based on parent overlay. | ||
| 147 | // Returns calculated width and top margin in pixels. | 149 | // Returns calculated width and top margin in pixels. | ||
| 148 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | 150 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | ||
| 151 | + | return CalculateModalDimensionsWithScale(parent, cfg, 1) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // normalizeUIScale maps nonpositive or nonfinite scale factors to 1. | |||
| 155 | + | func normalizeUIScale(uiScale float64) float64 { | |||
| 156 | + | if math.IsNaN(uiScale) || math.IsInf(uiScale, 0) || uiScale <= 0 { | |||
| 157 | + | return 1.0 | |||
| 158 | + | } | |||
| 159 | + | return uiScale | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // CalculateModalDimensionsWithScale computes width and top margin based on | |||
| 163 | + | // parent overlay, scaling the requested width by the UI scale factor while | |||
| 164 | + | // preserving scale=1 geometry, top placement, and fallback height. | |||
| 165 | + | // Returns calculated width and top margin in pixels. | |||
| 166 | + | func CalculateModalDimensionsWithScale(parent layout.OverlayWidget, cfg ModalSizeConfig, uiScale float64) (width, marginTop int) { | |||
| 167 | + | scale := normalizeUIScale(uiScale) | |||
| 168 | + | ||||
| 149 | var parentWidth, parentHeight int | 169 | var parentWidth, parentHeight int | ||
| 150 | 170 | ||||
| 151 | if parent != nil { | 171 | if parent != nil { | ||
| @@ -156,16 +176,19 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) | |||||
| 156 | // Use fallback if parent not allocated or too small to be useful | 176 | // Use fallback if parent not allocated or too small to be useful | ||
| 157 | // A width < 100 is too small for any meaningful modal | 177 | // A width < 100 is too small for any meaningful modal | ||
| 158 | if parentWidth < 100 { | 178 | if parentWidth < 100 { | ||
| 159 | - | parentWidth = cfg.FallbackWidth | 179 | + | parentWidth = ScaleValue(cfg.FallbackWidth, scale) |
| 160 | } | 180 | } | ||
| 161 | if parentHeight < 100 { | 181 | if parentHeight < 100 { | ||
| 162 | parentHeight = cfg.FallbackHeight | 182 | parentHeight = cfg.FallbackHeight | ||
| 163 | } | 183 | } | ||
| 164 | 184 | ||||
| 165 | if cfg.FixedWidth > 0 { | 185 | if cfg.FixedWidth > 0 { | ||
| 166 | - | width = cfg.FixedWidth | 186 | + | // Fixed widths (e.g. standalone omnibox) are content-sized requests: |
| 187 | + | // scale them directly without clamping to the host allocation, which | |||
| 188 | + | // is itself driven by the child and would prevent growth. | |||
| 189 | + | width = ScaleValue(cfg.FixedWidth, scale) | |||
| 167 | } else { | 190 | } else { | ||
| 168 | - | width = min(int(float64(parentWidth)*cfg.WidthPct), cfg.MaxWidth) | 191 | + | width = min(int(float64(parentWidth)*cfg.WidthPct), ScaleValue(cfg.MaxWidth, scale)) |
| 169 | } | 192 | } | ||
| 170 | 193 | ||||
| 171 | if cfg.UseFixedTopMargin { | 194 | if cfg.UseFixedTopMargin { | ||
M internal/ui/component/modal_sizing_test.go +128 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package component | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| @@ -283,3 +284,130 @@ func TestEffectiveMaxRows_NoAdaptation(t *testing.T) { | |||
| 283 | 284 | got := EffectiveMaxRows(500, 72, sizeCfg, defaults) | |
| 284 | 285 | assert.Equal(t, 10, got) | |
| 285 | 286 | } | |
| 287 | + | ||
| 288 | + | func mockParent(t *testing.T, w, h int) *layoutmocks.MockOverlayWidget { | |
| 289 | + | t.Helper() | |
| 290 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |
| 291 | + | parent.EXPECT().GetAllocatedWidth().Return(w).Maybe() | |
| 292 | + | parent.EXPECT().GetAllocatedHeight().Return(h).Maybe() | |
| 293 | + | return parent | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestCalculateModalDimensionsWithScale_ScaleOneUnchanged(t *testing.T) { | |
| 297 | + | cfg := ModalSizeConfig{ | |
| 298 | + | WidthPct: 0.8, | |
| 299 | + | MaxWidth: 800, | |
| 300 | + | TopMarginPct: 0.2, | |
| 301 | + | FallbackWidth: 800, | |
| 302 | + | FallbackHeight: 600, | |
| 303 | + | } | |
| 304 | + | parent := mockParent(t, 1920, 1080) | |
| 305 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1) | |
| 306 | + | wLegacy, mLegacy := CalculateModalDimensions(parent, cfg) | |
| 307 | + | assert.Equal(t, wLegacy, w) | |
| 308 | + | assert.Equal(t, mLegacy, m) | |
| 309 | + | assert.Equal(t, 800, w) | |
| 310 | + | assert.Equal(t, 216, m) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestCalculateModalDimensionsWithScale_LargeParent(t *testing.T) { | |
| 314 | + | cfg := OmniboxSizeDefaults | |
| 315 | + | for _, tt := range []struct { | |
| 316 | + | scale float64 | |
| 317 | + | want int | |
| 318 | + | }{ | |
| 319 | + | {1.25, 1000}, | |
| 320 | + | {1.5, 1200}, | |
| 321 | + | {2.0, 1600}, | |
| 322 | + | } { | |
| 323 | + | parent := mockParent(t, 3000, 1200) | |
| 324 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, tt.scale) | |
| 325 | + | assert.Equal(t, tt.want, w, "scale %v", tt.scale) | |
| 326 | + | assert.Equal(t, 240, m, "top margin unchanged") | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func TestCalculateModalDimensionsWithScale_SmallParentPercentageBound(t *testing.T) { | |
| 331 | + | cfg := SessionManagerSizeDefaults // 0.6 * w, max 600 | |
| 332 | + | parent := mockParent(t, 500, 800) | |
| 333 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 2.0) | |
| 334 | + | // 0.6*500=300 < 600*2 → percentage bound wins | |
| 335 | + | assert.Equal(t, 300, w) | |
| 336 | + | assert.Equal(t, int(float64(800)*cfg.TopMarginPct), m) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestCalculateModalDimensionsWithScale_ScaleBelowOne(t *testing.T) { | |
| 340 | + | cfg := OmniboxSizeDefaults | |
| 341 | + | parent := mockParent(t, 3000, 1080) | |
| 342 | + | w, _ := CalculateModalDimensionsWithScale(parent, cfg, 0.5) | |
| 343 | + | assert.Equal(t, 400, w) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func TestCalculateModalDimensionsWithScale_NormalizesBadScale(t *testing.T) { | |
| 347 | + | cfg := OmniboxSizeDefaults | |
| 348 | + | for _, scale := range []float64{0, -1, -0.5, nan(), inf(1), inf(-1)} { | |
| 349 | + | parent := mockParent(t, 3000, 1080) | |
| 350 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, scale) | |
| 351 | + | assert.Equal(t, 800, w, "scale %v", scale) | |
| 352 | + | assert.Equal(t, 216, m, "scale %v", scale) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestCalculateModalDimensionsWithScale_FallbackScaled(t *testing.T) { | |
| 357 | + | cfg := OmniboxSizeDefaults | |
| 358 | + | parent := mockParent(t, 0, 0) | |
| 359 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |
| 360 | + | // fallback 800*1.5=1200 → pct 0.8*1200=960 vs max 1200 → 960 | |
| 361 | + | assert.Equal(t, 960, w) | |
| 362 | + | assert.Equal(t, int(float64(600)*cfg.TopMarginPct), m) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestCalculateModalDimensionsWithScale_FixedScalesOnceBeyondAllocation(t *testing.T) { | |
| 366 | + | cfg := ResolveModalSizeConfig(ModalSizeConfig{ | |
| 367 | + | FixedWidth: 800, | |
| 368 | + | FixedTopMargin: 0, | |
| 369 | + | UseFixedTopMargin: true, | |
| 370 | + | }, OmniboxSizeDefaults) | |
| 371 | + | // Child-sized host allocation (small) must not clamp the fixed request. | |
| 372 | + | parent := mockParent(t, 120, 200) | |
| 373 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |
| 374 | + | assert.Equal(t, 1200, w) | |
| 375 | + | assert.Equal(t, 0, m) | |
| 376 | + | // Config must not be mutated by repeated calls. | |
| 377 | + | assert.Equal(t, 800, cfg.FixedWidth) | |
| 378 | + | w2, _ := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |
| 379 | + | assert.Equal(t, w, w2) | |
| 380 | + | assert.Equal(t, 800, cfg.FixedWidth) | |
| 381 | + | assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func TestCalculateModalDimensionsWithScale_NoDefaultMutation(t *testing.T) { | |
| 385 | + | before := OmniboxSizeDefaults | |
| 386 | + | parent := mockParent(t, 3000, 1200) | |
| 387 | + | _, _ = CalculateModalDimensionsWithScale(parent, OmniboxSizeDefaults, 2.0) | |
| 388 | + | assert.Equal(t, before, OmniboxSizeDefaults) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func TestOmniboxRequestedDimensions_UsesScale(t *testing.T) { | |
| 392 | + | cfg := ResolveModalSizeConfig(ModalSizeConfig{ | |
| 393 | + | FixedWidth: 800, | |
| 394 | + | FixedTopMargin: 0, | |
| 395 | + | UseFixedTopMargin: true, | |
| 396 | + | }, OmniboxSizeDefaults) | |
| 397 | + | o := &Omnibox{parentOverlay: mockParent(t, 120, 200), sizeCfg: cfg, uiScale: 1.5} | |
| 398 | + | w, m := o.requestedDimensions() | |
| 399 | + | assert.Equal(t, 1200, w) | |
| 400 | + | assert.Equal(t, 0, m) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestSessionManagerRequestedDimensions_UsesScale(t *testing.T) { | |
| 404 | + | sm := &SessionManager{parentOverlay: mockParent(t, 3000, 1200), uiScale: 1.5} | |
| 405 | + | w, m := sm.requestedDimensions() | |
| 406 | + | // 0.6*3000=1800 vs 600*1.5=900 → 900 | |
| 407 | + | assert.Equal(t, 900, w) | |
| 408 | + | assert.Equal(t, int(float64(1200)*SessionManagerSizeDefaults.TopMarginPct), m) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func nan() float64 { return math.NaN() } | |
| 412 | + | ||
| 413 | + | func inf(sign int) float64 { return math.Inf(sign) } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package component | 1 | package component | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | 7 | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | ||
| @@ -283,3 +284,130 @@ func TestEffectiveMaxRows_NoAdaptation(t *testing.T) { | |||||
| 283 | got := EffectiveMaxRows(500, 72, sizeCfg, defaults) | 284 | got := EffectiveMaxRows(500, 72, sizeCfg, defaults) | ||
| 284 | assert.Equal(t, 10, got) | 285 | assert.Equal(t, 10, got) | ||
| 285 | } | 286 | } | ||
| 287 | + | ||||
| 288 | + | func mockParent(t *testing.T, w, h int) *layoutmocks.MockOverlayWidget { | |||
| 289 | + | t.Helper() | |||
| 290 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |||
| 291 | + | parent.EXPECT().GetAllocatedWidth().Return(w).Maybe() | |||
| 292 | + | parent.EXPECT().GetAllocatedHeight().Return(h).Maybe() | |||
| 293 | + | return parent | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestCalculateModalDimensionsWithScale_ScaleOneUnchanged(t *testing.T) { | |||
| 297 | + | cfg := ModalSizeConfig{ | |||
| 298 | + | WidthPct: 0.8, | |||
| 299 | + | MaxWidth: 800, | |||
| 300 | + | TopMarginPct: 0.2, | |||
| 301 | + | FallbackWidth: 800, | |||
| 302 | + | FallbackHeight: 600, | |||
| 303 | + | } | |||
| 304 | + | parent := mockParent(t, 1920, 1080) | |||
| 305 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1) | |||
| 306 | + | wLegacy, mLegacy := CalculateModalDimensions(parent, cfg) | |||
| 307 | + | assert.Equal(t, wLegacy, w) | |||
| 308 | + | assert.Equal(t, mLegacy, m) | |||
| 309 | + | assert.Equal(t, 800, w) | |||
| 310 | + | assert.Equal(t, 216, m) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func TestCalculateModalDimensionsWithScale_LargeParent(t *testing.T) { | |||
| 314 | + | cfg := OmniboxSizeDefaults | |||
| 315 | + | for _, tt := range []struct { | |||
| 316 | + | scale float64 | |||
| 317 | + | want int | |||
| 318 | + | }{ | |||
| 319 | + | {1.25, 1000}, | |||
| 320 | + | {1.5, 1200}, | |||
| 321 | + | {2.0, 1600}, | |||
| 322 | + | } { | |||
| 323 | + | parent := mockParent(t, 3000, 1200) | |||
| 324 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, tt.scale) | |||
| 325 | + | assert.Equal(t, tt.want, w, "scale %v", tt.scale) | |||
| 326 | + | assert.Equal(t, 240, m, "top margin unchanged") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func TestCalculateModalDimensionsWithScale_SmallParentPercentageBound(t *testing.T) { | |||
| 331 | + | cfg := SessionManagerSizeDefaults // 0.6 * w, max 600 | |||
| 332 | + | parent := mockParent(t, 500, 800) | |||
| 333 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 2.0) | |||
| 334 | + | // 0.6*500=300 < 600*2 → percentage bound wins | |||
| 335 | + | assert.Equal(t, 300, w) | |||
| 336 | + | assert.Equal(t, int(float64(800)*cfg.TopMarginPct), m) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestCalculateModalDimensionsWithScale_ScaleBelowOne(t *testing.T) { | |||
| 340 | + | cfg := OmniboxSizeDefaults | |||
| 341 | + | parent := mockParent(t, 3000, 1080) | |||
| 342 | + | w, _ := CalculateModalDimensionsWithScale(parent, cfg, 0.5) | |||
| 343 | + | assert.Equal(t, 400, w) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func TestCalculateModalDimensionsWithScale_NormalizesBadScale(t *testing.T) { | |||
| 347 | + | cfg := OmniboxSizeDefaults | |||
| 348 | + | for _, scale := range []float64{0, -1, -0.5, nan(), inf(1), inf(-1)} { | |||
| 349 | + | parent := mockParent(t, 3000, 1080) | |||
| 350 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, scale) | |||
| 351 | + | assert.Equal(t, 800, w, "scale %v", scale) | |||
| 352 | + | assert.Equal(t, 216, m, "scale %v", scale) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestCalculateModalDimensionsWithScale_FallbackScaled(t *testing.T) { | |||
| 357 | + | cfg := OmniboxSizeDefaults | |||
| 358 | + | parent := mockParent(t, 0, 0) | |||
| 359 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |||
| 360 | + | // fallback 800*1.5=1200 → pct 0.8*1200=960 vs max 1200 → 960 | |||
| 361 | + | assert.Equal(t, 960, w) | |||
| 362 | + | assert.Equal(t, int(float64(600)*cfg.TopMarginPct), m) | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestCalculateModalDimensionsWithScale_FixedScalesOnceBeyondAllocation(t *testing.T) { | |||
| 366 | + | cfg := ResolveModalSizeConfig(ModalSizeConfig{ | |||
| 367 | + | FixedWidth: 800, | |||
| 368 | + | FixedTopMargin: 0, | |||
| 369 | + | UseFixedTopMargin: true, | |||
| 370 | + | }, OmniboxSizeDefaults) | |||
| 371 | + | // Child-sized host allocation (small) must not clamp the fixed request. | |||
| 372 | + | parent := mockParent(t, 120, 200) | |||
| 373 | + | w, m := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |||
| 374 | + | assert.Equal(t, 1200, w) | |||
| 375 | + | assert.Equal(t, 0, m) | |||
| 376 | + | // Config must not be mutated by repeated calls. | |||
| 377 | + | assert.Equal(t, 800, cfg.FixedWidth) | |||
| 378 | + | w2, _ := CalculateModalDimensionsWithScale(parent, cfg, 1.5) | |||
| 379 | + | assert.Equal(t, w, w2) | |||
| 380 | + | assert.Equal(t, 800, cfg.FixedWidth) | |||
| 381 | + | assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func TestCalculateModalDimensionsWithScale_NoDefaultMutation(t *testing.T) { | |||
| 385 | + | before := OmniboxSizeDefaults | |||
| 386 | + | parent := mockParent(t, 3000, 1200) | |||
| 387 | + | _, _ = CalculateModalDimensionsWithScale(parent, OmniboxSizeDefaults, 2.0) | |||
| 388 | + | assert.Equal(t, before, OmniboxSizeDefaults) | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func TestOmniboxRequestedDimensions_UsesScale(t *testing.T) { | |||
| 392 | + | cfg := ResolveModalSizeConfig(ModalSizeConfig{ | |||
| 393 | + | FixedWidth: 800, | |||
| 394 | + | FixedTopMargin: 0, | |||
| 395 | + | UseFixedTopMargin: true, | |||
| 396 | + | }, OmniboxSizeDefaults) | |||
| 397 | + | o := &Omnibox{parentOverlay: mockParent(t, 120, 200), sizeCfg: cfg, uiScale: 1.5} | |||
| 398 | + | w, m := o.requestedDimensions() | |||
| 399 | + | assert.Equal(t, 1200, w) | |||
| 400 | + | assert.Equal(t, 0, m) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestSessionManagerRequestedDimensions_UsesScale(t *testing.T) { | |||
| 404 | + | sm := &SessionManager{parentOverlay: mockParent(t, 3000, 1200), uiScale: 1.5} | |||
| 405 | + | w, m := sm.requestedDimensions() | |||
| 406 | + | // 0.6*3000=1800 vs 600*1.5=900 → 900 | |||
| 407 | + | assert.Equal(t, 900, w) | |||
| 408 | + | assert.Equal(t, int(float64(1200)*SessionManagerSizeDefaults.TopMarginPct), m) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func nan() float64 { return math.NaN() } | |||
| 412 | + | ||||
| 413 | + | func inf(sign int) float64 { return math.Inf(sign) } | |||
M internal/ui/component/omnibox.go +47 -7
| @@ -197,10 +197,7 @@ type OmniboxConfig struct { | |||
| 197 | 197 | func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |
| 198 | 198 | log := logging.FromContext(ctx) | |
| 199 | 199 | ||
| 200 | - | uiScale := cfg.UIScale | |
| 201 | - | if uiScale <= 0 { | |
| 202 | - | uiScale = 1.0 | |
| 203 | - | } | |
| 200 | + | uiScale := normalizeUIScale(cfg.UIScale) | |
| 204 | 201 | ||
| 205 | 202 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |
| 206 | 203 | ||
| @@ -347,6 +344,43 @@ func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget { | |||
| 347 | 344 | return factory.WrapWidget(&o.outerBox.Widget) | |
| 348 | 345 | } | |
| 349 | 346 | ||
| 347 | + | // SetUIScale updates modal geometry on the GTK main thread. | |
| 348 | + | func (o *Omnibox) SetUIScale(scale float64) { | |
| 349 | + | scale = normalizeUIScale(scale) | |
| 350 | + | if o.uiScale == scale { | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | o.uiScale = scale | |
| 354 | + | o.measuredHeights.valid = false | |
| 355 | + | if o.mainBox == nil { | |
| 356 | + | return | |
| 357 | + | } | |
| 358 | + | width, _ := o.requestedDimensions() | |
| 359 | + | o.mainBox.SetSizeRequest(width, -1) | |
| 360 | + | if o.listBox != nil { | |
| 361 | + | // Rebuilding clears the selected GTK row, which resets | |
| 362 | + | // selectedIndex to -1 via the row-selected callback. Snapshot | |
| 363 | + | // and restore the selection so keyboard selection and ghost | |
| 364 | + | // completion survive a scale change with identical content. | |
| 365 | + | o.mu.RLock() | |
| 366 | + | selected := o.selectedIndex | |
| 367 | + | o.mu.RUnlock() | |
| 368 | + | o.rebuildList() | |
| 369 | + | if selected >= 0 && o.listBox.GetRowAtIndex(selected) != nil { | |
| 370 | + | o.selectIndex(selected) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | o.mu.RLock() | |
| 374 | + | count := len(o.favorites) | |
| 375 | + | if o.bangMode { | |
| 376 | + | count = len(o.bangSuggestions) | |
| 377 | + | } else if o.viewMode == ViewModeHistory { | |
| 378 | + | count = len(o.suggestions) | |
| 379 | + | } | |
| 380 | + | o.mu.RUnlock() | |
| 381 | + | o.resizeAndCenter(count) | |
| 382 | + | } | |
| 383 | + | ||
| 350 | 384 | // estimateRowHeight returns the current best estimate for a single row height. | |
| 351 | 385 | func (o *Omnibox) estimateRowHeight() int { | |
| 352 | 386 | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | |
| @@ -494,6 +528,12 @@ func (o *Omnibox) effectiveMaxRows() int { | |||
| 494 | 528 | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) | |
| 495 | 529 | } | |
| 496 | 530 | ||
| 531 | + | // requestedDimensions returns the scale-aware modal width and top margin for | |
| 532 | + | // the current parent overlay, size config, and UI scale. | |
| 533 | + | func (o *Omnibox) requestedDimensions() (width, marginTop int) { | |
| 534 | + | return CalculateModalDimensionsWithScale(o.parentOverlay, o.sizeCfg, o.uiScale) | |
| 535 | + | } | |
| 536 | + | ||
| 497 | 537 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | |
| 498 | 538 | // rowCount is the number of result rows to display (0 = no content, adapts to parent height). | |
| 499 | 539 | func (o *Omnibox) resizeAndCenter(rowCount int) { | |
| @@ -508,7 +548,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 508 | 548 | ||
| 509 | 549 | // Schedule measurement after GTK has laid out widgets | |
| 510 | 550 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 511 | - | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 551 | + | width, _ := o.requestedDimensions() | |
| 512 | 552 | o.measureAndResize(width, rowCount) | |
| 513 | 553 | return false | |
| 514 | 554 | } | |
| @@ -1980,7 +2020,7 @@ func (o *Omnibox) rebuildList() { | |||
| 1980 | 2020 | if width <= 0 { | |
| 1981 | 2021 | return false // Overlay not allocated yet, skip | |
| 1982 | 2022 | } | |
| 1983 | - | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 2023 | + | forWidth, _ := o.requestedDimensions() | |
| 1984 | 2024 | if o.measureComponentHeights(forWidth) { | |
| 1985 | 2025 | // Re-trigger resize with accurate measurements | |
| 1986 | 2026 | o.mu.RLock() | |
| @@ -2784,7 +2824,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2784 | 2824 | parentWidth = o.parentOverlay.GetAllocatedWidth() | |
| 2785 | 2825 | parentHeight = o.parentOverlay.GetAllocatedHeight() | |
| 2786 | 2826 | } | |
| 2787 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 2827 | + | width, marginTop := o.requestedDimensions() | |
| 2788 | 2828 | log.Debug(). | |
| 2789 | 2829 | Int("parentWidth", parentWidth). | |
| 2790 | 2830 | Int("parentHeight", parentHeight). | |
| @@ -197,10 +197,7 @@ type OmniboxConfig struct { | |||||
| 197 | func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | 197 | func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | ||
| 198 | log := logging.FromContext(ctx) | 198 | log := logging.FromContext(ctx) | ||
| 199 | 199 | ||||
| 200 | - | uiScale := cfg.UIScale | 200 | + | uiScale := normalizeUIScale(cfg.UIScale) |
| 201 | - | if uiScale <= 0 { | |||
| 202 | - | uiScale = 1.0 | |||
| 203 | - | } | |||
| 204 | 201 | ||||
| 205 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | 202 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | ||
| 206 | 203 | ||||
| @@ -347,6 +344,43 @@ func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget { | |||||
| 347 | return factory.WrapWidget(&o.outerBox.Widget) | 344 | return factory.WrapWidget(&o.outerBox.Widget) | ||
| 348 | } | 345 | } | ||
| 349 | 346 | ||||
| 347 | + | // SetUIScale updates modal geometry on the GTK main thread. | |||
| 348 | + | func (o *Omnibox) SetUIScale(scale float64) { | |||
| 349 | + | scale = normalizeUIScale(scale) | |||
| 350 | + | if o.uiScale == scale { | |||
| 351 | + | return | |||
| 352 | + | } | |||
| 353 | + | o.uiScale = scale | |||
| 354 | + | o.measuredHeights.valid = false | |||
| 355 | + | if o.mainBox == nil { | |||
| 356 | + | return | |||
| 357 | + | } | |||
| 358 | + | width, _ := o.requestedDimensions() | |||
| 359 | + | o.mainBox.SetSizeRequest(width, -1) | |||
| 360 | + | if o.listBox != nil { | |||
| 361 | + | // Rebuilding clears the selected GTK row, which resets | |||
| 362 | + | // selectedIndex to -1 via the row-selected callback. Snapshot | |||
| 363 | + | // and restore the selection so keyboard selection and ghost | |||
| 364 | + | // completion survive a scale change with identical content. | |||
| 365 | + | o.mu.RLock() | |||
| 366 | + | selected := o.selectedIndex | |||
| 367 | + | o.mu.RUnlock() | |||
| 368 | + | o.rebuildList() | |||
| 369 | + | if selected >= 0 && o.listBox.GetRowAtIndex(selected) != nil { | |||
| 370 | + | o.selectIndex(selected) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | o.mu.RLock() | |||
| 374 | + | count := len(o.favorites) | |||
| 375 | + | if o.bangMode { | |||
| 376 | + | count = len(o.bangSuggestions) | |||
| 377 | + | } else if o.viewMode == ViewModeHistory { | |||
| 378 | + | count = len(o.suggestions) | |||
| 379 | + | } | |||
| 380 | + | o.mu.RUnlock() | |||
| 381 | + | o.resizeAndCenter(count) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 350 | // estimateRowHeight returns the current best estimate for a single row height. | 384 | // estimateRowHeight returns the current best estimate for a single row height. | ||
| 351 | func (o *Omnibox) estimateRowHeight() int { | 385 | func (o *Omnibox) estimateRowHeight() int { | ||
| 352 | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | 386 | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | ||
| @@ -494,6 +528,12 @@ func (o *Omnibox) effectiveMaxRows() int { | |||||
| 494 | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) | 528 | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) | ||
| 495 | } | 529 | } | ||
| 496 | 530 | ||||
| 531 | + | // requestedDimensions returns the scale-aware modal width and top margin for | |||
| 532 | + | // the current parent overlay, size config, and UI scale. | |||
| 533 | + | func (o *Omnibox) requestedDimensions() (width, marginTop int) { | |||
| 534 | + | return CalculateModalDimensionsWithScale(o.parentOverlay, o.sizeCfg, o.uiScale) | |||
| 535 | + | } | |||
| 536 | + | ||||
| 497 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | 537 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | ||
| 498 | // rowCount is the number of result rows to display (0 = no content, adapts to parent height). | 538 | // rowCount is the number of result rows to display (0 = no content, adapts to parent height). | ||
| 499 | func (o *Omnibox) resizeAndCenter(rowCount int) { | 539 | func (o *Omnibox) resizeAndCenter(rowCount int) { | ||
| @@ -508,7 +548,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||||
| 508 | 548 | ||||
| 509 | // Schedule measurement after GTK has laid out widgets | 549 | // Schedule measurement after GTK has laid out widgets | ||
| 510 | var cb glib.SourceFunc = func(uintptr) bool { | 550 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 511 | - | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | 551 | + | width, _ := o.requestedDimensions() |
| 512 | o.measureAndResize(width, rowCount) | 552 | o.measureAndResize(width, rowCount) | ||
| 513 | return false | 553 | return false | ||
| 514 | } | 554 | } | ||
| @@ -1980,7 +2020,7 @@ func (o *Omnibox) rebuildList() { | |||||
| 1980 | if width <= 0 { | 2020 | if width <= 0 { | ||
| 1981 | return false // Overlay not allocated yet, skip | 2021 | return false // Overlay not allocated yet, skip | ||
| 1982 | } | 2022 | } | ||
| 1983 | - | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | 2023 | + | forWidth, _ := o.requestedDimensions() |
| 1984 | if o.measureComponentHeights(forWidth) { | 2024 | if o.measureComponentHeights(forWidth) { | ||
| 1985 | // Re-trigger resize with accurate measurements | 2025 | // Re-trigger resize with accurate measurements | ||
| 1986 | o.mu.RLock() | 2026 | o.mu.RLock() | ||
| @@ -2784,7 +2824,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2784 | parentWidth = o.parentOverlay.GetAllocatedWidth() | 2824 | parentWidth = o.parentOverlay.GetAllocatedWidth() | ||
| 2785 | parentHeight = o.parentOverlay.GetAllocatedHeight() | 2825 | parentHeight = o.parentOverlay.GetAllocatedHeight() | ||
| 2786 | } | 2826 | } | ||
| 2787 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | 2827 | + | width, marginTop := o.requestedDimensions() |
| 2788 | log.Debug(). | 2828 | log.Debug(). | ||
| 2789 | Int("parentWidth", parentWidth). | 2829 | Int("parentWidth", parentWidth). | ||
| 2790 | Int("parentHeight", parentHeight). | 2830 | Int("parentHeight", parentHeight). | ||
M internal/ui/component/session_manager.go +23 -6
| @@ -90,10 +90,7 @@ type SessionManagerConfig struct { | |||
| 90 | 90 | func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager { | |
| 91 | 91 | log := logging.FromContext(ctx) | |
| 92 | 92 | ||
| 93 | - | uiScale := cfg.UIScale | |
| 94 | - | if uiScale <= 0 { | |
| 95 | - | uiScale = 1.0 | |
| 96 | - | } | |
| 93 | + | uiScale := normalizeUIScale(cfg.UIScale) | |
| 97 | 94 | ||
| 98 | 95 | sm := &SessionManager{ | |
| 99 | 96 | ctx: ctx, | |
| @@ -215,12 +212,32 @@ func (sm *SessionManager) Toggle(ctx context.Context) { | |||
| 215 | 212 | } | |
| 216 | 213 | } | |
| 217 | 214 | ||
| 215 | + | // SetUIScale updates modal geometry on the GTK main thread. | |
| 216 | + | func (sm *SessionManager) SetUIScale(scale float64) { | |
| 217 | + | scale = normalizeUIScale(scale) | |
| 218 | + | if sm.uiScale == scale { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | sm.uiScale = scale | |
| 222 | + | sm.measuredHeights.valid = false | |
| 223 | + | sm.resizeAndCenter() | |
| 224 | + | if sm.IsVisible() { | |
| 225 | + | sm.populateList() | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // requestedDimensions returns the scale-aware modal width and top margin for | |
| 230 | + | // the current parent overlay, session size defaults, and UI scale. | |
| 231 | + | func (sm *SessionManager) requestedDimensions() (width, marginTop int) { | |
| 232 | + | return CalculateModalDimensionsWithScale(sm.parentOverlay, SessionManagerSizeDefaults, sm.uiScale) | |
| 233 | + | } | |
| 234 | + | ||
| 218 | 235 | func (sm *SessionManager) resizeAndCenter() { | |
| 219 | 236 | if sm.outerBox == nil || sm.mainBox == nil { | |
| 220 | 237 | return | |
| 221 | 238 | } | |
| 222 | 239 | ||
| 223 | - | width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | |
| 240 | + | width, marginTop := sm.requestedDimensions() | |
| 224 | 241 | ||
| 225 | 242 | sm.mainBox.SetSizeRequest(width, -1) | |
| 226 | 243 | sm.outerBox.SetMarginTop(marginTop) | |
| @@ -477,7 +494,7 @@ func (sm *SessionManager) populateList() { | |||
| 477 | 494 | sm.selectBySessionIndex(selectedIdx) | |
| 478 | 495 | ||
| 479 | 496 | // Attempt to measure actual widget heights for accurate sizing | |
| 480 | - | width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | |
| 497 | + | width, _ := sm.requestedDimensions() | |
| 481 | 498 | sm.measureWidgetHeights(width) | |
| 482 | 499 | ||
| 483 | 500 | sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount) | |
| @@ -90,10 +90,7 @@ type SessionManagerConfig struct { | |||||
| 90 | func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager { | 90 | func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager { | ||
| 91 | log := logging.FromContext(ctx) | 91 | log := logging.FromContext(ctx) | ||
| 92 | 92 | ||||
| 93 | - | uiScale := cfg.UIScale | 93 | + | uiScale := normalizeUIScale(cfg.UIScale) |
| 94 | - | if uiScale <= 0 { | |||
| 95 | - | uiScale = 1.0 | |||
| 96 | - | } | |||
| 97 | 94 | ||||
| 98 | sm := &SessionManager{ | 95 | sm := &SessionManager{ | ||
| 99 | ctx: ctx, | 96 | ctx: ctx, | ||
| @@ -215,12 +212,32 @@ func (sm *SessionManager) Toggle(ctx context.Context) { | |||||
| 215 | } | 212 | } | ||
| 216 | } | 213 | } | ||
| 217 | 214 | ||||
| 215 | + | // SetUIScale updates modal geometry on the GTK main thread. | |||
| 216 | + | func (sm *SessionManager) SetUIScale(scale float64) { | |||
| 217 | + | scale = normalizeUIScale(scale) | |||
| 218 | + | if sm.uiScale == scale { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | sm.uiScale = scale | |||
| 222 | + | sm.measuredHeights.valid = false | |||
| 223 | + | sm.resizeAndCenter() | |||
| 224 | + | if sm.IsVisible() { | |||
| 225 | + | sm.populateList() | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // requestedDimensions returns the scale-aware modal width and top margin for | |||
| 230 | + | // the current parent overlay, session size defaults, and UI scale. | |||
| 231 | + | func (sm *SessionManager) requestedDimensions() (width, marginTop int) { | |||
| 232 | + | return CalculateModalDimensionsWithScale(sm.parentOverlay, SessionManagerSizeDefaults, sm.uiScale) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 218 | func (sm *SessionManager) resizeAndCenter() { | 235 | func (sm *SessionManager) resizeAndCenter() { | ||
| 219 | if sm.outerBox == nil || sm.mainBox == nil { | 236 | if sm.outerBox == nil || sm.mainBox == nil { | ||
| 220 | return | 237 | return | ||
| 221 | } | 238 | } | ||
| 222 | 239 | ||||
| 223 | - | width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | 240 | + | width, marginTop := sm.requestedDimensions() |
| 224 | 241 | ||||
| 225 | sm.mainBox.SetSizeRequest(width, -1) | 242 | sm.mainBox.SetSizeRequest(width, -1) | ||
| 226 | sm.outerBox.SetMarginTop(marginTop) | 243 | sm.outerBox.SetMarginTop(marginTop) | ||
| @@ -477,7 +494,7 @@ func (sm *SessionManager) populateList() { | |||||
| 477 | sm.selectBySessionIndex(selectedIdx) | 494 | sm.selectBySessionIndex(selectedIdx) | ||
| 478 | 495 | ||||
| 479 | // Attempt to measure actual widget heights for accurate sizing | 496 | // Attempt to measure actual widget heights for accurate sizing | ||
| 480 | - | width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | 497 | + | width, _ := sm.requestedDimensions() |
| 481 | sm.measureWidgetHeights(width) | 498 | sm.measureWidgetHeights(width) | ||
| 482 | 499 | ||||
| 483 | sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount) | 500 | sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount) | ||
M internal/ui/component/workspace_view.go +12 -0
| @@ -732,6 +732,18 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) { | |||
| 732 | 732 | wv.omniboxCfg = cfg | |
| 733 | 733 | } | |
| 734 | 734 | ||
| 735 | + | // SetOmniboxUIScale updates current and future omniboxes on the GTK main thread. | |
| 736 | + | func (wv *WorkspaceView) SetOmniboxUIScale(scale float64) { | |
| 737 | + | scale = normalizeUIScale(scale) | |
| 738 | + | wv.mu.Lock() | |
| 739 | + | wv.omniboxCfg.UIScale = scale | |
| 740 | + | omnibox := wv.omnibox | |
| 741 | + | wv.mu.Unlock() | |
| 742 | + | if omnibox != nil { | |
| 743 | + | omnibox.SetUIScale(scale) | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | ||
| 735 | 747 | // SetFindBarConfig stores the find bar configuration for later use. | |
| 736 | 748 | func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) { | |
| 737 | 749 | wv.mu.Lock() | |
| @@ -732,6 +732,18 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) { | |||||
| 732 | wv.omniboxCfg = cfg | 732 | wv.omniboxCfg = cfg | ||
| 733 | } | 733 | } | ||
| 734 | 734 | ||||
| 735 | + | // SetOmniboxUIScale updates current and future omniboxes on the GTK main thread. | |||
| 736 | + | func (wv *WorkspaceView) SetOmniboxUIScale(scale float64) { | |||
| 737 | + | scale = normalizeUIScale(scale) | |||
| 738 | + | wv.mu.Lock() | |||
| 739 | + | wv.omniboxCfg.UIScale = scale | |||
| 740 | + | omnibox := wv.omnibox | |||
| 741 | + | wv.mu.Unlock() | |||
| 742 | + | if omnibox != nil { | |||
| 743 | + | omnibox.SetUIScale(scale) | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | ||||
| 735 | // SetFindBarConfig stores the find bar configuration for later use. | 747 | // SetFindBarConfig stores the find bar configuration for later use. | ||
| 736 | func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) { | 748 | func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) { | ||
| 737 | wv.mu.Lock() | 749 | wv.mu.Lock() | ||
M internal/ui/theme/css.go +2 -1
| @@ -951,7 +951,8 @@ func generateSessionManagerCSS(p Palette) string { | |||
| 951 | 951 | border: 0.0625em solid var(--border); | |
| 952 | 952 | border-radius: 0.1875em; | |
| 953 | 953 | padding: 0; | |
| 954 | - | min-width: 28em; | |
| 954 | + | /* Width is owned by the Go sizing request so CSS must not force | |
| 955 | + | growth beyond the percentage viewport limit. */ | |
| 955 | 956 | /* Note: max-width not supported in GTK4 CSS, rely on container constraints */ | |
| 956 | 957 | } | |
| 957 | 958 | ||
| @@ -951,7 +951,8 @@ func generateSessionManagerCSS(p Palette) string { | |||||
| 951 | border: 0.0625em solid var(--border); | 951 | border: 0.0625em solid var(--border); | ||
| 952 | border-radius: 0.1875em; | 952 | border-radius: 0.1875em; | ||
| 953 | padding: 0; | 953 | padding: 0; | ||
| 954 | - | min-width: 28em; | 954 | + | /* Width is owned by the Go sizing request so CSS must not force |
| 955 | + | growth beyond the percentage viewport limit. */ | |||
| 955 | /* Note: max-width not supported in GTK4 CSS, rely on container constraints */ | 956 | /* Note: max-width not supported in GTK4 CSS, rely on container constraints */ | ||
| 956 | } | 957 | } | ||
| 957 | 958 | ||||
M internal/ui/theme/css_test.go +7 -0
| @@ -311,3 +311,10 @@ func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |||
| 311 | 311 | assert.NotContains(t, css, "pointer-events:") | |
| 312 | 312 | assert.NotContains(t, css, "text-align:") | |
| 313 | 313 | } | |
| 314 | + | ||
| 315 | + | func TestGenerateCSS_SessionContainerHasNoMinWidth(t *testing.T) { | |
| 316 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 317 | + | block := cssRuleBlock(t, css, ".session-manager-container") | |
| 318 | + | assert.NotContains(t, block, "min-width:") | |
| 319 | + | assert.Contains(t, block, "background-color: var(--surface-variant);") | |
| 320 | + | } | |
| @@ -311,3 +311,10 @@ func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |||||
| 311 | assert.NotContains(t, css, "pointer-events:") | 311 | assert.NotContains(t, css, "pointer-events:") | ||
| 312 | assert.NotContains(t, css, "text-align:") | 312 | assert.NotContains(t, css, "text-align:") | ||
| 313 | } | 313 | } | ||
| 314 | + | ||||
| 315 | + | func TestGenerateCSS_SessionContainerHasNoMinWidth(t *testing.T) { | |||
| 316 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 317 | + | block := cssRuleBlock(t, css, ".session-manager-container") | |||
| 318 | + | assert.NotContains(t, block, "min-width:") | |||
| 319 | + | assert.Contains(t, block, "background-color: var(--surface-variant);") | |||
| 320 | + | } | |||