detached a753e76

refactor(ui): extract shared modal sizing helpers for UI scale support

commit
a753e76ed1d72cfb6b5288cc8c172b081a9b5094
author
bnema · · 2025-12-25 11:39 UTC
parent
4fc8d20
signature
GPG Unverified2B82207D370127A0 The key is unknown
5 files · +364 -95
M internal/ui/app.go +1 -0
@@ -410,6 +410,7 @@ func (a *App) initSessionManager(ctx context.Context) {
410410 ListSessionsUC: listSessionsUC,
411411 DeleteSessionUC: deleteSessionUC,
412412 CurrentSession: a.deps.CurrentSessionID,
413+ UIScale: a.deps.Config.DefaultUIScale,
413414 OnClose: func() {
414415 log.Debug().Msg("session manager closed")
415416 },
@@ -410,6 +410,7 @@ func (a *App) initSessionManager(ctx context.Context) {
410 ListSessionsUC: listSessionsUC,410 ListSessionsUC: listSessionsUC,
411 DeleteSessionUC: deleteSessionUC,411 DeleteSessionUC: deleteSessionUC,
412 CurrentSession: a.deps.CurrentSessionID,412 CurrentSession: a.deps.CurrentSessionID,
413+ UIScale: a.deps.Config.DefaultUIScale,
413 OnClose: func() {414 OnClose: func() {
414 log.Debug().Msg("session manager closed")415 log.Debug().Msg("session manager closed")
415 },416 },
A internal/ui/component/modal_sizing.go +123 -0
@@ -0,0 +1,123 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/layout"
5+ "github.com/jwijenbergh/puregotk/v4/gtk"
6+)
7+
8+// ModalSizeConfig holds configuration for modal sizing calculations.
9+type ModalSizeConfig struct {
10+ WidthPct float64 // Percentage of parent width (e.g., 0.6)
11+ MaxWidth int // Maximum width in pixels
12+ TopMarginPct float64 // Top margin as percentage of parent height
13+ FallbackWidth int // Fallback when parent not allocated
14+ FallbackHeight int // Fallback height
15+}
16+
17+// RowHeightDefaults holds default (unscaled) row heights for list-based modals.
18+// These are base pixel values that should be multiplied by UI scale.
19+type RowHeightDefaults struct {
20+ Standard int // Standard list row (e.g., session row, history row)
21+ Compact int // Compact row (e.g., tree row, sub-item)
22+ Divider int // Divider/separator row
23+}
24+
25+// ListDisplayDefaults holds display limits for list-based modals.
26+type ListDisplayDefaults struct {
27+ MaxVisibleRows int // Maximum rows visible before scrolling
28+ MaxResults int // Maximum results to fetch/display
29+}
30+
31+// Package-level defaults for row heights.
32+var DefaultRowHeights = RowHeightDefaults{
33+ Standard: 50,
34+ Compact: 28,
35+ Divider: 30,
36+}
37+
38+// OmniboxSizeDefaults provides default sizing for omnibox modal.
39+var OmniboxSizeDefaults = ModalSizeConfig{
40+ WidthPct: 0.8,
41+ MaxWidth: 800,
42+ TopMarginPct: 0.2,
43+ FallbackWidth: 800,
44+ FallbackHeight: 600,
45+}
46+
47+// OmniboxListDefaults provides display limits for omnibox modal.
48+var OmniboxListDefaults = ListDisplayDefaults{
49+ MaxVisibleRows: 10,
50+ MaxResults: 10,
51+}
52+
53+// SessionManagerSizeDefaults provides default sizing for session manager modal.
54+var SessionManagerSizeDefaults = ModalSizeConfig{
55+ WidthPct: 0.6,
56+ MaxWidth: 600,
57+ TopMarginPct: 0.15,
58+ FallbackWidth: 600,
59+ FallbackHeight: 600,
60+}
61+
62+// SessionManagerListDefaults provides display limits for session manager modal.
63+var SessionManagerListDefaults = ListDisplayDefaults{
64+ MaxVisibleRows: 6,
65+ MaxResults: 50,
66+}
67+
68+// CalculateModalDimensions computes width and top margin based on parent overlay.
69+// Returns calculated width and top margin in pixels.
70+func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
71+ var parentWidth, parentHeight int
72+
73+ if parent != nil {
74+ parentWidth = parent.GetAllocatedWidth()
75+ parentHeight = parent.GetAllocatedHeight()
76+ }
77+
78+ if parentWidth <= 0 {
79+ parentWidth = cfg.FallbackWidth
80+ }
81+ if parentHeight <= 0 {
82+ parentHeight = cfg.FallbackHeight
83+ }
84+
85+ width = int(float64(parentWidth) * cfg.WidthPct)
86+ if width > cfg.MaxWidth {
87+ width = cfg.MaxWidth
88+ }
89+
90+ marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
91+ return width, marginTop
92+}
93+
94+// SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
95+// Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
96+func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
97+ if sw == nil {
98+ return
99+ }
100+ sw.SetMinContentHeight(-1)
101+ sw.SetMaxContentHeight(height)
102+ sw.SetMinContentHeight(height)
103+}
104+
105+// MeasureWidgetHeight returns the natural height of a widget for a given width.
106+// Returns 0 if widget is nil or measurement fails.
107+func MeasureWidgetHeight(widget *gtk.Widget, forWidth int) int {
108+ if widget == nil {
109+ return 0
110+ }
111+ var minH, natH int
112+ widget.Measure(gtk.OrientationVerticalValue, forWidth, &minH, &natH, nil, nil)
113+ return natH
114+}
115+
116+// ScaleValue scales a base pixel value by UI scale factor.
117+// Returns the base value if scale is <= 0.
118+func ScaleValue(base int, uiScale float64) int {
119+ if uiScale <= 0 {
120+ uiScale = 1.0
121+ }
122+ return int(float64(base) * uiScale)
123+}
@@ -0,0 +1,123 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/layout"
5+ "github.com/jwijenbergh/puregotk/v4/gtk"
6+)
7+
8+// ModalSizeConfig holds configuration for modal sizing calculations.
9+type ModalSizeConfig struct {
10+ WidthPct float64 // Percentage of parent width (e.g., 0.6)
11+ MaxWidth int // Maximum width in pixels
12+ TopMarginPct float64 // Top margin as percentage of parent height
13+ FallbackWidth int // Fallback when parent not allocated
14+ FallbackHeight int // Fallback height
15+}
16+
17+// RowHeightDefaults holds default (unscaled) row heights for list-based modals.
18+// These are base pixel values that should be multiplied by UI scale.
19+type RowHeightDefaults struct {
20+ Standard int // Standard list row (e.g., session row, history row)
21+ Compact int // Compact row (e.g., tree row, sub-item)
22+ Divider int // Divider/separator row
23+}
24+
25+// ListDisplayDefaults holds display limits for list-based modals.
26+type ListDisplayDefaults struct {
27+ MaxVisibleRows int // Maximum rows visible before scrolling
28+ MaxResults int // Maximum results to fetch/display
29+}
30+
31+// Package-level defaults for row heights.
32+var DefaultRowHeights = RowHeightDefaults{
33+ Standard: 50,
34+ Compact: 28,
35+ Divider: 30,
36+}
37+
38+// OmniboxSizeDefaults provides default sizing for omnibox modal.
39+var OmniboxSizeDefaults = ModalSizeConfig{
40+ WidthPct: 0.8,
41+ MaxWidth: 800,
42+ TopMarginPct: 0.2,
43+ FallbackWidth: 800,
44+ FallbackHeight: 600,
45+}
46+
47+// OmniboxListDefaults provides display limits for omnibox modal.
48+var OmniboxListDefaults = ListDisplayDefaults{
49+ MaxVisibleRows: 10,
50+ MaxResults: 10,
51+}
52+
53+// SessionManagerSizeDefaults provides default sizing for session manager modal.
54+var SessionManagerSizeDefaults = ModalSizeConfig{
55+ WidthPct: 0.6,
56+ MaxWidth: 600,
57+ TopMarginPct: 0.15,
58+ FallbackWidth: 600,
59+ FallbackHeight: 600,
60+}
61+
62+// SessionManagerListDefaults provides display limits for session manager modal.
63+var SessionManagerListDefaults = ListDisplayDefaults{
64+ MaxVisibleRows: 6,
65+ MaxResults: 50,
66+}
67+
68+// CalculateModalDimensions computes width and top margin based on parent overlay.
69+// Returns calculated width and top margin in pixels.
70+func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
71+ var parentWidth, parentHeight int
72+
73+ if parent != nil {
74+ parentWidth = parent.GetAllocatedWidth()
75+ parentHeight = parent.GetAllocatedHeight()
76+ }
77+
78+ if parentWidth <= 0 {
79+ parentWidth = cfg.FallbackWidth
80+ }
81+ if parentHeight <= 0 {
82+ parentHeight = cfg.FallbackHeight
83+ }
84+
85+ width = int(float64(parentWidth) * cfg.WidthPct)
86+ if width > cfg.MaxWidth {
87+ width = cfg.MaxWidth
88+ }
89+
90+ marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
91+ return width, marginTop
92+}
93+
94+// SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
95+// Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
96+func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
97+ if sw == nil {
98+ return
99+ }
100+ sw.SetMinContentHeight(-1)
101+ sw.SetMaxContentHeight(height)
102+ sw.SetMinContentHeight(height)
103+}
104+
105+// MeasureWidgetHeight returns the natural height of a widget for a given width.
106+// Returns 0 if widget is nil or measurement fails.
107+func MeasureWidgetHeight(widget *gtk.Widget, forWidth int) int {
108+ if widget == nil {
109+ return 0
110+ }
111+ var minH, natH int
112+ widget.Measure(gtk.OrientationVerticalValue, forWidth, &minH, &natH, nil, nil)
113+ return natH
114+}
115+
116+// ScaleValue scales a base pixel value by UI scale factor.
117+// Returns the base value if scale is <= 0.
118+func ScaleValue(base int, uiScale float64) int {
119+ if uiScale <= 0 {
120+ uiScale = 1.0
121+ }
122+ return int(float64(base) * uiScale)
123+}
A internal/ui/component/modal_sizing_test.go +145 -0
@@ -0,0 +1,145 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestCalculateModalDimensions_NilParent(t *testing.T) {
11+ cfg := ModalSizeConfig{
12+ WidthPct: 0.6,
13+ MaxWidth: 600,
14+ TopMarginPct: 0.15,
15+ FallbackWidth: 800,
16+ FallbackHeight: 600,
17+ }
18+
19+ width, marginTop := CalculateModalDimensions(nil, cfg)
20+
21+ // Should use fallback values
22+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
23+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
24+
25+ assert.Equal(t, expectedWidth, width)
26+ assert.Equal(t, expectedMargin, marginTop)
27+}
28+
29+func TestCalculateModalDimensions_ZeroAllocatedSize(t *testing.T) {
30+ cfg := ModalSizeConfig{
31+ WidthPct: 0.5,
32+ MaxWidth: 500,
33+ TopMarginPct: 0.2,
34+ FallbackWidth: 1000,
35+ FallbackHeight: 800,
36+ }
37+
38+ parent := layoutmocks.NewMockOverlayWidget(t)
39+ parent.EXPECT().GetAllocatedWidth().Return(0)
40+ parent.EXPECT().GetAllocatedHeight().Return(0)
41+
42+ width, marginTop := CalculateModalDimensions(parent, cfg)
43+
44+ // Should use fallback values when allocated size is 0
45+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
46+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
47+
48+ assert.Equal(t, expectedWidth, width)
49+ assert.Equal(t, expectedMargin, marginTop)
50+}
51+
52+func TestCalculateModalDimensions_WidthCapped(t *testing.T) {
53+ cfg := ModalSizeConfig{
54+ WidthPct: 0.8,
55+ MaxWidth: 600,
56+ TopMarginPct: 0.1,
57+ FallbackWidth: 800,
58+ FallbackHeight: 600,
59+ }
60+
61+ parent := layoutmocks.NewMockOverlayWidget(t)
62+ parent.EXPECT().GetAllocatedWidth().Return(1920) // Large screen
63+ parent.EXPECT().GetAllocatedHeight().Return(1080)
64+
65+ width, marginTop := CalculateModalDimensions(parent, cfg)
66+
67+ // 0.8 * 1920 = 1536, but capped at MaxWidth 600
68+ assert.Equal(t, cfg.MaxWidth, width)
69+ assert.Equal(t, int(float64(1080)*cfg.TopMarginPct), marginTop)
70+}
71+
72+func TestCalculateModalDimensions_WidthPercentage(t *testing.T) {
73+ cfg := ModalSizeConfig{
74+ WidthPct: 0.6,
75+ MaxWidth: 1000, // High cap, won't be hit
76+ TopMarginPct: 0.15,
77+ FallbackWidth: 800,
78+ FallbackHeight: 600,
79+ }
80+
81+ parent := layoutmocks.NewMockOverlayWidget(t)
82+ parent.EXPECT().GetAllocatedWidth().Return(800)
83+ parent.EXPECT().GetAllocatedHeight().Return(600)
84+
85+ width, marginTop := CalculateModalDimensions(parent, cfg)
86+
87+ assert.Equal(t, 480, width)
88+ assert.Equal(t, 90, marginTop)
89+}
90+
91+func TestScaleValue_DefaultScale(t *testing.T) {
92+ // When scale is 0 or negative, should default to 1.0
93+ assert.Equal(t, 50, ScaleValue(50, 0))
94+ assert.Equal(t, 50, ScaleValue(50, -1.0))
95+}
96+
97+func TestScaleValue_NormalScale(t *testing.T) {
98+ assert.Equal(t, 50, ScaleValue(50, 1.0))
99+}
100+
101+func TestScaleValue_Scaling(t *testing.T) {
102+ // 1.5x scale
103+ assert.Equal(t, 75, ScaleValue(50, 1.5))
104+ // 2x scale
105+ assert.Equal(t, 100, ScaleValue(50, 2.0))
106+ // 1.2x scale (common UI scale)
107+ assert.Equal(t, 60, ScaleValue(50, 1.2))
108+}
109+
110+func TestMeasureWidgetHeight_NilWidget(t *testing.T) {
111+ height := MeasureWidgetHeight(nil, 100)
112+ assert.Equal(t, 0, height)
113+}
114+
115+func TestDefaultRowHeights_Values(t *testing.T) {
116+ // Ensure defaults are sensible
117+ assert.Positive(t, DefaultRowHeights.Standard)
118+ assert.Positive(t, DefaultRowHeights.Compact)
119+ assert.Positive(t, DefaultRowHeights.Divider)
120+ // Standard should be larger than compact
121+ assert.Greater(t, DefaultRowHeights.Standard, DefaultRowHeights.Compact)
122+}
123+
124+func TestOmniboxSizeDefaults_Values(t *testing.T) {
125+ assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0)
126+ assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0)
127+ assert.Positive(t, OmniboxSizeDefaults.MaxWidth)
128+ assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0)
129+ assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0)
130+}
131+
132+func TestSessionManagerSizeDefaults_Values(t *testing.T) {
133+ assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)
134+ assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)
135+ assert.Positive(t, SessionManagerSizeDefaults.MaxWidth)
136+ assert.Greater(t, SessionManagerSizeDefaults.TopMarginPct, 0.0)
137+ assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)
138+}
139+
140+func TestListDisplayDefaults_Values(t *testing.T) {
141+ assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)
142+ assert.Positive(t, OmniboxListDefaults.MaxResults)
143+ assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows)
144+ assert.Positive(t, SessionManagerListDefaults.MaxResults)
145+}
@@ -0,0 +1,145 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestCalculateModalDimensions_NilParent(t *testing.T) {
11+ cfg := ModalSizeConfig{
12+ WidthPct: 0.6,
13+ MaxWidth: 600,
14+ TopMarginPct: 0.15,
15+ FallbackWidth: 800,
16+ FallbackHeight: 600,
17+ }
18+
19+ width, marginTop := CalculateModalDimensions(nil, cfg)
20+
21+ // Should use fallback values
22+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
23+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
24+
25+ assert.Equal(t, expectedWidth, width)
26+ assert.Equal(t, expectedMargin, marginTop)
27+}
28+
29+func TestCalculateModalDimensions_ZeroAllocatedSize(t *testing.T) {
30+ cfg := ModalSizeConfig{
31+ WidthPct: 0.5,
32+ MaxWidth: 500,
33+ TopMarginPct: 0.2,
34+ FallbackWidth: 1000,
35+ FallbackHeight: 800,
36+ }
37+
38+ parent := layoutmocks.NewMockOverlayWidget(t)
39+ parent.EXPECT().GetAllocatedWidth().Return(0)
40+ parent.EXPECT().GetAllocatedHeight().Return(0)
41+
42+ width, marginTop := CalculateModalDimensions(parent, cfg)
43+
44+ // Should use fallback values when allocated size is 0
45+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
46+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
47+
48+ assert.Equal(t, expectedWidth, width)
49+ assert.Equal(t, expectedMargin, marginTop)
50+}
51+
52+func TestCalculateModalDimensions_WidthCapped(t *testing.T) {
53+ cfg := ModalSizeConfig{
54+ WidthPct: 0.8,
55+ MaxWidth: 600,
56+ TopMarginPct: 0.1,
57+ FallbackWidth: 800,
58+ FallbackHeight: 600,
59+ }
60+
61+ parent := layoutmocks.NewMockOverlayWidget(t)
62+ parent.EXPECT().GetAllocatedWidth().Return(1920) // Large screen
63+ parent.EXPECT().GetAllocatedHeight().Return(1080)
64+
65+ width, marginTop := CalculateModalDimensions(parent, cfg)
66+
67+ // 0.8 * 1920 = 1536, but capped at MaxWidth 600
68+ assert.Equal(t, cfg.MaxWidth, width)
69+ assert.Equal(t, int(float64(1080)*cfg.TopMarginPct), marginTop)
70+}
71+
72+func TestCalculateModalDimensions_WidthPercentage(t *testing.T) {
73+ cfg := ModalSizeConfig{
74+ WidthPct: 0.6,
75+ MaxWidth: 1000, // High cap, won't be hit
76+ TopMarginPct: 0.15,
77+ FallbackWidth: 800,
78+ FallbackHeight: 600,
79+ }
80+
81+ parent := layoutmocks.NewMockOverlayWidget(t)
82+ parent.EXPECT().GetAllocatedWidth().Return(800)
83+ parent.EXPECT().GetAllocatedHeight().Return(600)
84+
85+ width, marginTop := CalculateModalDimensions(parent, cfg)
86+
87+ assert.Equal(t, 480, width)
88+ assert.Equal(t, 90, marginTop)
89+}
90+
91+func TestScaleValue_DefaultScale(t *testing.T) {
92+ // When scale is 0 or negative, should default to 1.0
93+ assert.Equal(t, 50, ScaleValue(50, 0))
94+ assert.Equal(t, 50, ScaleValue(50, -1.0))
95+}
96+
97+func TestScaleValue_NormalScale(t *testing.T) {
98+ assert.Equal(t, 50, ScaleValue(50, 1.0))
99+}
100+
101+func TestScaleValue_Scaling(t *testing.T) {
102+ // 1.5x scale
103+ assert.Equal(t, 75, ScaleValue(50, 1.5))
104+ // 2x scale
105+ assert.Equal(t, 100, ScaleValue(50, 2.0))
106+ // 1.2x scale (common UI scale)
107+ assert.Equal(t, 60, ScaleValue(50, 1.2))
108+}
109+
110+func TestMeasureWidgetHeight_NilWidget(t *testing.T) {
111+ height := MeasureWidgetHeight(nil, 100)
112+ assert.Equal(t, 0, height)
113+}
114+
115+func TestDefaultRowHeights_Values(t *testing.T) {
116+ // Ensure defaults are sensible
117+ assert.Positive(t, DefaultRowHeights.Standard)
118+ assert.Positive(t, DefaultRowHeights.Compact)
119+ assert.Positive(t, DefaultRowHeights.Divider)
120+ // Standard should be larger than compact
121+ assert.Greater(t, DefaultRowHeights.Standard, DefaultRowHeights.Compact)
122+}
123+
124+func TestOmniboxSizeDefaults_Values(t *testing.T) {
125+ assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0)
126+ assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0)
127+ assert.Positive(t, OmniboxSizeDefaults.MaxWidth)
128+ assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0)
129+ assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0)
130+}
131+
132+func TestSessionManagerSizeDefaults_Values(t *testing.T) {
133+ assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)
134+ assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)
135+ assert.Positive(t, SessionManagerSizeDefaults.MaxWidth)
136+ assert.Greater(t, SessionManagerSizeDefaults.TopMarginPct, 0.0)
137+ assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)
138+}
139+
140+func TestListDisplayDefaults_Values(t *testing.T) {
141+ assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)
142+ assert.Positive(t, OmniboxListDefaults.MaxResults)
143+ assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows)
144+ assert.Positive(t, SessionManagerListDefaults.MaxResults)
145+}
M internal/ui/component/omnibox.go +15 -50
@@ -20,13 +20,7 @@ import (
2020 )
2121
2222 const (
23- omniboxWidthPct = 0.8 // 80% of parent window width
24- omniboxMaxWidth = 800 // Maximum width in pixels
25- omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26- omniboxHeightPct = 0.6 // 60% of parent window height
27- omniboxMaxResults = 10
28- omniboxTopMarginPct = 0.2 // 20% from top
29- debounceDelayMs = 150
23+ debounceDelayMs = 150
3024 )
3125
3226 // ViewMode distinguishes history search from favorites display.
@@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
193187 return
194188 }
195189
196- // Use GetAllocatedWidth for actual rendered size (height not needed here)
197- parentWidth := o.parentOverlay.GetAllocatedWidth()
198- if parentWidth <= 0 {
199- parentWidth = omniboxMaxWidth
200- }
201-
202- width := int(float64(parentWidth) * omniboxWidthPct)
203- if width > omniboxMaxWidth {
204- width = omniboxMaxWidth
205- }
190+ // Calculate width using shared helper
191+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
206192
207193 // Cap at max results
208- if rowCount > omniboxMaxResults {
209- rowCount = omniboxMaxResults
194+ if rowCount > OmniboxListDefaults.MaxVisibleRows {
195+ rowCount = OmniboxListDefaults.MaxVisibleRows
210196 }
211197
212198 // Schedule measurement after GTK has laid out widgets
@@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
233219 // Use measured values from GTK4 Measure API
234220 rowHeight = o.measuredHeights.singleRow
235221 } else {
236- // Fallback to hardcoded estimate (before first measurement)
237- const fallbackRowHeight = 60
238- rowHeight = int(fallbackRowHeight * o.uiScale)
222+ // Fallback to scaled estimate (before first measurement)
223+ rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
239224 }
240225
241226 // Calculate content height for the scrolled window
@@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
246231 }
247232
248233 // Cap at max results
249- maxContentHeight := omniboxMaxResults * rowHeight
234+ maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
250235 if contentHeight > maxContentHeight {
251236 contentHeight = maxContentHeight
252237 }
253238
254- // Set size constraints on ScrolledWindow to control dynamic sizing
255- if o.scrolledWin != nil {
256- // Reset min first to avoid assertion failure when shrinking
257- // GTK requires min <= max, so we reset min before setting new values
258- o.scrolledWin.SetMinContentHeight(-1)
259- o.scrolledWin.SetMaxContentHeight(contentHeight)
260- o.scrolledWin.SetMinContentHeight(contentHeight)
261- }
239+ // Set size constraints on ScrolledWindow using shared helper
240+ SetScrolledWindowHeight(o.scrolledWin, contentHeight)
262241
263242 // Force layout recalculation
264243 o.outerBox.QueueResize()
@@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() {
602581
603582 input := usecase.SearchInput{
604583 Query: query,
605- Limit: omniboxMaxResults,
584+ Limit: OmniboxListDefaults.MaxResults,
606585 }
607586 output, err := o.historyUC.Search(ctx, input)
608587 if err != nil {
@@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() {
641620
642621 case "most_visited", "recent", "":
643622 // TODO: Implement GetMostVisited in use case if needed
644- results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
623+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
645624 if err != nil {
646625 log.Error().Err(err).Msg("failed to load recent history")
647626 return
@@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() {
778757 if width <= 0 {
779758 return false // Overlay not allocated yet, skip
780759 }
781- forWidth := int(float64(width) * omniboxWidthPct)
760+ forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
782761 if o.measureComponentHeights(forWidth) {
783762 // Re-trigger resize with accurate measurements
784763 o.mu.RLock()
@@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
12341213 o.scrolledWin.SetMaxContentHeight(0)
12351214 }
12361215
1237- // Get parent dimensions with fallback if not yet allocated
1238- parentWidth := o.parentOverlay.GetAllocatedWidth()
1239- parentHeight := o.parentOverlay.GetAllocatedHeight()
1240- if parentWidth <= 0 {
1241- parentWidth = omniboxMaxWidth
1242- }
1243- if parentHeight <= 0 {
1244- parentHeight = omniboxFallbackHeight
1245- }
1246-
1247- // Set width and vertical positioning (20% from top)
1248- width := int(float64(parentWidth) * omniboxWidthPct)
1249- if width > omniboxMaxWidth {
1250- width = omniboxMaxWidth
1251- }
1252- marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1216+ // Calculate dimensions using shared helper
1217+ width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
12531218
12541219 o.mainBox.SetSizeRequest(width, -1)
12551220 o.outerBox.SetMarginTop(marginTop)
@@ -20,13 +20,7 @@ import (
20 )20 )
21 21
22 const (22 const (
23- omniboxWidthPct = 0.8 // 80% of parent window width23+ debounceDelayMs = 150
24- omniboxMaxWidth = 800 // Maximum width in pixels
25- omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26- omniboxHeightPct = 0.6 // 60% of parent window height
27- omniboxMaxResults = 10
28- omniboxTopMarginPct = 0.2 // 20% from top
29- debounceDelayMs = 150
30 )24 )
31 25
32 // ViewMode distinguishes history search from favorites display.26 // ViewMode distinguishes history search from favorites display.
@@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
193 return187 return
194 }188 }
195 189
196- // Use GetAllocatedWidth for actual rendered size (height not needed here)190+ // Calculate width using shared helper
197- parentWidth := o.parentOverlay.GetAllocatedWidth()191+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
198- if parentWidth <= 0 {
199- parentWidth = omniboxMaxWidth
200- }
201-
202- width := int(float64(parentWidth) * omniboxWidthPct)
203- if width > omniboxMaxWidth {
204- width = omniboxMaxWidth
205- }
206 192
207 // Cap at max results193 // Cap at max results
208- if rowCount > omniboxMaxResults {194+ if rowCount > OmniboxListDefaults.MaxVisibleRows {
209- rowCount = omniboxMaxResults195+ rowCount = OmniboxListDefaults.MaxVisibleRows
210 }196 }
211 197
212 // Schedule measurement after GTK has laid out widgets198 // Schedule measurement after GTK has laid out widgets
@@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
233 // Use measured values from GTK4 Measure API219 // Use measured values from GTK4 Measure API
234 rowHeight = o.measuredHeights.singleRow220 rowHeight = o.measuredHeights.singleRow
235 } else {221 } else {
236- // Fallback to hardcoded estimate (before first measurement)222+ // Fallback to scaled estimate (before first measurement)
237- const fallbackRowHeight = 60223+ rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
238- rowHeight = int(fallbackRowHeight * o.uiScale)
239 }224 }
240 225
241 // Calculate content height for the scrolled window226 // Calculate content height for the scrolled window
@@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
246 }231 }
247 232
248 // Cap at max results233 // Cap at max results
249- maxContentHeight := omniboxMaxResults * rowHeight234+ maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
250 if contentHeight > maxContentHeight {235 if contentHeight > maxContentHeight {
251 contentHeight = maxContentHeight236 contentHeight = maxContentHeight
252 }237 }
253 238
254- // Set size constraints on ScrolledWindow to control dynamic sizing239+ // Set size constraints on ScrolledWindow using shared helper
255- if o.scrolledWin != nil {240+ SetScrolledWindowHeight(o.scrolledWin, contentHeight)
256- // Reset min first to avoid assertion failure when shrinking
257- // GTK requires min <= max, so we reset min before setting new values
258- o.scrolledWin.SetMinContentHeight(-1)
259- o.scrolledWin.SetMaxContentHeight(contentHeight)
260- o.scrolledWin.SetMinContentHeight(contentHeight)
261- }
262 241
263 // Force layout recalculation242 // Force layout recalculation
264 o.outerBox.QueueResize()243 o.outerBox.QueueResize()
@@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() {
602 581
603 input := usecase.SearchInput{582 input := usecase.SearchInput{
604 Query: query,583 Query: query,
605- Limit: omniboxMaxResults,584+ Limit: OmniboxListDefaults.MaxResults,
606 }585 }
607 output, err := o.historyUC.Search(ctx, input)586 output, err := o.historyUC.Search(ctx, input)
608 if err != nil {587 if err != nil {
@@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() {
641 620
642 case "most_visited", "recent", "":621 case "most_visited", "recent", "":
643 // TODO: Implement GetMostVisited in use case if needed622 // TODO: Implement GetMostVisited in use case if needed
644- results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)623+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
645 if err != nil {624 if err != nil {
646 log.Error().Err(err).Msg("failed to load recent history")625 log.Error().Err(err).Msg("failed to load recent history")
647 return626 return
@@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() {
778 if width <= 0 {757 if width <= 0 {
779 return false // Overlay not allocated yet, skip758 return false // Overlay not allocated yet, skip
780 }759 }
781- forWidth := int(float64(width) * omniboxWidthPct)760+ forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
782 if o.measureComponentHeights(forWidth) {761 if o.measureComponentHeights(forWidth) {
783 // Re-trigger resize with accurate measurements762 // Re-trigger resize with accurate measurements
784 o.mu.RLock()763 o.mu.RLock()
@@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1234 o.scrolledWin.SetMaxContentHeight(0)1213 o.scrolledWin.SetMaxContentHeight(0)
1235 }1214 }
1236 1215
1237- // Get parent dimensions with fallback if not yet allocated1216+ // Calculate dimensions using shared helper
1238- parentWidth := o.parentOverlay.GetAllocatedWidth()1217+ width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
1239- parentHeight := o.parentOverlay.GetAllocatedHeight()
1240- if parentWidth <= 0 {
1241- parentWidth = omniboxMaxWidth
1242- }
1243- if parentHeight <= 0 {
1244- parentHeight = omniboxFallbackHeight
1245- }
1246-
1247- // Set width and vertical positioning (20% from top)
1248- width := int(float64(parentWidth) * omniboxWidthPct)
1249- if width > omniboxMaxWidth {
1250- width = omniboxMaxWidth
1251- }
1252- marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1253 1218
1254 o.mainBox.SetSizeRequest(width, -1)1219 o.mainBox.SetSizeRequest(width, -1)
1255 o.outerBox.SetMarginTop(marginTop)1220 o.outerBox.SetMarginTop(marginTop)
M internal/ui/component/session_manager.go +80 -45
@@ -17,17 +17,9 @@ import (
1717 )
1818
1919 const (
20- sessionManagerWidthPct = 0.6 // 60% of parent window width
21- sessionManagerMaxWidth = 600 // Maximum width in pixels
22- sessionManagerTopMargin = 0.15
23- sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable
24- sessionDefaultListLimit = 50
25- sessionIDMaxDisplay = 20
26- sessionRowBoxSpacing = 8
27- sessionRowHeight = 50 // Approximate height per session row
28- sessionTreeRowHeight = 28 // Approximate height per tree row
29- sessionDividerRowHeight = 30 // Height for divider rows
30- sessionMaxTitleLen = 45 // Max chars for pane title display
20+ sessionIDMaxDisplay = 20
21+ sessionRowBoxSpacing = 8
22+ sessionMaxTitleLen = 45 // Max chars for pane title display
3123 )
3224
3325 // SessionManager is a modal overlay for managing sessions.
@@ -68,6 +60,17 @@ type SessionManager struct {
6860 // GTK callback retention
6961 retainedCallbacks []interface{}
7062
63+ // Scaling
64+ uiScale float64
65+
66+ // Cached measurements (populated after first layout)
67+ measuredHeights struct {
68+ sessionRow int // Session row natural height
69+ treeRow int // Tree row natural height
70+ dividerRow int // Divider row natural height
71+ valid bool // Whether cache is valid
72+ }
73+
7174 ctx context.Context
7275 }
7376
@@ -77,6 +80,7 @@ type SessionManagerConfig struct {
7780 DeleteSessionUC *usecase.DeleteSessionUseCase
7881 Spawner port.SessionSpawner
7982 CurrentSession entity.SessionID
83+ UIScale float64
8084 OnClose func()
8185 OnOpen func(sessionID entity.SessionID)
8286 OnToast func(ctx context.Context, message string, level ToastLevel)
@@ -86,6 +90,11 @@ type SessionManagerConfig struct {
8690 func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
8791 log := logging.FromContext(ctx)
8892
93+ uiScale := cfg.UIScale
94+ if uiScale <= 0 {
95+ uiScale = 1.0
96+ }
97+
8998 sm := &SessionManager{
9099 ctx: ctx,
91100 listSessionsUC: cfg.ListSessionsUC,
@@ -97,6 +106,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa
97106 onToast: cfg.OnToast,
98107 selectedIndex: -1,
99108 expandedIndex: -1,
109+ uiScale: uiScale,
100110 }
101111
102112 if err := sm.createWidgets(); err != nil {
@@ -210,27 +220,7 @@ func (sm *SessionManager) resizeAndCenter() {
210220 return
211221 }
212222
213- var parentWidth, parentHeight int
214-
215- if sm.parentOverlay != nil {
216- parentWidth = sm.parentOverlay.GetAllocatedWidth()
217- parentHeight = sm.parentOverlay.GetAllocatedHeight()
218- }
219-
220- // Use defaults if parent overlay not set or not yet allocated
221- if parentWidth <= 0 {
222- parentWidth = sessionManagerMaxWidth
223- }
224- if parentHeight <= 0 {
225- parentHeight = 600
226- }
227-
228- width := int(float64(parentWidth) * sessionManagerWidthPct)
229- if width > sessionManagerMaxWidth {
230- width = sessionManagerMaxWidth
231- }
232-
233- marginTop := int(float64(parentHeight) * sessionManagerTopMargin)
223+ width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
234224
235225 sm.mainBox.SetSizeRequest(width, -1)
236226 sm.outerBox.SetMarginTop(marginTop)
@@ -419,7 +409,7 @@ func (sm *SessionManager) loadSessions() {
419409 return
420410 }
421411
422- output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, sessionDefaultListLimit)
412+ output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, SessionManagerListDefaults.MaxResults)
423413 if err != nil {
424414 log.Error().Err(err).Msg("failed to list sessions")
425415 return
@@ -488,6 +478,11 @@ func (sm *SessionManager) populateList() {
488478 sm.mu.Unlock()
489479
490480 sm.selectBySessionIndex(selectedIdx)
481+
482+ // Attempt to measure actual widget heights for accurate sizing
483+ width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
484+ sm.measureWidgetHeights(width)
485+
491486 sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
492487 }
493488
@@ -548,14 +543,23 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
548543 return
549544 }
550545
546+ // Get row heights - use measured values if available, otherwise scale defaults
547+ var sessionH, treeH, dividerH int
548+ if sm.measuredHeights.valid {
549+ sessionH = sm.measuredHeights.sessionRow
550+ treeH = sm.measuredHeights.treeRow
551+ dividerH = sm.measuredHeights.dividerRow
552+ } else {
553+ sessionH = ScaleValue(DefaultRowHeights.Standard, sm.uiScale)
554+ treeH = ScaleValue(DefaultRowHeights.Compact, sm.uiScale)
555+ dividerH = ScaleValue(DefaultRowHeights.Divider, sm.uiScale)
556+ }
557+
551558 // Calculate actual content height
552- contentHeight := sessionRowCount*sessionRowHeight +
553- dividerCount*sessionDividerRowHeight +
554- treeRowCount*sessionTreeRowHeight
559+ contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH
555560
556561 // Calculate max visible height - limit to prevent oversized modal
557- // Allow up to sessionMaxVisibleRows session rows plus one divider
558- maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
562+ maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH
559563
560564 // Cap at max visible height - rest will be scrollable
561565 if contentHeight > maxVisibleHeight {
@@ -563,22 +567,53 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
563567 }
564568
565569 // Ensure minimum height for at least one row
566- if contentHeight < sessionRowHeight {
567- contentHeight = sessionRowHeight
570+ if contentHeight < sessionH {
571+ contentHeight = sessionH
568572 }
569573
570- // Schedule resize on main thread
571- // Use the same pattern as omnibox: reset min to -1 first to avoid GTK assertion
574+ // Schedule resize on main thread using shared helper
572575 cb := glib.SourceFunc(func(_ uintptr) bool {
573- sm.scrolledWindow.SetMinContentHeight(-1)
574- sm.scrolledWindow.SetMaxContentHeight(contentHeight)
575- sm.scrolledWindow.SetMinContentHeight(contentHeight)
576+ SetScrolledWindowHeight(sm.scrolledWindow, contentHeight)
576577 sm.outerBox.QueueResize()
577578 return false
578579 })
579580 glib.IdleAdd(&cb, 0)
580581 }
581582
583+// measureWidgetHeights attempts to measure actual row heights from the list.
584+// Must be called after list is populated. Updates measuredHeights cache if successful.
585+func (sm *SessionManager) measureWidgetHeights(forWidth int) {
586+ if sm.listBox == nil || sm.measuredHeights.valid {
587+ return
588+ }
589+
590+ // We need to find different row types and measure them.
591+ // Session rows have "session-row" class, tree rows have "session-tree-row" class,
592+ // divider rows have "session-divider" class.
593+
594+ // For now, measure using first row and estimate others based on ratio.
595+ // This is simpler and works well enough since we have fallback scaling.
596+ row := sm.listBox.GetRowAtIndex(0)
597+ if row == nil {
598+ return
599+ }
600+
601+ sessionH := MeasureWidgetHeight(&row.Widget, forWidth)
602+ if sessionH <= 0 {
603+ return
604+ }
605+
606+ // Estimate tree and divider heights based on ratio to standard row
607+ // These ratios match the original constants: tree=28/50, divider=30/50
608+ treeH := sessionH * DefaultRowHeights.Compact / DefaultRowHeights.Standard
609+ dividerH := sessionH * DefaultRowHeights.Divider / DefaultRowHeights.Standard
610+
611+ sm.measuredHeights.sessionRow = sessionH
612+ sm.measuredHeights.treeRow = treeH
613+ sm.measuredHeights.dividerRow = dividerH
614+ sm.measuredHeights.valid = true
615+}
616+
582617 // createTreeRows creates tree rows showing the tab/pane structure of a session.
583618 func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
584619 if state == nil {
@@ -17,17 +17,9 @@ import (
17 )17 )
18 18
19 const (19 const (
20- sessionManagerWidthPct = 0.6 // 60% of parent window width20+ sessionIDMaxDisplay = 20
21- sessionManagerMaxWidth = 600 // Maximum width in pixels21+ sessionRowBoxSpacing = 8
22- sessionManagerTopMargin = 0.1522+ sessionMaxTitleLen = 45 // Max chars for pane title display
23- sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable
24- sessionDefaultListLimit = 50
25- sessionIDMaxDisplay = 20
26- sessionRowBoxSpacing = 8
27- sessionRowHeight = 50 // Approximate height per session row
28- sessionTreeRowHeight = 28 // Approximate height per tree row
29- sessionDividerRowHeight = 30 // Height for divider rows
30- sessionMaxTitleLen = 45 // Max chars for pane title display
31 )23 )
32 24
33 // SessionManager is a modal overlay for managing sessions.25 // SessionManager is a modal overlay for managing sessions.
@@ -68,6 +60,17 @@ type SessionManager struct {
68 // GTK callback retention60 // GTK callback retention
69 retainedCallbacks []interface{}61 retainedCallbacks []interface{}
70 62
63+ // Scaling
64+ uiScale float64
65+
66+ // Cached measurements (populated after first layout)
67+ measuredHeights struct {
68+ sessionRow int // Session row natural height
69+ treeRow int // Tree row natural height
70+ dividerRow int // Divider row natural height
71+ valid bool // Whether cache is valid
72+ }
73+
71 ctx context.Context74 ctx context.Context
72 }75 }
73 76
@@ -77,6 +80,7 @@ type SessionManagerConfig struct {
77 DeleteSessionUC *usecase.DeleteSessionUseCase80 DeleteSessionUC *usecase.DeleteSessionUseCase
78 Spawner port.SessionSpawner81 Spawner port.SessionSpawner
79 CurrentSession entity.SessionID82 CurrentSession entity.SessionID
83+ UIScale float64
80 OnClose func()84 OnClose func()
81 OnOpen func(sessionID entity.SessionID)85 OnOpen func(sessionID entity.SessionID)
82 OnToast func(ctx context.Context, message string, level ToastLevel)86 OnToast func(ctx context.Context, message string, level ToastLevel)
@@ -86,6 +90,11 @@ type SessionManagerConfig struct {
86 func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {90 func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
87 log := logging.FromContext(ctx)91 log := logging.FromContext(ctx)
88 92
93+ uiScale := cfg.UIScale
94+ if uiScale <= 0 {
95+ uiScale = 1.0
96+ }
97+
89 sm := &SessionManager{98 sm := &SessionManager{
90 ctx: ctx,99 ctx: ctx,
91 listSessionsUC: cfg.ListSessionsUC,100 listSessionsUC: cfg.ListSessionsUC,
@@ -97,6 +106,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa
97 onToast: cfg.OnToast,106 onToast: cfg.OnToast,
98 selectedIndex: -1,107 selectedIndex: -1,
99 expandedIndex: -1,108 expandedIndex: -1,
109+ uiScale: uiScale,
100 }110 }
101 111
102 if err := sm.createWidgets(); err != nil {112 if err := sm.createWidgets(); err != nil {
@@ -210,27 +220,7 @@ func (sm *SessionManager) resizeAndCenter() {
210 return220 return
211 }221 }
212 222
213- var parentWidth, parentHeight int223+ width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
214-
215- if sm.parentOverlay != nil {
216- parentWidth = sm.parentOverlay.GetAllocatedWidth()
217- parentHeight = sm.parentOverlay.GetAllocatedHeight()
218- }
219-
220- // Use defaults if parent overlay not set or not yet allocated
221- if parentWidth <= 0 {
222- parentWidth = sessionManagerMaxWidth
223- }
224- if parentHeight <= 0 {
225- parentHeight = 600
226- }
227-
228- width := int(float64(parentWidth) * sessionManagerWidthPct)
229- if width > sessionManagerMaxWidth {
230- width = sessionManagerMaxWidth
231- }
232-
233- marginTop := int(float64(parentHeight) * sessionManagerTopMargin)
234 224
235 sm.mainBox.SetSizeRequest(width, -1)225 sm.mainBox.SetSizeRequest(width, -1)
236 sm.outerBox.SetMarginTop(marginTop)226 sm.outerBox.SetMarginTop(marginTop)
@@ -419,7 +409,7 @@ func (sm *SessionManager) loadSessions() {
419 return409 return
420 }410 }
421 411
422- output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, sessionDefaultListLimit)412+ output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, SessionManagerListDefaults.MaxResults)
423 if err != nil {413 if err != nil {
424 log.Error().Err(err).Msg("failed to list sessions")414 log.Error().Err(err).Msg("failed to list sessions")
425 return415 return
@@ -488,6 +478,11 @@ func (sm *SessionManager) populateList() {
488 sm.mu.Unlock()478 sm.mu.Unlock()
489 479
490 sm.selectBySessionIndex(selectedIdx)480 sm.selectBySessionIndex(selectedIdx)
481+
482+ // Attempt to measure actual widget heights for accurate sizing
483+ width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
484+ sm.measureWidgetHeights(width)
485+
491 sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)486 sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
492 }487 }
493 488
@@ -548,14 +543,23 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
548 return543 return
549 }544 }
550 545
546+ // Get row heights - use measured values if available, otherwise scale defaults
547+ var sessionH, treeH, dividerH int
548+ if sm.measuredHeights.valid {
549+ sessionH = sm.measuredHeights.sessionRow
550+ treeH = sm.measuredHeights.treeRow
551+ dividerH = sm.measuredHeights.dividerRow
552+ } else {
553+ sessionH = ScaleValue(DefaultRowHeights.Standard, sm.uiScale)
554+ treeH = ScaleValue(DefaultRowHeights.Compact, sm.uiScale)
555+ dividerH = ScaleValue(DefaultRowHeights.Divider, sm.uiScale)
556+ }
557+
551 // Calculate actual content height558 // Calculate actual content height
552- contentHeight := sessionRowCount*sessionRowHeight +559+ contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH
553- dividerCount*sessionDividerRowHeight +
554- treeRowCount*sessionTreeRowHeight
555 560
556 // Calculate max visible height - limit to prevent oversized modal561 // Calculate max visible height - limit to prevent oversized modal
557- // Allow up to sessionMaxVisibleRows session rows plus one divider562+ maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH
558- maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
559 563
560 // Cap at max visible height - rest will be scrollable564 // Cap at max visible height - rest will be scrollable
561 if contentHeight > maxVisibleHeight {565 if contentHeight > maxVisibleHeight {
@@ -563,22 +567,53 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
563 }567 }
564 568
565 // Ensure minimum height for at least one row569 // Ensure minimum height for at least one row
566- if contentHeight < sessionRowHeight {570+ if contentHeight < sessionH {
567- contentHeight = sessionRowHeight571+ contentHeight = sessionH
568 }572 }
569 573
570- // Schedule resize on main thread574+ // Schedule resize on main thread using shared helper
571- // Use the same pattern as omnibox: reset min to -1 first to avoid GTK assertion
572 cb := glib.SourceFunc(func(_ uintptr) bool {575 cb := glib.SourceFunc(func(_ uintptr) bool {
573- sm.scrolledWindow.SetMinContentHeight(-1)576+ SetScrolledWindowHeight(sm.scrolledWindow, contentHeight)
574- sm.scrolledWindow.SetMaxContentHeight(contentHeight)
575- sm.scrolledWindow.SetMinContentHeight(contentHeight)
576 sm.outerBox.QueueResize()577 sm.outerBox.QueueResize()
577 return false578 return false
578 })579 })
579 glib.IdleAdd(&cb, 0)580 glib.IdleAdd(&cb, 0)
580 }581 }
581 582
583+// measureWidgetHeights attempts to measure actual row heights from the list.
584+// Must be called after list is populated. Updates measuredHeights cache if successful.
585+func (sm *SessionManager) measureWidgetHeights(forWidth int) {
586+ if sm.listBox == nil || sm.measuredHeights.valid {
587+ return
588+ }
589+
590+ // We need to find different row types and measure them.
591+ // Session rows have "session-row" class, tree rows have "session-tree-row" class,
592+ // divider rows have "session-divider" class.
593+
594+ // For now, measure using first row and estimate others based on ratio.
595+ // This is simpler and works well enough since we have fallback scaling.
596+ row := sm.listBox.GetRowAtIndex(0)
597+ if row == nil {
598+ return
599+ }
600+
601+ sessionH := MeasureWidgetHeight(&row.Widget, forWidth)
602+ if sessionH <= 0 {
603+ return
604+ }
605+
606+ // Estimate tree and divider heights based on ratio to standard row
607+ // These ratios match the original constants: tree=28/50, divider=30/50
608+ treeH := sessionH * DefaultRowHeights.Compact / DefaultRowHeights.Standard
609+ dividerH := sessionH * DefaultRowHeights.Divider / DefaultRowHeights.Standard
610+
611+ sm.measuredHeights.sessionRow = sessionH
612+ sm.measuredHeights.treeRow = treeH
613+ sm.measuredHeights.dividerRow = dividerH
614+ sm.measuredHeights.valid = true
615+}
616+
582 // createTreeRows creates tree rows showing the tab/pane structure of a session.617 // createTreeRows creates tree rows showing the tab/pane structure of a session.
583 func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {618 func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
584 if state == nil {619 if state == nil {

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