detached 202ebb8

fix(ui): implement reactive font configuration and improve workspace styling

commit
202ebb859df33479b45596b50197f990dcb4f928
author
brice · · 2025-12-22 20:12 UTC
committer
brice · · 2025-12-22 19:10 UTC
parent
a6d8f47
signature
unsigned
4 files · +80 -12
M internal/ui/component/workspace_view_test.go +2 -0
@@ -147,6 +147,7 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
147147
148148 mockStackBox.EXPECT().SetVisible(true).Once()
149149 mockBox.EXPECT().Append(mockStackBox).Once()
150+ mockBox.EXPECT().AddCssClass("single-pane").Once()
150151 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
151152
152153 pane := entity.NewPane(entity.PaneID("pane-1"))
@@ -204,6 +205,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
204205 mockBox.EXPECT().Append(mockPaned).Once()
205206
206207 // Initial active pane is pane-1
208+ mockBox.EXPECT().RemoveCssClass("single-pane").Once()
207209 mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()
208210
209211 splitNode := &entity.PaneNode{
@@ -147,6 +147,7 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
147 147
148 mockStackBox.EXPECT().SetVisible(true).Once()148 mockStackBox.EXPECT().SetVisible(true).Once()
149 mockBox.EXPECT().Append(mockStackBox).Once()149 mockBox.EXPECT().Append(mockStackBox).Once()
150+ mockBox.EXPECT().AddCssClass("single-pane").Once()
150 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()151 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
151 152
152 pane := entity.NewPane(entity.PaneID("pane-1"))153 pane := entity.NewPane(entity.PaneID("pane-1"))
@@ -204,6 +205,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
204 mockBox.EXPECT().Append(mockPaned).Once()205 mockBox.EXPECT().Append(mockPaned).Once()
205 206
206 // Initial active pane is pane-1207 // Initial active pane is pane-1
208+ mockBox.EXPECT().RemoveCssClass("single-pane").Once()
207 mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()209 mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()
208 210
209 splitNode := &entity.PaneNode{211 splitNode := &entity.PaneNode{
M internal/ui/theme/css.go +49 -1
@@ -14,18 +14,66 @@ func GenerateCSS(p Palette) string {
1414 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
1515 // Scale affects font sizes and widget padding/margins proportionally.
1616 func GenerateCSSWithScale(p Palette, scale float64) string {
17+ return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
18+}
19+
20+// FontConfig holds font settings for CSS generation.
21+type FontConfig struct {
22+ SansFont string
23+ MonospaceFont string
24+}
25+
26+// DefaultFontConfig returns safe font fallbacks.
27+func DefaultFontConfig() FontConfig {
28+ return FontConfig{
29+ SansFont: "sans-serif",
30+ MonospaceFont: "monospace",
31+ }
32+}
33+
34+// FontCSSVars generates CSS custom properties for fonts.
35+func FontCSSVars(fonts FontConfig) string {
36+ var sb strings.Builder
37+ // Quote the configured family; include generic fallback.
38+ sb.WriteString(fmt.Sprintf(" --font-sans: \"%s\", sans-serif;\n", fonts.SansFont))
39+ sb.WriteString(fmt.Sprintf(" --font-mono: \"%s\", monospace;\n", fonts.MonospaceFont))
40+ return sb.String()
41+}
42+
43+func generateFontCSS() string {
44+ return `/* Font styling */
45+window, tooltip, popover {
46+ font-family: var(--font-sans);
47+}
48+`
49+}
50+
51+// GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52+// Scale affects font sizes and widget padding/margins proportionally.
53+func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
1754 if scale <= 0 {
1855 scale = 1.0
1956 }
2057
58+ defaults := DefaultFontConfig()
59+ fonts = FontConfig{
60+ SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
61+ MonospaceFont: Coalesce(fonts.MonospaceFont, defaults.MonospaceFont),
62+ }
63+
2164 var sb strings.Builder
2265
2366 // CSS custom properties (variables) - GTK4 uses :root selector
2467 sb.WriteString("/* Theme variables */\n")
2568 sb.WriteString(":root {\n")
2669 sb.WriteString(p.ToCSSVars())
70+ sb.WriteString(FontCSSVars(fonts))
2771 sb.WriteString("}\n\n")
2872
73+ // Global font styling
74+ sb.WriteString(generateFontCSS())
75+ sb.WriteString("\n")
76+
2977 // Global UI scaling via font-size on all widgets
3078 if scale != 1.0 {
3179 sb.WriteString(generateScalingCSS(scale))
@@ -274,7 +322,7 @@ entry.omnibox-entry > *:focus-visible {
274322 padding: 0.125em 0.375em;
275323 font-size: 0.625em;
276324 font-weight: 500;
277- font-family: monospace;
325+ font-family: var(--font-mono);
278326 margin-left: 0.5em;
279327 }
280328
@@ -14,18 +14,66 @@ func GenerateCSS(p Palette) string {
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 font sizes and widget padding/margins proportionally.
16 func GenerateCSSWithScale(p Palette, scale float64) string {16 func GenerateCSSWithScale(p Palette, scale float64) string {
17+ return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
18+}
19+
20+// FontConfig holds font settings for CSS generation.
21+type FontConfig struct {
22+ SansFont string
23+ MonospaceFont string
24+}
25+
26+// DefaultFontConfig returns safe font fallbacks.
27+func DefaultFontConfig() FontConfig {
28+ return FontConfig{
29+ SansFont: "sans-serif",
30+ MonospaceFont: "monospace",
31+ }
32+}
33+
34+// FontCSSVars generates CSS custom properties for fonts.
35+func FontCSSVars(fonts FontConfig) string {
36+ var sb strings.Builder
37+ // Quote the configured family; include generic fallback.
38+ sb.WriteString(fmt.Sprintf(" --font-sans: \"%s\", sans-serif;\n", fonts.SansFont))
39+ sb.WriteString(fmt.Sprintf(" --font-mono: \"%s\", monospace;\n", fonts.MonospaceFont))
40+ return sb.String()
41+}
42+
43+func generateFontCSS() string {
44+ return `/* Font styling */
45+window, tooltip, popover {
46+ font-family: var(--font-sans);
47+}
48+`
49+}
50+
51+// GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52+// Scale affects font sizes and widget padding/margins proportionally.
53+func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
17 if scale <= 0 {54 if scale <= 0 {
18 scale = 1.055 scale = 1.0
19 }56 }
20 57
58+ defaults := DefaultFontConfig()
59+ fonts = FontConfig{
60+ SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
61+ MonospaceFont: Coalesce(fonts.MonospaceFont, defaults.MonospaceFont),
62+ }
63+
21 var sb strings.Builder64 var sb strings.Builder
22 65
23 // CSS custom properties (variables) - GTK4 uses :root selector66 // CSS custom properties (variables) - GTK4 uses :root selector
24 sb.WriteString("/* Theme variables */\n")67 sb.WriteString("/* Theme variables */\n")
25 sb.WriteString(":root {\n")68 sb.WriteString(":root {\n")
26 sb.WriteString(p.ToCSSVars())69 sb.WriteString(p.ToCSSVars())
70+ sb.WriteString(FontCSSVars(fonts))
27 sb.WriteString("}\n\n")71 sb.WriteString("}\n\n")
28 72
73+ // Global font styling
74+ sb.WriteString(generateFontCSS())
75+ sb.WriteString("\n")
76+
29 // Global UI scaling via font-size on all widgets77 // Global UI scaling via font-size on all widgets
30 if scale != 1.0 {78 if scale != 1.0 {
31 sb.WriteString(generateScalingCSS(scale))79 sb.WriteString(generateScalingCSS(scale))
@@ -274,7 +322,7 @@ entry.omnibox-entry > *:focus-visible {
274 padding: 0.125em 0.375em;322 padding: 0.125em 0.375em;
275 font-size: 0.625em;323 font-size: 0.625em;
276 font-weight: 500;324 font-weight: 500;
277- font-family: monospace;325+ font-family: var(--font-mono);
278 margin-left: 0.5em;326 margin-left: 0.5em;
279 }327 }
280 328
M internal/ui/theme/manager.go +20 -2
@@ -17,6 +17,7 @@ type Manager struct {
1717 lightPalette Palette // Light theme colors
1818 darkPalette Palette // Dark theme colors
1919 uiScale float64 // UI scaling factor (1.0 = 100%)
20+ fonts FontConfig
2021 cssProvider *gtk.CssProvider
2122 }
2223
@@ -49,10 +50,18 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
4950 uiScale = cfg.DefaultUIScale
5051 }
5152
53+ fonts := DefaultFontConfig()
54+ if cfg != nil {
55+ fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
56+ fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57+ }
58+
5259 log.Debug().
5360 Str("scheme", scheme).
5461 Bool("prefers_dark", prefersDark).
5562 Float64("ui_scale", uiScale).
63+ Str("sans_font", fonts.SansFont).
64+ Str("monospace_font", fonts.MonospaceFont).
5665 Msg("theme manager initialized")
5766
5867 return &Manager{
@@ -61,6 +70,7 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
6170 lightPalette: lightPalette,
6271 darkPalette: darkPalette,
6372 uiScale: uiScale,
73+ fonts: fonts,
6474 }
6575 }
6676
@@ -104,9 +114,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
104114 return
105115 }
106116
107- // Generate CSS with current palette and UI scale
117+ // Generate CSS with current palette, UI scale and fonts
108118 palette := m.GetCurrentPalette()
109- css := GenerateCSSWithScale(palette, m.uiScale)
119+ css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
110120
111121 // Create CSS provider if needed
112122 if m.cssProvider == nil {
@@ -174,10 +184,18 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
174184 m.uiScale = cfg.DefaultUIScale
175185 }
176186
187+ defaults := DefaultFontConfig()
188+ m.fonts = FontConfig{
189+ SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
190+ MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
191+ }
192+
177193 log.Info().
178194 Str("scheme", m.scheme).
179195 Bool("prefers_dark", m.prefersDark).
180196 Float64("ui_scale", m.uiScale).
197+ Str("sans_font", m.fonts.SansFont).
198+ Str("monospace_font", m.fonts.MonospaceFont).
181199 Msg("theme manager updated from config")
182200
183201 // Re-apply CSS if display is available
@@ -17,6 +17,7 @@ type Manager struct {
17 lightPalette Palette // Light theme colors17 lightPalette Palette // Light theme colors
18 darkPalette Palette // Dark theme colors18 darkPalette Palette // Dark theme colors
19 uiScale float64 // UI scaling factor (1.0 = 100%)19 uiScale float64 // UI scaling factor (1.0 = 100%)
20+ fonts FontConfig
20 cssProvider *gtk.CssProvider21 cssProvider *gtk.CssProvider
21 }22 }
22 23
@@ -49,10 +50,18 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
49 uiScale = cfg.DefaultUIScale50 uiScale = cfg.DefaultUIScale
50 }51 }
51 52
53+ fonts := DefaultFontConfig()
54+ if cfg != nil {
55+ fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
56+ fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57+ }
58+
52 log.Debug().59 log.Debug().
53 Str("scheme", scheme).60 Str("scheme", scheme).
54 Bool("prefers_dark", prefersDark).61 Bool("prefers_dark", prefersDark).
55 Float64("ui_scale", uiScale).62 Float64("ui_scale", uiScale).
63+ Str("sans_font", fonts.SansFont).
64+ Str("monospace_font", fonts.MonospaceFont).
56 Msg("theme manager initialized")65 Msg("theme manager initialized")
57 66
58 return &Manager{67 return &Manager{
@@ -61,6 +70,7 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
61 lightPalette: lightPalette,70 lightPalette: lightPalette,
62 darkPalette: darkPalette,71 darkPalette: darkPalette,
63 uiScale: uiScale,72 uiScale: uiScale,
73+ fonts: fonts,
64 }74 }
65 }75 }
66 76
@@ -104,9 +114,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
104 return114 return
105 }115 }
106 116
107- // Generate CSS with current palette and UI scale117+ // Generate CSS with current palette, UI scale and fonts
108 palette := m.GetCurrentPalette()118 palette := m.GetCurrentPalette()
109- css := GenerateCSSWithScale(palette, m.uiScale)119+ css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
110 120
111 // Create CSS provider if needed121 // Create CSS provider if needed
112 if m.cssProvider == nil {122 if m.cssProvider == nil {
@@ -174,10 +184,18 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
174 m.uiScale = cfg.DefaultUIScale184 m.uiScale = cfg.DefaultUIScale
175 }185 }
176 186
187+ defaults := DefaultFontConfig()
188+ m.fonts = FontConfig{
189+ SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
190+ MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
191+ }
192+
177 log.Info().193 log.Info().
178 Str("scheme", m.scheme).194 Str("scheme", m.scheme).
179 Bool("prefers_dark", m.prefersDark).195 Bool("prefers_dark", m.prefersDark).
180 Float64("ui_scale", m.uiScale).196 Float64("ui_scale", m.uiScale).
197+ Str("sans_font", m.fonts.SansFont).
198+ Str("monospace_font", m.fonts.MonospaceFont).
181 Msg("theme manager updated from config")199 Msg("theme manager updated from config")
182 200
183 // Re-apply CSS if display is available201 // Re-apply CSS if display is available
M internal/ui/theme/palette.go +9 -9
@@ -70,13 +70,13 @@ func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
7070 }
7171
7272 return Palette{
73- Background: coalesce(cfg.Background, defaults.Background),
74- Surface: coalesce(cfg.Surface, defaults.Surface),
75- SurfaceVariant: coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
76- Text: coalesce(cfg.Text, defaults.Text),
77- Muted: coalesce(cfg.Muted, defaults.Muted),
78- Accent: coalesce(cfg.Accent, defaults.Accent),
79- Border: coalesce(cfg.Border, defaults.Border),
73+ Background: Coalesce(cfg.Background, defaults.Background),
74+ Surface: Coalesce(cfg.Surface, defaults.Surface),
75+ SurfaceVariant: Coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
76+ Text: Coalesce(cfg.Text, defaults.Text),
77+ Muted: Coalesce(cfg.Muted, defaults.Muted),
78+ Accent: Coalesce(cfg.Accent, defaults.Accent),
79+ Border: Coalesce(cfg.Border, defaults.Border),
8080 // Semantic colors always use defaults (not user-editable)
8181 Success: defaults.Success,
8282 Warning: defaults.Warning,
@@ -84,8 +84,8 @@ func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
8484 }
8585 }
8686
87-// coalesce returns the first non-empty string.
88-func coalesce(values ...string) string {
87+// Coalesce returns the first non-empty string.
88+func Coalesce(values ...string) string {
8989 for _, v := range values {
9090 if v != "" {
9191 return v
@@ -70,13 +70,13 @@ func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
70 }70 }
71 71
72 return Palette{72 return Palette{
73- Background: coalesce(cfg.Background, defaults.Background),73+ Background: Coalesce(cfg.Background, defaults.Background),
74- Surface: coalesce(cfg.Surface, defaults.Surface),74+ Surface: Coalesce(cfg.Surface, defaults.Surface),
75- SurfaceVariant: coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),75+ SurfaceVariant: Coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
76- Text: coalesce(cfg.Text, defaults.Text),76+ Text: Coalesce(cfg.Text, defaults.Text),
77- Muted: coalesce(cfg.Muted, defaults.Muted),77+ Muted: Coalesce(cfg.Muted, defaults.Muted),
78- Accent: coalesce(cfg.Accent, defaults.Accent),78+ Accent: Coalesce(cfg.Accent, defaults.Accent),
79- Border: coalesce(cfg.Border, defaults.Border),79+ Border: Coalesce(cfg.Border, defaults.Border),
80 // Semantic colors always use defaults (not user-editable)80 // Semantic colors always use defaults (not user-editable)
81 Success: defaults.Success,81 Success: defaults.Success,
82 Warning: defaults.Warning,82 Warning: defaults.Warning,
@@ -84,8 +84,8 @@ func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
84 }84 }
85 }85 }
86 86
87-// coalesce returns the first non-empty string.87+// Coalesce returns the first non-empty string.
88-func coalesce(values ...string) string {88+func Coalesce(values ...string) string {
89 for _, v := range values {89 for _, v := range values {
90 if v != "" {90 if v != "" {
91 return v91 return v

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