feat(theme): implement UI scaling via CSS with em-based units
- commit
- 453b589b6ed885681dac22998e4a1f9c39bef239
- author
- brice · · 2025-12-15 19:29 UTC
- committer
- brice · · 2025-12-15 22:00 UTC
- parent
- ac0f90d
- signature
- unsigned
2 files · +105 -43
M internal/ui/theme/css.go +94 -41
| @@ -2,11 +2,22 @@ | |||
| 2 | 2 | package theme | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | - | // GenerateCSS creates GTK4 CSS using the provided palette. | |
| 9 | + | // GenerateCSS creates GTK4 CSS using the provided palette with default scale. | |
| 9 | 10 | func GenerateCSS(p Palette) string { | |
| 11 | + | return GenerateCSSWithScale(p, 1.0) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor. | |
| 15 | + | // Scale affects font sizes and widget padding/margins proportionally. | |
| 16 | + | func GenerateCSSWithScale(p Palette, scale float64) string { | |
| 17 | + | if scale <= 0 { | |
| 18 | + | scale = 1.0 | |
| 19 | + | } | |
| 20 | + | ||
| 10 | 21 | var sb strings.Builder | |
| 11 | 22 | ||
| 12 | 23 | // CSS custom properties (variables) - GTK4 uses :root selector | |
| @@ -15,6 +26,12 @@ func GenerateCSS(p Palette) string { | |||
| 15 | 26 | sb.WriteString(p.ToCSSVars()) | |
| 16 | 27 | sb.WriteString("}\n\n") | |
| 17 | 28 | ||
| 29 | + | // Global UI scaling via font-size on all widgets | |
| 30 | + | if scale != 1.0 { | |
| 31 | + | sb.WriteString(generateScalingCSS(scale)) | |
| 32 | + | sb.WriteString("\n") | |
| 33 | + | } | |
| 34 | + | ||
| 18 | 35 | // Tab bar styling | |
| 19 | 36 | sb.WriteString(generateTabBarCSS(p)) | |
| 20 | 37 | sb.WriteString("\n") | |
| @@ -33,14 +50,29 @@ func GenerateCSS(p Palette) string { | |||
| 33 | 50 | return sb.String() | |
| 34 | 51 | } | |
| 35 | 52 | ||
| 53 | + | // generateScalingCSS creates CSS rules that scale the UI. | |
| 54 | + | // GTK4 CSS doesn't support transform:scale() well, so we scale font-size | |
| 55 | + | // and use relative units (em) for padding/margins in other rules. | |
| 56 | + | func generateScalingCSS(scale float64) string { | |
| 57 | + | // Use 16px base so em conversions are correct: | |
| 58 | + | // 1px = 0.0625em (1/16), 4px = 0.25em, etc. | |
| 59 | + | basePx := int(16 * scale) | |
| 60 | + | return fmt.Sprintf(`/* UI Scaling (%.0f%%) */ | |
| 61 | + | window { | |
| 62 | + | font-size: %dpx; | |
| 63 | + | } | |
| 64 | + | `, scale*100, basePx) | |
| 65 | + | } | |
| 66 | + | ||
| 36 | 67 | // generateTabBarCSS creates tab bar styles. | |
| 68 | + | // Uses em units for scalable UI. | |
| 37 | 69 | func generateTabBarCSS(p Palette) string { | |
| 38 | 70 | return `/* Tab bar styling */ | |
| 39 | 71 | .tab-bar { | |
| 40 | 72 | background-color: var(--surface); | |
| 41 | - | border-top: 2px solid var(--border); | |
| 73 | + | border-top: 0.125em solid var(--border); | |
| 42 | 74 | padding: 0; | |
| 43 | - | min-height: 32px; | |
| 75 | + | min-height: 2em; | |
| 44 | 76 | } | |
| 45 | 77 | ||
| 46 | 78 | /* Tab button styling */ | |
| @@ -48,9 +80,9 @@ button.tab-button { | |||
| 48 | 80 | background-color: var(--surface-variant); | |
| 49 | 81 | background-image: none; | |
| 50 | 82 | border: none; | |
| 51 | - | border-right: 1px solid var(--border); | |
| 83 | + | border-right: 0.0625em solid var(--border); | |
| 52 | 84 | border-radius: 0; | |
| 53 | - | padding: 4px 8px; | |
| 85 | + | padding: 0.25em 0.5em; | |
| 54 | 86 | transition: background-color 200ms ease-in-out; | |
| 55 | 87 | } | |
| 56 | 88 | ||
| @@ -65,7 +97,7 @@ button.tab-button.tab-button-active { | |||
| 65 | 97 | ||
| 66 | 98 | /* Tab title text */ | |
| 67 | 99 | .tab-title { | |
| 68 | - | font-size: 12px; | |
| 100 | + | font-size: 0.75em; | |
| 69 | 101 | color: var(--text); | |
| 70 | 102 | font-weight: 500; | |
| 71 | 103 | } | |
| @@ -73,14 +105,15 @@ button.tab-button.tab-button-active { | |||
| 73 | 105 | } | |
| 74 | 106 | ||
| 75 | 107 | // generateOmniboxCSS creates omnibox styles. | |
| 108 | + | // Uses em units for scalable UI. | |
| 76 | 109 | func generateOmniboxCSS(p Palette) string { | |
| 77 | 110 | return `/* ===== Omnibox Styling ===== */ | |
| 78 | 111 | ||
| 79 | 112 | /* Omnibox window - floating popup */ | |
| 80 | 113 | window.omnibox-window { | |
| 81 | 114 | background-color: var(--surface-variant); | |
| 82 | - | border: 1px solid var(--border); | |
| 83 | - | border-radius: 3px; | |
| 115 | + | border: 0.0625em solid var(--border); | |
| 116 | + | border-radius: 0.1875em; | |
| 84 | 117 | } | |
| 85 | 118 | ||
| 86 | 119 | /* Main container */ | |
| @@ -92,18 +125,18 @@ window.omnibox-window { | |||
| 92 | 125 | /* Header with History/Favorites toggle */ | |
| 93 | 126 | .omnibox-header { | |
| 94 | 127 | background-color: shade(var(--surface-variant), 1.1); | |
| 95 | - | border-bottom: 1px solid var(--border); | |
| 96 | - | padding: 6px 12px; | |
| 128 | + | border-bottom: 0.0625em solid var(--border); | |
| 129 | + | padding: 0.375em 0.75em; | |
| 97 | 130 | } | |
| 98 | 131 | ||
| 99 | 132 | .omnibox-header-btn { | |
| 100 | 133 | background-color: transparent; | |
| 101 | 134 | background-image: none; | |
| 102 | 135 | border: none; | |
| 103 | - | border-radius: 2px; | |
| 104 | - | padding: 4px 12px; | |
| 105 | - | margin-right: 8px; | |
| 106 | - | font-size: 13px; | |
| 136 | + | border-radius: 0.125em; | |
| 137 | + | padding: 0.25em 0.75em; | |
| 138 | + | margin-right: 0.5em; | |
| 139 | + | font-size: 0.8125em; | |
| 107 | 140 | font-weight: 500; | |
| 108 | 141 | color: var(--muted); | |
| 109 | 142 | transition: all 100ms ease-in-out; | |
| @@ -124,11 +157,11 @@ window.omnibox-window { | |||
| 124 | 157 | .omnibox-entry { | |
| 125 | 158 | background-color: var(--bg); | |
| 126 | 159 | color: var(--text); | |
| 127 | - | border: 1px solid var(--border); | |
| 128 | - | border-radius: 2px; | |
| 129 | - | padding: 10px 12px; | |
| 130 | - | margin: 8px 12px; | |
| 131 | - | font-size: 16px; | |
| 160 | + | border: 0.0625em solid var(--border); | |
| 161 | + | border-radius: 0.125em; | |
| 162 | + | padding: 0.625em 0.75em; | |
| 163 | + | margin: 0.5em 0.75em; | |
| 164 | + | font-size: 1em; | |
| 132 | 165 | caret-color: var(--accent); | |
| 133 | 166 | } | |
| 134 | 167 | ||
| @@ -140,7 +173,7 @@ window.omnibox-window { | |||
| 140 | 173 | /* Scrolled window for suggestions */ | |
| 141 | 174 | .omnibox-scrolled { | |
| 142 | 175 | background-color: shade(var(--surface-variant), 0.95); | |
| 143 | - | border-top: 1px solid var(--border); | |
| 176 | + | border-top: 0.0625em solid var(--border); | |
| 144 | 177 | } | |
| 145 | 178 | ||
| 146 | 179 | /* List box */ | |
| @@ -150,11 +183,11 @@ window.omnibox-window { | |||
| 150 | 183 | ||
| 151 | 184 | /* Suggestion rows */ | |
| 152 | 185 | .omnibox-row { | |
| 153 | - | padding: 8px 12px; | |
| 186 | + | padding: 0.5em 0.75em; | |
| 154 | 187 | margin: 0; | |
| 155 | 188 | border-radius: 0; | |
| 156 | - | border-left: 3px solid transparent; | |
| 157 | - | border-bottom: 1px solid alpha(var(--border), 0.5); | |
| 189 | + | border-left: 0.1875em solid transparent; | |
| 190 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.5); | |
| 158 | 191 | transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out; | |
| 159 | 192 | } | |
| 160 | 193 | ||
| @@ -172,9 +205,16 @@ window.omnibox-window { | |||
| 172 | 205 | border-left-color: var(--accent); | |
| 173 | 206 | } | |
| 174 | 207 | ||
| 208 | + | /* Favicon in omnibox rows */ | |
| 209 | + | .omnibox-favicon { | |
| 210 | + | min-width: 1em; | |
| 211 | + | min-height: 1em; | |
| 212 | + | margin-right: 0.5em; | |
| 213 | + | } | |
| 214 | + | ||
| 175 | 215 | /* Suggestion title/URL */ | |
| 176 | 216 | .omnibox-suggestion-title { | |
| 177 | - | font-size: 14px; | |
| 217 | + | font-size: 0.875em; | |
| 178 | 218 | color: var(--text, #ffffff); | |
| 179 | 219 | font-weight: 400; | |
| 180 | 220 | } | |
| @@ -193,12 +233,12 @@ window.omnibox-window { | |||
| 193 | 233 | .omnibox-shortcut-badge { | |
| 194 | 234 | background-color: alpha(var(--muted, #909090), 0.3); | |
| 195 | 235 | color: var(--muted, #909090); | |
| 196 | - | border-radius: 4px; | |
| 197 | - | padding: 2px 6px; | |
| 198 | - | font-size: 10px; | |
| 236 | + | border-radius: 0.25em; | |
| 237 | + | padding: 0.125em 0.375em; | |
| 238 | + | font-size: 0.625em; | |
| 199 | 239 | font-weight: 500; | |
| 200 | 240 | font-family: monospace; | |
| 201 | - | margin-left: 8px; | |
| 241 | + | margin-left: 0.5em; | |
| 202 | 242 | } | |
| 203 | 243 | ||
| 204 | 244 | .omnibox-row:hover .omnibox-shortcut-badge { | |
| @@ -210,49 +250,62 @@ window.omnibox-window { | |||
| 210 | 250 | background-color: alpha(var(--accent, #4ade80), 0.3); | |
| 211 | 251 | color: var(--accent, #4ade80); | |
| 212 | 252 | } | |
| 253 | + | ||
| 254 | + | /* Zoom indicator in omnibox header */ | |
| 255 | + | .omnibox-zoom-indicator { | |
| 256 | + | background-color: alpha(var(--accent, #4ade80), 0.2); | |
| 257 | + | color: var(--accent, #4ade80); | |
| 258 | + | border-radius: 0.25em; | |
| 259 | + | padding: 0.125em 0.5em; | |
| 260 | + | font-size: 0.6875em; | |
| 261 | + | font-weight: 600; | |
| 262 | + | margin-left: auto; | |
| 263 | + | } | |
| 213 | 264 | ` | |
| 214 | 265 | } | |
| 215 | 266 | ||
| 216 | 267 | // generatePaneCSS creates pane border styles. | |
| 268 | + | // Uses em units for scalable UI. | |
| 217 | 269 | func generatePaneCSS(p Palette) string { | |
| 218 | 270 | return `/* ===== Pane Styling ===== */ | |
| 219 | 271 | ||
| 220 | 272 | /* Pane border - default transparent */ | |
| 221 | 273 | .pane-border { | |
| 222 | - | border: 1px solid transparent; | |
| 274 | + | border: 0.0625em solid transparent; | |
| 223 | 275 | } | |
| 224 | 276 | ||
| 225 | 277 | /* Active pane - accent border */ | |
| 226 | 278 | .pane-active { | |
| 227 | - | border: 1px solid var(--accent); | |
| 279 | + | border: 0.0625em solid var(--accent); | |
| 228 | 280 | } | |
| 229 | 281 | ||
| 230 | 282 | /* Pane mode active - thick blue inset border (for overlay) */ | |
| 231 | 283 | .pane-mode-active { | |
| 232 | 284 | background-color: transparent; | |
| 233 | - | box-shadow: inset 0 0 0 4px #4A90E2; | |
| 285 | + | box-shadow: inset 0 0 0 0.25em #4A90E2; | |
| 234 | 286 | border-radius: 0; | |
| 235 | 287 | } | |
| 236 | 288 | ||
| 237 | 289 | /* Tab mode active - thick orange inset border (for overlay) */ | |
| 238 | 290 | .tab-mode-active { | |
| 239 | 291 | background-color: transparent; | |
| 240 | - | box-shadow: inset 0 0 0 4px #FFA500; | |
| 292 | + | box-shadow: inset 0 0 0 0.25em #FFA500; | |
| 241 | 293 | border-radius: 0; | |
| 242 | 294 | } | |
| 243 | 295 | ` | |
| 244 | 296 | } | |
| 245 | 297 | ||
| 246 | 298 | // generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles. | |
| 299 | + | // Uses em units for scalable UI. | |
| 247 | 300 | func generateStackedPaneCSS(p Palette) string { | |
| 248 | 301 | return `/* ===== Stacked Pane Styling ===== */ | |
| 249 | 302 | ||
| 250 | 303 | /* Title bar for inactive panes in a stack */ | |
| 251 | 304 | .stacked-pane-titlebar { | |
| 252 | 305 | background-color: var(--surface-variant); | |
| 253 | - | border-bottom: 1px solid var(--border); | |
| 254 | - | padding: 4px 8px; | |
| 255 | - | min-height: 24px; | |
| 306 | + | border-bottom: 0.0625em solid var(--border); | |
| 307 | + | padding: 0.25em 0.5em; | |
| 308 | + | min-height: 1.5em; | |
| 256 | 309 | } | |
| 257 | 310 | ||
| 258 | 311 | /* Title bar button wrapper - clickable area */ | |
| @@ -260,7 +313,7 @@ button.stacked-pane-title-button { | |||
| 260 | 313 | background-color: var(--surface-variant); | |
| 261 | 314 | background-image: none; | |
| 262 | 315 | border: none; | |
| 263 | - | border-bottom: 1px solid var(--border); | |
| 316 | + | border-bottom: 0.0625em solid var(--border); | |
| 264 | 317 | border-radius: 0; | |
| 265 | 318 | padding: 0; | |
| 266 | 319 | margin: 0; | |
| @@ -279,20 +332,20 @@ button.stacked-pane-title-button:focus { | |||
| 279 | 332 | /* Title bar content box */ | |
| 280 | 333 | .stacked-pane-titlebar.active { | |
| 281 | 334 | background-color: alpha(var(--accent), 0.1); | |
| 282 | - | border-left: 3px solid var(--accent); | |
| 335 | + | border-left: 0.1875em solid var(--accent); | |
| 283 | 336 | } | |
| 284 | 337 | ||
| 285 | 338 | /* Favicon image in title bar */ | |
| 286 | 339 | .stacked-pane-titlebar image { | |
| 287 | - | min-width: 16px; | |
| 288 | - | min-height: 16px; | |
| 289 | - | margin-right: 6px; | |
| 340 | + | min-width: 1em; | |
| 341 | + | min-height: 1em; | |
| 342 | + | margin-right: 0.375em; | |
| 290 | 343 | } | |
| 291 | 344 | ||
| 292 | 345 | /* Title text in title bar */ | |
| 293 | 346 | .stacked-pane-titlebar label { | |
| 294 | 347 | color: var(--text); | |
| 295 | - | font-size: 12px; | |
| 348 | + | font-size: 0.75em; | |
| 296 | 349 | font-weight: 400; | |
| 297 | 350 | } | |
| 298 | 351 | ||
| @@ -2,11 +2,22 @@ | |||||
| 2 | package theme | 2 | package theme | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "fmt" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | - | // GenerateCSS creates GTK4 CSS using the provided palette. | 9 | + | // GenerateCSS creates GTK4 CSS using the provided palette with default scale. |
| 9 | func GenerateCSS(p Palette) string { | 10 | func GenerateCSS(p Palette) string { | ||
| 11 | + | return GenerateCSSWithScale(p, 1.0) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor. | |||
| 15 | + | // Scale affects font sizes and widget padding/margins proportionally. | |||
| 16 | + | func GenerateCSSWithScale(p Palette, scale float64) string { | |||
| 17 | + | if scale <= 0 { | |||
| 18 | + | scale = 1.0 | |||
| 19 | + | } | |||
| 20 | + | ||||
| 10 | var sb strings.Builder | 21 | var sb strings.Builder | ||
| 11 | 22 | ||||
| 12 | // CSS custom properties (variables) - GTK4 uses :root selector | 23 | // CSS custom properties (variables) - GTK4 uses :root selector | ||
| @@ -15,6 +26,12 @@ func GenerateCSS(p Palette) string { | |||||
| 15 | sb.WriteString(p.ToCSSVars()) | 26 | sb.WriteString(p.ToCSSVars()) | ||
| 16 | sb.WriteString("}\n\n") | 27 | sb.WriteString("}\n\n") | ||
| 17 | 28 | ||||
| 29 | + | // Global UI scaling via font-size on all widgets | |||
| 30 | + | if scale != 1.0 { | |||
| 31 | + | sb.WriteString(generateScalingCSS(scale)) | |||
| 32 | + | sb.WriteString("\n") | |||
| 33 | + | } | |||
| 34 | + | ||||
| 18 | // Tab bar styling | 35 | // Tab bar styling | ||
| 19 | sb.WriteString(generateTabBarCSS(p)) | 36 | sb.WriteString(generateTabBarCSS(p)) | ||
| 20 | sb.WriteString("\n") | 37 | sb.WriteString("\n") | ||
| @@ -33,14 +50,29 @@ func GenerateCSS(p Palette) string { | |||||
| 33 | return sb.String() | 50 | return sb.String() | ||
| 34 | } | 51 | } | ||
| 35 | 52 | ||||
| 53 | + | // generateScalingCSS creates CSS rules that scale the UI. | |||
| 54 | + | // GTK4 CSS doesn't support transform:scale() well, so we scale font-size | |||
| 55 | + | // and use relative units (em) for padding/margins in other rules. | |||
| 56 | + | func generateScalingCSS(scale float64) string { | |||
| 57 | + | // Use 16px base so em conversions are correct: | |||
| 58 | + | // 1px = 0.0625em (1/16), 4px = 0.25em, etc. | |||
| 59 | + | basePx := int(16 * scale) | |||
| 60 | + | return fmt.Sprintf(`/* UI Scaling (%.0f%%) */ | |||
| 61 | + | window { | |||
| 62 | + | font-size: %dpx; | |||
| 63 | + | } | |||
| 64 | + | `, scale*100, basePx) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 36 | // generateTabBarCSS creates tab bar styles. | 67 | // generateTabBarCSS creates tab bar styles. | ||
| 68 | + | // Uses em units for scalable UI. | |||
| 37 | func generateTabBarCSS(p Palette) string { | 69 | func generateTabBarCSS(p Palette) string { | ||
| 38 | return `/* Tab bar styling */ | 70 | return `/* Tab bar styling */ | ||
| 39 | .tab-bar { | 71 | .tab-bar { | ||
| 40 | background-color: var(--surface); | 72 | background-color: var(--surface); | ||
| 41 | - | border-top: 2px solid var(--border); | 73 | + | border-top: 0.125em solid var(--border); |
| 42 | padding: 0; | 74 | padding: 0; | ||
| 43 | - | min-height: 32px; | 75 | + | min-height: 2em; |
| 44 | } | 76 | } | ||
| 45 | 77 | ||||
| 46 | /* Tab button styling */ | 78 | /* Tab button styling */ | ||
| @@ -48,9 +80,9 @@ button.tab-button { | |||||
| 48 | background-color: var(--surface-variant); | 80 | background-color: var(--surface-variant); | ||
| 49 | background-image: none; | 81 | background-image: none; | ||
| 50 | border: none; | 82 | border: none; | ||
| 51 | - | border-right: 1px solid var(--border); | 83 | + | border-right: 0.0625em solid var(--border); |
| 52 | border-radius: 0; | 84 | border-radius: 0; | ||
| 53 | - | padding: 4px 8px; | 85 | + | padding: 0.25em 0.5em; |
| 54 | transition: background-color 200ms ease-in-out; | 86 | transition: background-color 200ms ease-in-out; | ||
| 55 | } | 87 | } | ||
| 56 | 88 | ||||
| @@ -65,7 +97,7 @@ button.tab-button.tab-button-active { | |||||
| 65 | 97 | ||||
| 66 | /* Tab title text */ | 98 | /* Tab title text */ | ||
| 67 | .tab-title { | 99 | .tab-title { | ||
| 68 | - | font-size: 12px; | 100 | + | font-size: 0.75em; |
| 69 | color: var(--text); | 101 | color: var(--text); | ||
| 70 | font-weight: 500; | 102 | font-weight: 500; | ||
| 71 | } | 103 | } | ||
| @@ -73,14 +105,15 @@ button.tab-button.tab-button-active { | |||||
| 73 | } | 105 | } | ||
| 74 | 106 | ||||
| 75 | // generateOmniboxCSS creates omnibox styles. | 107 | // generateOmniboxCSS creates omnibox styles. | ||
| 108 | + | // Uses em units for scalable UI. | |||
| 76 | func generateOmniboxCSS(p Palette) string { | 109 | func generateOmniboxCSS(p Palette) string { | ||
| 77 | return `/* ===== Omnibox Styling ===== */ | 110 | return `/* ===== Omnibox Styling ===== */ | ||
| 78 | 111 | ||||
| 79 | /* Omnibox window - floating popup */ | 112 | /* Omnibox window - floating popup */ | ||
| 80 | window.omnibox-window { | 113 | window.omnibox-window { | ||
| 81 | background-color: var(--surface-variant); | 114 | background-color: var(--surface-variant); | ||
| 82 | - | border: 1px solid var(--border); | 115 | + | border: 0.0625em solid var(--border); |
| 83 | - | border-radius: 3px; | 116 | + | border-radius: 0.1875em; |
| 84 | } | 117 | } | ||
| 85 | 118 | ||||
| 86 | /* Main container */ | 119 | /* Main container */ | ||
| @@ -92,18 +125,18 @@ window.omnibox-window { | |||||
| 92 | /* Header with History/Favorites toggle */ | 125 | /* Header with History/Favorites toggle */ | ||
| 93 | .omnibox-header { | 126 | .omnibox-header { | ||
| 94 | background-color: shade(var(--surface-variant), 1.1); | 127 | background-color: shade(var(--surface-variant), 1.1); | ||
| 95 | - | border-bottom: 1px solid var(--border); | 128 | + | border-bottom: 0.0625em solid var(--border); |
| 96 | - | padding: 6px 12px; | 129 | + | padding: 0.375em 0.75em; |
| 97 | } | 130 | } | ||
| 98 | 131 | ||||
| 99 | .omnibox-header-btn { | 132 | .omnibox-header-btn { | ||
| 100 | background-color: transparent; | 133 | background-color: transparent; | ||
| 101 | background-image: none; | 134 | background-image: none; | ||
| 102 | border: none; | 135 | border: none; | ||
| 103 | - | border-radius: 2px; | 136 | + | border-radius: 0.125em; |
| 104 | - | padding: 4px 12px; | 137 | + | padding: 0.25em 0.75em; |
| 105 | - | margin-right: 8px; | 138 | + | margin-right: 0.5em; |
| 106 | - | font-size: 13px; | 139 | + | font-size: 0.8125em; |
| 107 | font-weight: 500; | 140 | font-weight: 500; | ||
| 108 | color: var(--muted); | 141 | color: var(--muted); | ||
| 109 | transition: all 100ms ease-in-out; | 142 | transition: all 100ms ease-in-out; | ||
| @@ -124,11 +157,11 @@ window.omnibox-window { | |||||
| 124 | .omnibox-entry { | 157 | .omnibox-entry { | ||
| 125 | background-color: var(--bg); | 158 | background-color: var(--bg); | ||
| 126 | color: var(--text); | 159 | color: var(--text); | ||
| 127 | - | border: 1px solid var(--border); | 160 | + | border: 0.0625em solid var(--border); |
| 128 | - | border-radius: 2px; | 161 | + | border-radius: 0.125em; |
| 129 | - | padding: 10px 12px; | 162 | + | padding: 0.625em 0.75em; |
| 130 | - | margin: 8px 12px; | 163 | + | margin: 0.5em 0.75em; |
| 131 | - | font-size: 16px; | 164 | + | font-size: 1em; |
| 132 | caret-color: var(--accent); | 165 | caret-color: var(--accent); | ||
| 133 | } | 166 | } | ||
| 134 | 167 | ||||
| @@ -140,7 +173,7 @@ window.omnibox-window { | |||||
| 140 | /* Scrolled window for suggestions */ | 173 | /* Scrolled window for suggestions */ | ||
| 141 | .omnibox-scrolled { | 174 | .omnibox-scrolled { | ||
| 142 | background-color: shade(var(--surface-variant), 0.95); | 175 | background-color: shade(var(--surface-variant), 0.95); | ||
| 143 | - | border-top: 1px solid var(--border); | 176 | + | border-top: 0.0625em solid var(--border); |
| 144 | } | 177 | } | ||
| 145 | 178 | ||||
| 146 | /* List box */ | 179 | /* List box */ | ||
| @@ -150,11 +183,11 @@ window.omnibox-window { | |||||
| 150 | 183 | ||||
| 151 | /* Suggestion rows */ | 184 | /* Suggestion rows */ | ||
| 152 | .omnibox-row { | 185 | .omnibox-row { | ||
| 153 | - | padding: 8px 12px; | 186 | + | padding: 0.5em 0.75em; |
| 154 | margin: 0; | 187 | margin: 0; | ||
| 155 | border-radius: 0; | 188 | border-radius: 0; | ||
| 156 | - | border-left: 3px solid transparent; | 189 | + | border-left: 0.1875em solid transparent; |
| 157 | - | border-bottom: 1px solid alpha(var(--border), 0.5); | 190 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.5); |
| 158 | transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out; | 191 | transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out; | ||
| 159 | } | 192 | } | ||
| 160 | 193 | ||||
| @@ -172,9 +205,16 @@ window.omnibox-window { | |||||
| 172 | border-left-color: var(--accent); | 205 | border-left-color: var(--accent); | ||
| 173 | } | 206 | } | ||
| 174 | 207 | ||||
| 208 | + | /* Favicon in omnibox rows */ | |||
| 209 | + | .omnibox-favicon { | |||
| 210 | + | min-width: 1em; | |||
| 211 | + | min-height: 1em; | |||
| 212 | + | margin-right: 0.5em; | |||
| 213 | + | } | |||
| 214 | + | ||||
| 175 | /* Suggestion title/URL */ | 215 | /* Suggestion title/URL */ | ||
| 176 | .omnibox-suggestion-title { | 216 | .omnibox-suggestion-title { | ||
| 177 | - | font-size: 14px; | 217 | + | font-size: 0.875em; |
| 178 | color: var(--text, #ffffff); | 218 | color: var(--text, #ffffff); | ||
| 179 | font-weight: 400; | 219 | font-weight: 400; | ||
| 180 | } | 220 | } | ||
| @@ -193,12 +233,12 @@ window.omnibox-window { | |||||
| 193 | .omnibox-shortcut-badge { | 233 | .omnibox-shortcut-badge { | ||
| 194 | background-color: alpha(var(--muted, #909090), 0.3); | 234 | background-color: alpha(var(--muted, #909090), 0.3); | ||
| 195 | color: var(--muted, #909090); | 235 | color: var(--muted, #909090); | ||
| 196 | - | border-radius: 4px; | 236 | + | border-radius: 0.25em; |
| 197 | - | padding: 2px 6px; | 237 | + | padding: 0.125em 0.375em; |
| 198 | - | font-size: 10px; | 238 | + | font-size: 0.625em; |
| 199 | font-weight: 500; | 239 | font-weight: 500; | ||
| 200 | font-family: monospace; | 240 | font-family: monospace; | ||
| 201 | - | margin-left: 8px; | 241 | + | margin-left: 0.5em; |
| 202 | } | 242 | } | ||
| 203 | 243 | ||||
| 204 | .omnibox-row:hover .omnibox-shortcut-badge { | 244 | .omnibox-row:hover .omnibox-shortcut-badge { | ||
| @@ -210,49 +250,62 @@ window.omnibox-window { | |||||
| 210 | background-color: alpha(var(--accent, #4ade80), 0.3); | 250 | background-color: alpha(var(--accent, #4ade80), 0.3); | ||
| 211 | color: var(--accent, #4ade80); | 251 | color: var(--accent, #4ade80); | ||
| 212 | } | 252 | } | ||
| 253 | + | ||||
| 254 | + | /* Zoom indicator in omnibox header */ | |||
| 255 | + | .omnibox-zoom-indicator { | |||
| 256 | + | background-color: alpha(var(--accent, #4ade80), 0.2); | |||
| 257 | + | color: var(--accent, #4ade80); | |||
| 258 | + | border-radius: 0.25em; | |||
| 259 | + | padding: 0.125em 0.5em; | |||
| 260 | + | font-size: 0.6875em; | |||
| 261 | + | font-weight: 600; | |||
| 262 | + | margin-left: auto; | |||
| 263 | + | } | |||
| 213 | ` | 264 | ` | ||
| 214 | } | 265 | } | ||
| 215 | 266 | ||||
| 216 | // generatePaneCSS creates pane border styles. | 267 | // generatePaneCSS creates pane border styles. | ||
| 268 | + | // Uses em units for scalable UI. | |||
| 217 | func generatePaneCSS(p Palette) string { | 269 | func generatePaneCSS(p Palette) string { | ||
| 218 | return `/* ===== Pane Styling ===== */ | 270 | return `/* ===== Pane Styling ===== */ | ||
| 219 | 271 | ||||
| 220 | /* Pane border - default transparent */ | 272 | /* Pane border - default transparent */ | ||
| 221 | .pane-border { | 273 | .pane-border { | ||
| 222 | - | border: 1px solid transparent; | 274 | + | border: 0.0625em solid transparent; |
| 223 | } | 275 | } | ||
| 224 | 276 | ||||
| 225 | /* Active pane - accent border */ | 277 | /* Active pane - accent border */ | ||
| 226 | .pane-active { | 278 | .pane-active { | ||
| 227 | - | border: 1px solid var(--accent); | 279 | + | border: 0.0625em solid var(--accent); |
| 228 | } | 280 | } | ||
| 229 | 281 | ||||
| 230 | /* Pane mode active - thick blue inset border (for overlay) */ | 282 | /* Pane mode active - thick blue inset border (for overlay) */ | ||
| 231 | .pane-mode-active { | 283 | .pane-mode-active { | ||
| 232 | background-color: transparent; | 284 | background-color: transparent; | ||
| 233 | - | box-shadow: inset 0 0 0 4px #4A90E2; | 285 | + | box-shadow: inset 0 0 0 0.25em #4A90E2; |
| 234 | border-radius: 0; | 286 | border-radius: 0; | ||
| 235 | } | 287 | } | ||
| 236 | 288 | ||||
| 237 | /* Tab mode active - thick orange inset border (for overlay) */ | 289 | /* Tab mode active - thick orange inset border (for overlay) */ | ||
| 238 | .tab-mode-active { | 290 | .tab-mode-active { | ||
| 239 | background-color: transparent; | 291 | background-color: transparent; | ||
| 240 | - | box-shadow: inset 0 0 0 4px #FFA500; | 292 | + | box-shadow: inset 0 0 0 0.25em #FFA500; |
| 241 | border-radius: 0; | 293 | border-radius: 0; | ||
| 242 | } | 294 | } | ||
| 243 | ` | 295 | ` | ||
| 244 | } | 296 | } | ||
| 245 | 297 | ||||
| 246 | // generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles. | 298 | // generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles. | ||
| 299 | + | // Uses em units for scalable UI. | |||
| 247 | func generateStackedPaneCSS(p Palette) string { | 300 | func generateStackedPaneCSS(p Palette) string { | ||
| 248 | return `/* ===== Stacked Pane Styling ===== */ | 301 | return `/* ===== Stacked Pane Styling ===== */ | ||
| 249 | 302 | ||||
| 250 | /* Title bar for inactive panes in a stack */ | 303 | /* Title bar for inactive panes in a stack */ | ||
| 251 | .stacked-pane-titlebar { | 304 | .stacked-pane-titlebar { | ||
| 252 | background-color: var(--surface-variant); | 305 | background-color: var(--surface-variant); | ||
| 253 | - | border-bottom: 1px solid var(--border); | 306 | + | border-bottom: 0.0625em solid var(--border); |
| 254 | - | padding: 4px 8px; | 307 | + | padding: 0.25em 0.5em; |
| 255 | - | min-height: 24px; | 308 | + | min-height: 1.5em; |
| 256 | } | 309 | } | ||
| 257 | 310 | ||||
| 258 | /* Title bar button wrapper - clickable area */ | 311 | /* Title bar button wrapper - clickable area */ | ||
| @@ -260,7 +313,7 @@ button.stacked-pane-title-button { | |||||
| 260 | background-color: var(--surface-variant); | 313 | background-color: var(--surface-variant); | ||
| 261 | background-image: none; | 314 | background-image: none; | ||
| 262 | border: none; | 315 | border: none; | ||
| 263 | - | border-bottom: 1px solid var(--border); | 316 | + | border-bottom: 0.0625em solid var(--border); |
| 264 | border-radius: 0; | 317 | border-radius: 0; | ||
| 265 | padding: 0; | 318 | padding: 0; | ||
| 266 | margin: 0; | 319 | margin: 0; | ||
| @@ -279,20 +332,20 @@ button.stacked-pane-title-button:focus { | |||||
| 279 | /* Title bar content box */ | 332 | /* Title bar content box */ | ||
| 280 | .stacked-pane-titlebar.active { | 333 | .stacked-pane-titlebar.active { | ||
| 281 | background-color: alpha(var(--accent), 0.1); | 334 | background-color: alpha(var(--accent), 0.1); | ||
| 282 | - | border-left: 3px solid var(--accent); | 335 | + | border-left: 0.1875em solid var(--accent); |
| 283 | } | 336 | } | ||
| 284 | 337 | ||||
| 285 | /* Favicon image in title bar */ | 338 | /* Favicon image in title bar */ | ||
| 286 | .stacked-pane-titlebar image { | 339 | .stacked-pane-titlebar image { | ||
| 287 | - | min-width: 16px; | 340 | + | min-width: 1em; |
| 288 | - | min-height: 16px; | 341 | + | min-height: 1em; |
| 289 | - | margin-right: 6px; | 342 | + | margin-right: 0.375em; |
| 290 | } | 343 | } | ||
| 291 | 344 | ||||
| 292 | /* Title text in title bar */ | 345 | /* Title text in title bar */ | ||
| 293 | .stacked-pane-titlebar label { | 346 | .stacked-pane-titlebar label { | ||
| 294 | color: var(--text); | 347 | color: var(--text); | ||
| 295 | - | font-size: 12px; | 348 | + | font-size: 0.75em; |
| 296 | font-weight: 400; | 349 | font-weight: 400; | ||
| 297 | } | 350 | } | ||
| 298 | 351 | ||||
M internal/ui/theme/manager.go +11 -2
| @@ -15,6 +15,7 @@ type Manager struct { | |||
| 15 | 15 | prefersDark bool // Resolved dark mode preference | |
| 16 | 16 | lightPalette Palette // Light theme colors | |
| 17 | 17 | darkPalette Palette // Dark theme colors | |
| 18 | + | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 18 | 19 | cssProvider *gtk.CssProvider | |
| 19 | 20 | } | |
| 20 | 21 | ||
| @@ -41,9 +42,16 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 41 | 42 | darkPalette = DefaultDarkPalette() | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 45 | + | // Get UI scale factor (default to 1.0 if not set) | |
| 46 | + | uiScale := 1.0 | |
| 47 | + | if cfg != nil && cfg.DefaultUIScale > 0 { | |
| 48 | + | uiScale = cfg.DefaultUIScale | |
| 49 | + | } | |
| 50 | + | ||
| 44 | 51 | log.Debug(). | |
| 45 | 52 | Str("scheme", scheme). | |
| 46 | 53 | Bool("prefers_dark", prefersDark). | |
| 54 | + | Float64("ui_scale", uiScale). | |
| 47 | 55 | Msg("theme manager initialized") | |
| 48 | 56 | ||
| 49 | 57 | return &Manager{ | |
| @@ -51,6 +59,7 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 51 | 59 | prefersDark: prefersDark, | |
| 52 | 60 | lightPalette: lightPalette, | |
| 53 | 61 | darkPalette: darkPalette, | |
| 62 | + | uiScale: uiScale, | |
| 54 | 63 | } | |
| 55 | 64 | } | |
| 56 | 65 | ||
| @@ -86,9 +95,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 86 | 95 | return | |
| 87 | 96 | } | |
| 88 | 97 | ||
| 89 | - | // Generate CSS with current palette | |
| 98 | + | // Generate CSS with current palette and UI scale | |
| 90 | 99 | palette := m.GetCurrentPalette() | |
| 91 | - | css := GenerateCSS(palette) | |
| 100 | + | css := GenerateCSSWithScale(palette, m.uiScale) | |
| 92 | 101 | ||
| 93 | 102 | // Create CSS provider if needed | |
| 94 | 103 | if m.cssProvider == nil { | |
| @@ -15,6 +15,7 @@ type Manager struct { | |||||
| 15 | prefersDark bool // Resolved dark mode preference | 15 | prefersDark bool // Resolved dark mode preference | ||
| 16 | lightPalette Palette // Light theme colors | 16 | lightPalette Palette // Light theme colors | ||
| 17 | darkPalette Palette // Dark theme colors | 17 | darkPalette Palette // Dark theme colors | ||
| 18 | + | uiScale float64 // UI scaling factor (1.0 = 100%) | |||
| 18 | cssProvider *gtk.CssProvider | 19 | cssProvider *gtk.CssProvider | ||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| @@ -41,9 +42,16 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||||
| 41 | darkPalette = DefaultDarkPalette() | 42 | darkPalette = DefaultDarkPalette() | ||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 45 | + | // Get UI scale factor (default to 1.0 if not set) | |||
| 46 | + | uiScale := 1.0 | |||
| 47 | + | if cfg != nil && cfg.DefaultUIScale > 0 { | |||
| 48 | + | uiScale = cfg.DefaultUIScale | |||
| 49 | + | } | |||
| 50 | + | ||||
| 44 | log.Debug(). | 51 | log.Debug(). | ||
| 45 | Str("scheme", scheme). | 52 | Str("scheme", scheme). | ||
| 46 | Bool("prefers_dark", prefersDark). | 53 | Bool("prefers_dark", prefersDark). | ||
| 54 | + | Float64("ui_scale", uiScale). | |||
| 47 | Msg("theme manager initialized") | 55 | Msg("theme manager initialized") | ||
| 48 | 56 | ||||
| 49 | return &Manager{ | 57 | return &Manager{ | ||
| @@ -51,6 +59,7 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||||
| 51 | prefersDark: prefersDark, | 59 | prefersDark: prefersDark, | ||
| 52 | lightPalette: lightPalette, | 60 | lightPalette: lightPalette, | ||
| 53 | darkPalette: darkPalette, | 61 | darkPalette: darkPalette, | ||
| 62 | + | uiScale: uiScale, | |||
| 54 | } | 63 | } | ||
| 55 | } | 64 | } | ||
| 56 | 65 | ||||
| @@ -86,9 +95,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||||
| 86 | return | 95 | return | ||
| 87 | } | 96 | } | ||
| 88 | 97 | ||||
| 89 | - | // Generate CSS with current palette | 98 | + | // Generate CSS with current palette and UI scale |
| 90 | palette := m.GetCurrentPalette() | 99 | palette := m.GetCurrentPalette() | ||
| 91 | - | css := GenerateCSS(palette) | 100 | + | css := GenerateCSSWithScale(palette, m.uiScale) |
| 92 | 101 | ||||
| 93 | // Create CSS provider if needed | 102 | // Create CSS provider if needed | ||
| 94 | if m.cssProvider == nil { | 103 | if m.cssProvider == nil { | ||