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 Unverified
2B82207D370127A0The 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) { | |||
| 410 | 410 | ListSessionsUC: listSessionsUC, | |
| 411 | 411 | DeleteSessionUC: deleteSessionUC, | |
| 412 | 412 | CurrentSession: a.deps.CurrentSessionID, | |
| 413 | + | UIScale: a.deps.Config.DefaultUIScale, | |
| 413 | 414 | OnClose: func() { | |
| 414 | 415 | log.Debug().Msg("session manager closed") | |
| 415 | 416 | }, | |
| @@ -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 ( | |||
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | 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 | |
| 30 | 24 | ) | |
| 31 | 25 | ||
| 32 | 26 | // ViewMode distinguishes history search from favorites display. | |
| @@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 193 | 187 | return | |
| 194 | 188 | } | |
| 195 | 189 | ||
| 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) | |
| 206 | 192 | ||
| 207 | 193 | // Cap at max results | |
| 208 | - | if rowCount > omniboxMaxResults { | |
| 209 | - | rowCount = omniboxMaxResults | |
| 194 | + | if rowCount > OmniboxListDefaults.MaxVisibleRows { | |
| 195 | + | rowCount = OmniboxListDefaults.MaxVisibleRows | |
| 210 | 196 | } | |
| 211 | 197 | ||
| 212 | 198 | // Schedule measurement after GTK has laid out widgets | |
| @@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 233 | 219 | // Use measured values from GTK4 Measure API | |
| 234 | 220 | rowHeight = o.measuredHeights.singleRow | |
| 235 | 221 | } 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) | |
| 239 | 224 | } | |
| 240 | 225 | ||
| 241 | 226 | // Calculate content height for the scrolled window | |
| @@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 246 | 231 | } | |
| 247 | 232 | ||
| 248 | 233 | // Cap at max results | |
| 249 | - | maxContentHeight := omniboxMaxResults * rowHeight | |
| 234 | + | maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight | |
| 250 | 235 | if contentHeight > maxContentHeight { | |
| 251 | 236 | contentHeight = maxContentHeight | |
| 252 | 237 | } | |
| 253 | 238 | ||
| 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) | |
| 262 | 241 | ||
| 263 | 242 | // Force layout recalculation | |
| 264 | 243 | o.outerBox.QueueResize() | |
| @@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() { | |||
| 602 | 581 | ||
| 603 | 582 | input := usecase.SearchInput{ | |
| 604 | 583 | Query: query, | |
| 605 | - | Limit: omniboxMaxResults, | |
| 584 | + | Limit: OmniboxListDefaults.MaxResults, | |
| 606 | 585 | } | |
| 607 | 586 | output, err := o.historyUC.Search(ctx, input) | |
| 608 | 587 | if err != nil { | |
| @@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 641 | 620 | ||
| 642 | 621 | case "most_visited", "recent", "": | |
| 643 | 622 | // 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 | 624 | if err != nil { | |
| 646 | 625 | log.Error().Err(err).Msg("failed to load recent history") | |
| 647 | 626 | return | |
| @@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() { | |||
| 778 | 757 | if width <= 0 { | |
| 779 | 758 | return false // Overlay not allocated yet, skip | |
| 780 | 759 | } | |
| 781 | - | forWidth := int(float64(width) * omniboxWidthPct) | |
| 760 | + | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | |
| 782 | 761 | if o.measureComponentHeights(forWidth) { | |
| 783 | 762 | // Re-trigger resize with accurate measurements | |
| 784 | 763 | o.mu.RLock() | |
| @@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 1234 | 1213 | o.scrolledWin.SetMaxContentHeight(0) | |
| 1235 | 1214 | } | |
| 1236 | 1215 | ||
| 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) | |
| 1253 | 1218 | ||
| 1254 | 1219 | o.mainBox.SetSizeRequest(width, -1) | |
| 1255 | 1220 | 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 width | 23 | + | 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 | return | 187 | 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 results | 193 | // Cap at max results | ||
| 208 | - | if rowCount > omniboxMaxResults { | 194 | + | if rowCount > OmniboxListDefaults.MaxVisibleRows { |
| 209 | - | rowCount = omniboxMaxResults | 195 | + | rowCount = OmniboxListDefaults.MaxVisibleRows |
| 210 | } | 196 | } | ||
| 211 | 197 | ||||
| 212 | // Schedule measurement after GTK has laid out widgets | 198 | // 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 API | 219 | // Use measured values from GTK4 Measure API | ||
| 234 | rowHeight = o.measuredHeights.singleRow | 220 | 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 = 60 | 223 | + | rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale) |
| 238 | - | rowHeight = int(fallbackRowHeight * o.uiScale) | |||
| 239 | } | 224 | } | ||
| 240 | 225 | ||||
| 241 | // Calculate content height for the scrolled window | 226 | // 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 results | 233 | // Cap at max results | ||
| 249 | - | maxContentHeight := omniboxMaxResults * rowHeight | 234 | + | maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight |
| 250 | if contentHeight > maxContentHeight { | 235 | if contentHeight > maxContentHeight { | ||
| 251 | contentHeight = maxContentHeight | 236 | contentHeight = maxContentHeight | ||
| 252 | } | 237 | } | ||
| 253 | 238 | ||||
| 254 | - | // Set size constraints on ScrolledWindow to control dynamic sizing | 239 | + | // 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 recalculation | 242 | // 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 needed | 622 | // 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 | return | 626 | return | ||
| @@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() { | |||||
| 778 | if width <= 0 { | 757 | if width <= 0 { | ||
| 779 | return false // Overlay not allocated yet, skip | 758 | 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 measurements | 762 | // 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 allocated | 1216 | + | // 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 ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | 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 | |
| 31 | 23 | ) | |
| 32 | 24 | ||
| 33 | 25 | // SessionManager is a modal overlay for managing sessions. | |
| @@ -68,6 +60,17 @@ type SessionManager struct { | |||
| 68 | 60 | // GTK callback retention | |
| 69 | 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 | 74 | ctx context.Context | |
| 72 | 75 | } | |
| 73 | 76 | ||
| @@ -77,6 +80,7 @@ type SessionManagerConfig struct { | |||
| 77 | 80 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 78 | 81 | Spawner port.SessionSpawner | |
| 79 | 82 | CurrentSession entity.SessionID | |
| 83 | + | UIScale float64 | |
| 80 | 84 | OnClose func() | |
| 81 | 85 | OnOpen func(sessionID entity.SessionID) | |
| 82 | 86 | OnToast func(ctx context.Context, message string, level ToastLevel) | |
| @@ -86,6 +90,11 @@ type SessionManagerConfig struct { | |||
| 86 | 90 | func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager { | |
| 87 | 91 | log := logging.FromContext(ctx) | |
| 88 | 92 | ||
| 93 | + | uiScale := cfg.UIScale | |
| 94 | + | if uiScale <= 0 { | |
| 95 | + | uiScale = 1.0 | |
| 96 | + | } | |
| 97 | + | ||
| 89 | 98 | sm := &SessionManager{ | |
| 90 | 99 | ctx: ctx, | |
| 91 | 100 | listSessionsUC: cfg.ListSessionsUC, | |
| @@ -97,6 +106,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa | |||
| 97 | 106 | onToast: cfg.OnToast, | |
| 98 | 107 | selectedIndex: -1, | |
| 99 | 108 | expandedIndex: -1, | |
| 109 | + | uiScale: uiScale, | |
| 100 | 110 | } | |
| 101 | 111 | ||
| 102 | 112 | if err := sm.createWidgets(); err != nil { | |
| @@ -210,27 +220,7 @@ func (sm *SessionManager) resizeAndCenter() { | |||
| 210 | 220 | return | |
| 211 | 221 | } | |
| 212 | 222 | ||
| 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) | |
| 234 | 224 | ||
| 235 | 225 | sm.mainBox.SetSizeRequest(width, -1) | |
| 236 | 226 | sm.outerBox.SetMarginTop(marginTop) | |
| @@ -419,7 +409,7 @@ func (sm *SessionManager) loadSessions() { | |||
| 419 | 409 | 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 | 413 | if err != nil { | |
| 424 | 414 | log.Error().Err(err).Msg("failed to list sessions") | |
| 425 | 415 | return | |
| @@ -488,6 +478,11 @@ func (sm *SessionManager) populateList() { | |||
| 488 | 478 | sm.mu.Unlock() | |
| 489 | 479 | ||
| 490 | 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 | 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 | 543 | 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 | 558 | // Calculate actual content height | |
| 552 | - | contentHeight := sessionRowCount*sessionRowHeight + | |
| 553 | - | dividerCount*sessionDividerRowHeight + | |
| 554 | - | treeRowCount*sessionTreeRowHeight | |
| 559 | + | contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH | |
| 555 | 560 | ||
| 556 | 561 | // 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 | |
| 559 | 563 | ||
| 560 | 564 | // Cap at max visible height - rest will be scrollable | |
| 561 | 565 | if contentHeight > maxVisibleHeight { | |
| @@ -563,22 +567,53 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo | |||
| 563 | 567 | } | |
| 564 | 568 | ||
| 565 | 569 | // Ensure minimum height for at least one row | |
| 566 | - | if contentHeight < sessionRowHeight { | |
| 567 | - | contentHeight = sessionRowHeight | |
| 570 | + | if contentHeight < sessionH { | |
| 571 | + | contentHeight = sessionH | |
| 568 | 572 | } | |
| 569 | 573 | ||
| 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 | |
| 572 | 575 | 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) | |
| 576 | 577 | sm.outerBox.QueueResize() | |
| 577 | 578 | return false | |
| 578 | 579 | }) | |
| 579 | 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 | 617 | // createTreeRows creates tree rows showing the tab/pane structure of a session. | |
| 583 | 618 | func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow { | |
| 584 | 619 | if state == nil { | |
| @@ -17,17 +17,9 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | const ( | 19 | const ( | ||
| 20 | - | sessionManagerWidthPct = 0.6 // 60% of parent window width | 20 | + | sessionIDMaxDisplay = 20 |
| 21 | - | sessionManagerMaxWidth = 600 // Maximum width in pixels | 21 | + | sessionRowBoxSpacing = 8 |
| 22 | - | sessionManagerTopMargin = 0.15 | 22 | + | 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 retention | 60 | // 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.Context | 74 | ctx context.Context | ||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| @@ -77,6 +80,7 @@ type SessionManagerConfig struct { | |||||
| 77 | DeleteSessionUC *usecase.DeleteSessionUseCase | 80 | DeleteSessionUC *usecase.DeleteSessionUseCase | ||
| 78 | Spawner port.SessionSpawner | 81 | Spawner port.SessionSpawner | ||
| 79 | CurrentSession entity.SessionID | 82 | 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 | return | 220 | return | ||
| 211 | } | 221 | } | ||
| 212 | 222 | ||||
| 213 | - | var parentWidth, parentHeight int | 223 | + | 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 | return | 409 | 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 | return | 415 | 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 | return | 543 | 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 height | 558 | // 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 modal | 561 | // Calculate max visible height - limit to prevent oversized modal | ||
| 557 | - | // Allow up to sessionMaxVisibleRows session rows plus one divider | 562 | + | maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH |
| 558 | - | maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight | |||
| 559 | 563 | ||||
| 560 | // Cap at max visible height - rest will be scrollable | 564 | // 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 row | 569 | // Ensure minimum height for at least one row | ||
| 566 | - | if contentHeight < sessionRowHeight { | 570 | + | if contentHeight < sessionH { |
| 567 | - | contentHeight = sessionRowHeight | 571 | + | contentHeight = sessionH |
| 568 | } | 572 | } | ||
| 569 | 573 | ||||
| 570 | - | // Schedule resize on main thread | 574 | + | // 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 false | 578 | 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 { | ||