detached b124712

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 UnverifiedB5690EEEBB952194 The 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
55135513 sessionCfg := runtimeCfg.Session
55145514 a.syncExternalThemeWatcher(ctx)
55155515 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+ }
55165527 for _, bw := range a.browserWindows {
55175528 if bw == nil {
55185529 continue
55195530 }
5531+ if bw.sessionManager != nil {
5532+ bw.sessionManager.SetUIScale(runtimeCfg.DefaultUIScale)
5533+ }
55205534 // Reapply sidebar width from live config (reloads after sidebar_width
55215535 // changes in the config file).
55225536 bw.applySidebarWidthConfig(a)
@@ -5513,10 +5513,24 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
5513 sessionCfg := runtimeCfg.Session5513 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 continue5529 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_width5534 // 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 @@
11 package component
22
33 import (
4+ "math"
5+
46 "github.com/bnema/dumber/internal/ui/layout"
57 "github.com/bnema/puregotk/v4/gtk"
68 )
@@ -146,6 +148,24 @@ func ResolveModalSizeConfig(cfg, defaults ModalSizeConfig) ModalSizeConfig {
146148 // CalculateModalDimensions computes width and top margin based on parent overlay.
147149 // Returns calculated width and top margin in pixels.
148150 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+
149169 var parentWidth, parentHeight int
150170
151171 if parent != nil {
@@ -156,16 +176,19 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
156176 // Use fallback if parent not allocated or too small to be useful
157177 // A width < 100 is too small for any meaningful modal
158178 if parentWidth < 100 {
159- parentWidth = cfg.FallbackWidth
179+ parentWidth = ScaleValue(cfg.FallbackWidth, scale)
160180 }
161181 if parentHeight < 100 {
162182 parentHeight = cfg.FallbackHeight
163183 }
164184
165185 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)
167190 } else {
168- width = min(int(float64(parentWidth)*cfg.WidthPct), cfg.MaxWidth)
191+ width = min(int(float64(parentWidth)*cfg.WidthPct), ScaleValue(cfg.MaxWidth, scale))
169192 }
170193
171194 if cfg.UseFixedTopMargin {
@@ -1,6 +1,8 @@
1 package component1 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 int169 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 useful176 // Use fallback if parent not allocated or too small to be useful
157 // A width < 100 is too small for any meaningful modal177 // A width < 100 is too small for any meaningful modal
158 if parentWidth < 100 {178 if parentWidth < 100 {
159- parentWidth = cfg.FallbackWidth179+ parentWidth = ScaleValue(cfg.FallbackWidth, scale)
160 }180 }
161 if parentHeight < 100 {181 if parentHeight < 100 {
162 parentHeight = cfg.FallbackHeight182 parentHeight = cfg.FallbackHeight
163 }183 }
164 184
165 if cfg.FixedWidth > 0 {185 if cfg.FixedWidth > 0 {
166- width = cfg.FixedWidth186+ // 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 @@
11 package component
22
33 import (
4+ "math"
45 "testing"
56
67 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
@@ -283,3 +284,130 @@ func TestEffectiveMaxRows_NoAdaptation(t *testing.T) {
283284 got := EffectiveMaxRows(500, 72, sizeCfg, defaults)
284285 assert.Equal(t, 10, got)
285286 }
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 component1 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 {
197197 func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
198198 log := logging.FromContext(ctx)
199199
200- uiScale := cfg.UIScale
201- if uiScale <= 0 {
202- uiScale = 1.0
203- }
200+ uiScale := normalizeUIScale(cfg.UIScale)
204201
205202 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
206203
@@ -347,6 +344,43 @@ func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
347344 return factory.WrapWidget(&o.outerBox.Widget)
348345 }
349346
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+
350384 // estimateRowHeight returns the current best estimate for a single row height.
351385 func (o *Omnibox) estimateRowHeight() int {
352386 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
@@ -494,6 +528,12 @@ func (o *Omnibox) effectiveMaxRows() int {
494528 return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
495529 }
496530
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+
497537 // resizeAndCenter adjusts the omnibox size based on content and centers it.
498538 // rowCount is the number of result rows to display (0 = no content, adapts to parent height).
499539 func (o *Omnibox) resizeAndCenter(rowCount int) {
@@ -508,7 +548,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
508548
509549 // Schedule measurement after GTK has laid out widgets
510550 var cb glib.SourceFunc = func(uintptr) bool {
511- width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
551+ width, _ := o.requestedDimensions()
512552 o.measureAndResize(width, rowCount)
513553 return false
514554 }
@@ -1980,7 +2020,7 @@ func (o *Omnibox) rebuildList() {
19802020 if width <= 0 {
19812021 return false // Overlay not allocated yet, skip
19822022 }
1983- forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2023+ forWidth, _ := o.requestedDimensions()
19842024 if o.measureComponentHeights(forWidth) {
19852025 // Re-trigger resize with accurate measurements
19862026 o.mu.RLock()
@@ -2784,7 +2824,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
27842824 parentWidth = o.parentOverlay.GetAllocatedWidth()
27852825 parentHeight = o.parentOverlay.GetAllocatedHeight()
27862826 }
2787- width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2827+ width, marginTop := o.requestedDimensions()
27882828 log.Debug().
27892829 Int("parentWidth", parentWidth).
27902830 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.UIScale200+ 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 widgets549 // 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 false553 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, skip2021 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 measurements2025 // 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 {
9090 func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
9191 log := logging.FromContext(ctx)
9292
93- uiScale := cfg.UIScale
94- if uiScale <= 0 {
95- uiScale = 1.0
96- }
93+ uiScale := normalizeUIScale(cfg.UIScale)
9794
9895 sm := &SessionManager{
9996 ctx: ctx,
@@ -215,12 +212,32 @@ func (sm *SessionManager) Toggle(ctx context.Context) {
215212 }
216213 }
217214
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+
218235 func (sm *SessionManager) resizeAndCenter() {
219236 if sm.outerBox == nil || sm.mainBox == nil {
220237 return
221238 }
222239
223- width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
240+ width, marginTop := sm.requestedDimensions()
224241
225242 sm.mainBox.SetSizeRequest(width, -1)
226243 sm.outerBox.SetMarginTop(marginTop)
@@ -477,7 +494,7 @@ func (sm *SessionManager) populateList() {
477494 sm.selectBySessionIndex(selectedIdx)
478495
479496 // Attempt to measure actual widget heights for accurate sizing
480- width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
497+ width, _ := sm.requestedDimensions()
481498 sm.measureWidgetHeights(width)
482499
483500 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.UIScale93+ 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 return237 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 sizing496 // 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) {
732732 wv.omniboxCfg = cfg
733733 }
734734
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+
735747 // SetFindBarConfig stores the find bar configuration for later use.
736748 func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
737749 wv.mu.Lock()
@@ -732,6 +732,18 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
732 wv.omniboxCfg = cfg732 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 {
951951 border: 0.0625em solid var(--border);
952952 border-radius: 0.1875em;
953953 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. */
955956 /* Note: max-width not supported in GTK4 CSS, rely on container constraints */
956957 }
957958
@@ -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) {
311311 assert.NotContains(t, css, "pointer-events:")
312312 assert.NotContains(t, css, "text-align:")
313313 }
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+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close