detached c8834e5

fix(theme): use native GTK text scaling

Move UI text scaling from root CSS font-size into GtkSettings so scaled text renders more cleanly. Keep omnibox suggestion titles at a slightly heavier weight to avoid thin glyph artifacts at higher UI scales.
commit
c8834e506e447de888efa5f0a54ee43bac5c9606
author
brice · · 2026-04-08 05:27 UTC
parent
3132aa3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +75 -25
M internal/ui/theme/css.go +6 -25
@@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string {
1212 }
1313
1414 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
15-// Scale affects font sizes and widget padding/margins proportionally.
15+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
1616 func GenerateCSSWithScale(p Palette, scale float64) string {
1717 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
1818 }
@@ -49,14 +49,14 @@ window, tooltip, popover {
4949 }
5050
5151 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52-// Scale affects font sizes and widget padding/margins proportionally.
52+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
5353 // Uses default mode colors.
5454 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
5555 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
5656 }
5757
5858 // GenerateCSSFull creates GTK4 CSS using all provided configuration.
59-// Scale affects font sizes and widget padding/margins proportionally.
59+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
6060 func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
6161 if scale <= 0 {
6262 scale = 1.0
@@ -82,12 +82,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
8282 sb.WriteString(generateFontCSS())
8383 sb.WriteString("\n")
8484
85- // Global UI scaling via font-size on all widgets
86- if scale != 1.0 {
87- sb.WriteString(generateScalingCSS(scale))
88- sb.WriteString("\n")
89- }
90-
9185 // Tab bar styling
9286 sb.WriteString(generateTabBarCSS(p))
9387 sb.WriteString("\n")
@@ -146,20 +140,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
146140 return sb.String()
147141 }
148142
149-// generateScalingCSS creates CSS rules that scale the UI.
150-// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
151-// and use relative units (em) for padding/margins in other rules.
152-func generateScalingCSS(scale float64) string {
153- // Use 16px base so em conversions are correct:
154- // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
155- basePx := int(16 * scale)
156- return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
157-window {
158- font-size: %dpx;
159-}
160-`, scale*100, basePx)
161-}
162-
163143 // generateTabBarCSS creates tab bar styles.
164144 // Uses em units for scalable UI.
165145 func generateTabBarCSS(p Palette) string {
@@ -316,7 +296,8 @@ entry.omnibox-entry > text:focus-visible {
316296 border-radius: 0;
317297 border-left: 0.1875em solid transparent;
318298 border-bottom: 0.0625em solid alpha(var(--border), 0.35);
319- background-color: transparent;
299+ background-color: var(--surface);
300+ background-image: none;
320301 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
321302 min-height: 2.75em;
322303 }
@@ -362,7 +343,7 @@ entry.omnibox-entry > text:focus-visible {
362343 .omnibox-suggestion-title {
363344 font-size: 0.875em;
364345 color: var(--text);
365- font-weight: 400;
346+ font-weight: 500;
366347 }
367348
368349 /* Also style labels inside omnibox rows directly */
@@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string {
12 }12 }
13 13
14 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.14 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
15-// Scale affects font sizes and widget padding/margins proportionally.15+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
16 func GenerateCSSWithScale(p Palette, scale float64) string {16 func GenerateCSSWithScale(p Palette, scale float64) string {
17 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())17 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
18 }18 }
@@ -49,14 +49,14 @@ window, tooltip, popover {
49 }49 }
50 50
51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52-// Scale affects font sizes and widget padding/margins proportionally.52+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
53 // Uses default mode colors.53 // Uses default mode colors.
54 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {54 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
55 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())55 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
56 }56 }
57 57
58 // GenerateCSSFull creates GTK4 CSS using all provided configuration.58 // GenerateCSSFull creates GTK4 CSS using all provided configuration.
59-// Scale affects font sizes and widget padding/margins proportionally.59+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
60 func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {60 func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
61 if scale <= 0 {61 if scale <= 0 {
62 scale = 1.062 scale = 1.0
@@ -82,12 +82,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
82 sb.WriteString(generateFontCSS())82 sb.WriteString(generateFontCSS())
83 sb.WriteString("\n")83 sb.WriteString("\n")
84 84
85- // Global UI scaling via font-size on all widgets
86- if scale != 1.0 {
87- sb.WriteString(generateScalingCSS(scale))
88- sb.WriteString("\n")
89- }
90-
91 // Tab bar styling85 // Tab bar styling
92 sb.WriteString(generateTabBarCSS(p))86 sb.WriteString(generateTabBarCSS(p))
93 sb.WriteString("\n")87 sb.WriteString("\n")
@@ -146,20 +140,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
146 return sb.String()140 return sb.String()
147 }141 }
148 142
149-// generateScalingCSS creates CSS rules that scale the UI.
150-// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
151-// and use relative units (em) for padding/margins in other rules.
152-func generateScalingCSS(scale float64) string {
153- // Use 16px base so em conversions are correct:
154- // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
155- basePx := int(16 * scale)
156- return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
157-window {
158- font-size: %dpx;
159-}
160-`, scale*100, basePx)
161-}
162-
163 // generateTabBarCSS creates tab bar styles.143 // generateTabBarCSS creates tab bar styles.
164 // Uses em units for scalable UI.144 // Uses em units for scalable UI.
165 func generateTabBarCSS(p Palette) string {145 func generateTabBarCSS(p Palette) string {
@@ -316,7 +296,8 @@ entry.omnibox-entry > text:focus-visible {
316 border-radius: 0;296 border-radius: 0;
317 border-left: 0.1875em solid transparent;297 border-left: 0.1875em solid transparent;
318 border-bottom: 0.0625em solid alpha(var(--border), 0.35);298 border-bottom: 0.0625em solid alpha(var(--border), 0.35);
319- background-color: transparent;299+ background-color: var(--surface);
300+ background-image: none;
320 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;301 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
321 min-height: 2.75em;302 min-height: 2.75em;
322 }303 }
@@ -362,7 +343,7 @@ entry.omnibox-entry > text:focus-visible {
362 .omnibox-suggestion-title {343 .omnibox-suggestion-title {
363 font-size: 0.875em;344 font-size: 0.875em;
364 color: var(--text);345 color: var(--text);
365- font-weight: 400;346+ font-weight: 500;
366 }347 }
367 348
368 /* Also style labels inside omnibox rows directly */349 /* Also style labels inside omnibox rows directly */
M internal/ui/theme/css_test.go +32 -0
@@ -4,6 +4,8 @@ import (
44 "regexp"
55 "strings"
66 "testing"
7+
8+ "github.com/stretchr/testify/assert"
79 )
810
911 func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
@@ -72,3 +74,33 @@ func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
7274 t.Fatalf("expected omnibox results area to use the header surface color")
7375 }
7476 }
77+
78+func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
79+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
80+
81+ assert.NotContains(t, css, "/* UI Scaling (130%) */")
82+ assert.NotContains(t, css, "window {\n\tfont-size:")
83+}
84+
85+func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) {
86+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
87+
88+ assert.NotContains(t, css, "line-height:")
89+ assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:")
90+}
91+
92+func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) {
93+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
94+
95+ assert.Contains(t, css, ".omnibox-row {")
96+ assert.Contains(t, css, "background-color: var(--surface);")
97+ assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;")
98+}
99+
100+func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) {
101+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
102+
103+ assert.Contains(t, css, ".omnibox-suggestion-title {")
104+ assert.Contains(t, css, "font-weight: 500;")
105+ assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
106+}
@@ -4,6 +4,8 @@ import (
4 "regexp"4 "regexp"
5 "strings"5 "strings"
6 "testing"6 "testing"
7+
8+ "github.com/stretchr/testify/assert"
7 )9 )
8 10
9 func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {11 func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
@@ -72,3 +74,33 @@ func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
72 t.Fatalf("expected omnibox results area to use the header surface color")74 t.Fatalf("expected omnibox results area to use the header surface color")
73 }75 }
74 }76 }
77+
78+func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
79+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
80+
81+ assert.NotContains(t, css, "/* UI Scaling (130%) */")
82+ assert.NotContains(t, css, "window {\n\tfont-size:")
83+}
84+
85+func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) {
86+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
87+
88+ assert.NotContains(t, css, "line-height:")
89+ assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:")
90+}
91+
92+func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) {
93+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
94+
95+ assert.Contains(t, css, ".omnibox-row {")
96+ assert.Contains(t, css, "background-color: var(--surface);")
97+ assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;")
98+}
99+
100+func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) {
101+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
102+
103+ assert.Contains(t, css, ".omnibox-suggestion-title {")
104+ assert.Contains(t, css, "font-weight: 500;")
105+ assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
106+}
M internal/ui/theme/manager.go +28 -0
@@ -3,6 +3,8 @@ package theme
33 import (
44 "context"
55 "fmt"
6+ "math"
7+ "strings"
68
79 "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/domain/entity"
@@ -150,6 +152,23 @@ func (m *Manager) GetWebUIThemeCSS() string {
150152 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
151153 }
152154
155+const gtkDefaultFontPointSize = 11
156+
157+func formatGTKFontName(family string, uiScale float64) string {
158+ family = strings.TrimSpace(family)
159+ if family == "" {
160+ family = DefaultFontConfig().SansFont
161+ }
162+ if uiScale <= 0 {
163+ uiScale = 1.0
164+ }
165+ size := int(math.Round(gtkDefaultFontPointSize * uiScale))
166+ if size < 1 {
167+ size = gtkDefaultFontPointSize
168+ }
169+ return fmt.Sprintf("%s %d", family, size)
170+}
171+
153172 // ApplyToDisplay loads the theme CSS into the display.
154173 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
155174 log := logging.FromContext(ctx)
@@ -162,6 +181,15 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
162181 // Generate CSS with current palette, UI scale, fonts, and mode colors
163182 palette := m.GetCurrentPalette()
164183 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
184+ fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale)
185+
186+ settings := gtk.SettingsGetForDisplay(display)
187+ if settings == nil {
188+ log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
189+ } else {
190+ settings.SetPropertyGtkFontName(fontName)
191+ settings.Unref()
192+ }
165193
166194 // Create CSS provider if needed
167195 if m.cssProvider == nil {
@@ -3,6 +3,8 @@ package theme
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
7+ "strings"
6 8
7 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -150,6 +152,23 @@ func (m *Manager) GetWebUIThemeCSS() string {
150 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)152 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
151 }153 }
152 154
155+const gtkDefaultFontPointSize = 11
156+
157+func formatGTKFontName(family string, uiScale float64) string {
158+ family = strings.TrimSpace(family)
159+ if family == "" {
160+ family = DefaultFontConfig().SansFont
161+ }
162+ if uiScale <= 0 {
163+ uiScale = 1.0
164+ }
165+ size := int(math.Round(gtkDefaultFontPointSize * uiScale))
166+ if size < 1 {
167+ size = gtkDefaultFontPointSize
168+ }
169+ return fmt.Sprintf("%s %d", family, size)
170+}
171+
153 // ApplyToDisplay loads the theme CSS into the display.172 // ApplyToDisplay loads the theme CSS into the display.
154 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {173 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
155 log := logging.FromContext(ctx)174 log := logging.FromContext(ctx)
@@ -162,6 +181,15 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
162 // Generate CSS with current palette, UI scale, fonts, and mode colors181 // Generate CSS with current palette, UI scale, fonts, and mode colors
163 palette := m.GetCurrentPalette()182 palette := m.GetCurrentPalette()
164 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)183 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
184+ fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale)
185+
186+ settings := gtk.SettingsGetForDisplay(display)
187+ if settings == nil {
188+ log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
189+ } else {
190+ settings.SetPropertyGtkFontName(fontName)
191+ settings.Unref()
192+ }
165 193
166 // Create CSS provider if needed194 // Create CSS provider if needed
167 if m.cssProvider == nil {195 if m.cssProvider == nil {
M internal/ui/theme/manager_test.go +9 -0
@@ -313,3 +313,12 @@ func TestManager_ModeColors(t *testing.T) {
313313 assert.Equal(t, "#0000ff", modeColors.SessionMode)
314314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)
315315 }
316+
317+func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) {
318+ assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3))
319+}
320+
321+func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
322+ assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
323+ assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
324+}
@@ -313,3 +313,12 @@ func TestManager_ModeColors(t *testing.T) {
313 assert.Equal(t, "#0000ff", modeColors.SessionMode)313 assert.Equal(t, "#0000ff", modeColors.SessionMode)
314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)
315 }315 }
316+
317+func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) {
318+ assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3))
319+}
320+
321+func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
322+ assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
323+ assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
324+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close