detached 50d6c62

fix(context-menu): theme popover chrome

commit
50d6c6211263404dea148fb190fbfd2133ebf9d9
author
brice · · 2026-04-14 05:39 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
5b526c7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +152 -2
M internal/infrastructure/gtkmenu/renderer.go +29 -2
@@ -11,6 +11,13 @@ type renderItem struct {
1111 separator bool
1212 }
1313
14+const (
15+ contextMenuBoxClass = "context-menu"
16+ contextMenuPopoverClass = "context-menu-popover"
17+ contextMenuItemClass = "context-menu-item"
18+ contextMenuSeparatorClass = "context-menu-separator"
19+)
20+
1421 // Renderer shows GTK context menus on the provided anchor widget.
1522 type Renderer struct {
1623 dispatch func(func())
@@ -33,6 +40,18 @@ func BuildButtons(items []port.MenuItem) []renderItem {
3340 return renderItems
3441 }
3542
43+func contextMenuItemClasses() []string {
44+ return []string{"flat", contextMenuItemClass}
45+}
46+
47+func contextMenuPopoverClasses() []string {
48+ return []string{contextMenuPopoverClass}
49+}
50+
51+func contextMenuSeparatorClasses() []string {
52+ return []string{contextMenuSeparatorClass}
53+}
54+
3655 func isSeparator(item port.MenuItem) bool {
3756 return item.Action == "" && item.Label == ""
3857 }
@@ -79,7 +98,7 @@ func showMenu(
7998 }
8099
81100 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")
101+ box.AddCssClass(contextMenuBoxClass)
83102
84103 selected := false
85104 var popover *gtk.Popover
@@ -89,13 +108,18 @@ func showMenu(
89108 for _, item := range items {
90109 if item.separator {
91110 sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
111+ for _, class := range contextMenuSeparatorClasses() {
112+ sep.AddCssClass(class)
113+ }
92114 box.Append(&sep.Widget)
93115 continue
94116 }
95117
96118 btn := gtk.NewButton()
97119 btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")
120+ for _, class := range contextMenuItemClasses() {
121+ btn.AddCssClass(class)
122+ }
99123
100124 menuItem := item.item
101125 clickCb := func(_ gtk.Button) {
@@ -112,6 +136,9 @@ func showMenu(
112136 }
113137
114138 popover = gtk.NewPopover()
139+ for _, class := range contextMenuPopoverClasses() {
140+ popover.AddCssClass(class)
141+ }
115142 popover.SetChild(&box.Widget)
116143 attachPopover(popover, popoverHost, parent)
117144 popover.SetHasArrow(false)
@@ -11,6 +11,13 @@ type renderItem struct {
11 separator bool11 separator bool
12 }12 }
13 13
14+const (
15+ contextMenuBoxClass = "context-menu"
16+ contextMenuPopoverClass = "context-menu-popover"
17+ contextMenuItemClass = "context-menu-item"
18+ contextMenuSeparatorClass = "context-menu-separator"
19+)
20+
14 // Renderer shows GTK context menus on the provided anchor widget.21 // Renderer shows GTK context menus on the provided anchor widget.
15 type Renderer struct {22 type Renderer struct {
16 dispatch func(func())23 dispatch func(func())
@@ -33,6 +40,18 @@ func BuildButtons(items []port.MenuItem) []renderItem {
33 return renderItems40 return renderItems
34 }41 }
35 42
43+func contextMenuItemClasses() []string {
44+ return []string{"flat", contextMenuItemClass}
45+}
46+
47+func contextMenuPopoverClasses() []string {
48+ return []string{contextMenuPopoverClass}
49+}
50+
51+func contextMenuSeparatorClasses() []string {
52+ return []string{contextMenuSeparatorClass}
53+}
54+
36 func isSeparator(item port.MenuItem) bool {55 func isSeparator(item port.MenuItem) bool {
37 return item.Action == "" && item.Label == ""56 return item.Action == "" && item.Label == ""
38 }57 }
@@ -79,7 +98,7 @@ func showMenu(
79 }98 }
80 99
81 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)100 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")101+ box.AddCssClass(contextMenuBoxClass)
83 102
84 selected := false103 selected := false
85 var popover *gtk.Popover104 var popover *gtk.Popover
@@ -89,13 +108,18 @@ func showMenu(
89 for _, item := range items {108 for _, item := range items {
90 if item.separator {109 if item.separator {
91 sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)110 sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
111+ for _, class := range contextMenuSeparatorClasses() {
112+ sep.AddCssClass(class)
113+ }
92 box.Append(&sep.Widget)114 box.Append(&sep.Widget)
93 continue115 continue
94 }116 }
95 117
96 btn := gtk.NewButton()118 btn := gtk.NewButton()
97 btn.SetLabel(item.item.Label)119 btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")120+ for _, class := range contextMenuItemClasses() {
121+ btn.AddCssClass(class)
122+ }
99 123
100 menuItem := item.item124 menuItem := item.item
101 clickCb := func(_ gtk.Button) {125 clickCb := func(_ gtk.Button) {
@@ -112,6 +136,9 @@ func showMenu(
112 }136 }
113 137
114 popover = gtk.NewPopover()138 popover = gtk.NewPopover()
139+ for _, class := range contextMenuPopoverClasses() {
140+ popover.AddCssClass(class)
141+ }
115 popover.SetChild(&box.Widget)142 popover.SetChild(&box.Widget)
116 attachPopover(popover, popoverHost, parent)143 attachPopover(popover, popoverHost, parent)
117 popover.SetHasArrow(false)144 popover.SetHasArrow(false)
M internal/infrastructure/gtkmenu/renderer_test.go +12 -0
@@ -62,3 +62,15 @@ func TestBuildButtons(t *testing.T) {
6262 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
6363 require.False(t, renderItems[3].separator)
6464 }
65+
66+func TestContextMenuItemClasses(t *testing.T) {
67+ require.Equal(t, []string{"flat", contextMenuItemClass}, contextMenuItemClasses())
68+}
69+
70+func TestContextMenuSeparatorClasses(t *testing.T) {
71+ require.Equal(t, []string{contextMenuSeparatorClass}, contextMenuSeparatorClasses())
72+}
73+
74+func TestContextMenuPopoverClasses(t *testing.T) {
75+ require.Equal(t, []string{contextMenuPopoverClass}, contextMenuPopoverClasses())
76+}
@@ -62,3 +62,15 @@ func TestBuildButtons(t *testing.T) {
62 require.Equal(t, "Inspect Element", renderItems[3].item.Label)62 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
63 require.False(t, renderItems[3].separator)63 require.False(t, renderItems[3].separator)
64 }64 }
65+
66+func TestContextMenuItemClasses(t *testing.T) {
67+ require.Equal(t, []string{"flat", contextMenuItemClass}, contextMenuItemClasses())
68+}
69+
70+func TestContextMenuSeparatorClasses(t *testing.T) {
71+ require.Equal(t, []string{contextMenuSeparatorClass}, contextMenuSeparatorClasses())
72+}
73+
74+func TestContextMenuPopoverClasses(t *testing.T) {
75+ require.Equal(t, []string{contextMenuPopoverClass}, contextMenuPopoverClasses())
76+}
M internal/ui/theme/css.go +64 -0
@@ -114,6 +114,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
114114 sb.WriteString(generateSessionManagerCSS(p))
115115 sb.WriteString("\n")
116116
117+ // Context menu styling
118+ sb.WriteString(generateContextMenuCSS(p))
119+ sb.WriteString("\n")
120+
117121 // Tab picker styling
118122 sb.WriteString(generateTabPickerCSS(p))
119123 sb.WriteString("\n")
@@ -902,6 +906,66 @@ row:selected .session-manager-row {
902906 `
903907 }
904908
909+// generateContextMenuCSS creates shared context menu styles.
910+func generateContextMenuCSS(p Palette) string {
911+ _ = p
912+ return `/* ===== Context Menu Styling ===== */
913+
914+popover.context-menu-popover {
915+ background-color: transparent;
916+ background-image: none;
917+}
918+
919+popover.context-menu-popover > contents {
920+ background-color: var(--surface);
921+ background-image: none;
922+ border: 0.0625em solid alpha(var(--border), 0.85);
923+ border-radius: 0.25em;
924+ box-shadow: 0 0.75em 1.75em alpha(var(--bg), 0.28), 0 0.0625em 0 alpha(var(--border), 0.18) inset;
925+ padding: 0;
926+}
927+
928+.context-menu {
929+ padding: 0.125em 0;
930+ min-width: 10.5em;
931+}
932+
933+.context-menu button.context-menu-item {
934+ background-color: transparent;
935+ background-image: none;
936+ border: none;
937+ border-radius: 0;
938+ color: var(--text);
939+ padding: 0.25em 0.75em;
940+ margin: 0;
941+ min-height: 1.75em;
942+ transition: background-color 120ms ease-in-out, color 120ms ease-in-out;
943+}
944+
945+.context-menu button.context-menu-item:hover,
946+.context-menu button.context-menu-item:focus,
947+.context-menu button.context-menu-item:active {
948+ background-color: alpha(var(--surface-variant), 0.9);
949+ color: var(--text);
950+}
951+
952+.context-menu button.context-menu-item:active {
953+ background-color: alpha(var(--accent), 0.16);
954+}
955+
956+.context-menu button.context-menu-item:disabled {
957+ color: var(--muted);
958+ opacity: 0.7;
959+}
960+
961+.context-menu-separator {
962+ background-color: alpha(var(--border), 0.55);
963+ margin: 0.25em 0.5em;
964+ min-height: 0.0625em;
965+}
966+`
967+}
968+
905969 // generateToasterCSS creates toast notification styles.
906970 // Uses em units for scalable UI.
907971 func generateToasterCSS(p Palette) string {
@@ -114,6 +114,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
114 sb.WriteString(generateSessionManagerCSS(p))114 sb.WriteString(generateSessionManagerCSS(p))
115 sb.WriteString("\n")115 sb.WriteString("\n")
116 116
117+ // Context menu styling
118+ sb.WriteString(generateContextMenuCSS(p))
119+ sb.WriteString("\n")
120+
117 // Tab picker styling121 // Tab picker styling
118 sb.WriteString(generateTabPickerCSS(p))122 sb.WriteString(generateTabPickerCSS(p))
119 sb.WriteString("\n")123 sb.WriteString("\n")
@@ -902,6 +906,66 @@ row:selected .session-manager-row {
902 `906 `
903 }907 }
904 908
909+// generateContextMenuCSS creates shared context menu styles.
910+func generateContextMenuCSS(p Palette) string {
911+ _ = p
912+ return `/* ===== Context Menu Styling ===== */
913+
914+popover.context-menu-popover {
915+ background-color: transparent;
916+ background-image: none;
917+}
918+
919+popover.context-menu-popover > contents {
920+ background-color: var(--surface);
921+ background-image: none;
922+ border: 0.0625em solid alpha(var(--border), 0.85);
923+ border-radius: 0.25em;
924+ box-shadow: 0 0.75em 1.75em alpha(var(--bg), 0.28), 0 0.0625em 0 alpha(var(--border), 0.18) inset;
925+ padding: 0;
926+}
927+
928+.context-menu {
929+ padding: 0.125em 0;
930+ min-width: 10.5em;
931+}
932+
933+.context-menu button.context-menu-item {
934+ background-color: transparent;
935+ background-image: none;
936+ border: none;
937+ border-radius: 0;
938+ color: var(--text);
939+ padding: 0.25em 0.75em;
940+ margin: 0;
941+ min-height: 1.75em;
942+ transition: background-color 120ms ease-in-out, color 120ms ease-in-out;
943+}
944+
945+.context-menu button.context-menu-item:hover,
946+.context-menu button.context-menu-item:focus,
947+.context-menu button.context-menu-item:active {
948+ background-color: alpha(var(--surface-variant), 0.9);
949+ color: var(--text);
950+}
951+
952+.context-menu button.context-menu-item:active {
953+ background-color: alpha(var(--accent), 0.16);
954+}
955+
956+.context-menu button.context-menu-item:disabled {
957+ color: var(--muted);
958+ opacity: 0.7;
959+}
960+
961+.context-menu-separator {
962+ background-color: alpha(var(--border), 0.55);
963+ margin: 0.25em 0.5em;
964+ min-height: 0.0625em;
965+}
966+`
967+}
968+
905 // generateToasterCSS creates toast notification styles.969 // generateToasterCSS creates toast notification styles.
906 // Uses em units for scalable UI.970 // Uses em units for scalable UI.
907 func generateToasterCSS(p Palette) string {971 func generateToasterCSS(p Palette) string {
M internal/ui/theme/css_test.go +47 -0
@@ -108,6 +108,53 @@ func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
108108 }
109109 }
110110
111+func TestGenerateCSS_ContextMenuUsesCompactSingleFrameStyling(t *testing.T) {
112+ css := GenerateCSS(DefaultDarkPalette())
113+
114+ assert.Contains(t, css, "popover.context-menu-popover > contents {")
115+ assert.Contains(t, css, ".context-menu {")
116+ assert.Contains(t, css, ".context-menu button.context-menu-item {")
117+ assert.Contains(t, css, ".context-menu button.context-menu-item:hover,")
118+ assert.Contains(t, css, ".context-menu button.context-menu-item:disabled {")
119+ assert.Contains(t, css, ".context-menu-separator {")
120+
121+ popoverBlockRe := regexp.MustCompile(`(?s)popover\.context-menu-popover\s*>\s*contents\s*\{[^}]*\}`)
122+ popoverBlock := popoverBlockRe.FindString(css)
123+ if popoverBlock == "" {
124+ t.Fatal("expected context menu popover contents block in CSS")
125+ }
126+
127+ assert.Contains(t, popoverBlock, "background-color: var(--surface);")
128+ assert.Contains(t, popoverBlock, "border: 0.0625em solid alpha(var(--border), 0.85);")
129+ assert.Contains(t, popoverBlock, "border-radius: 0.25em;")
130+ assert.Contains(t, popoverBlock, "box-shadow:")
131+
132+ blockRe := regexp.MustCompile(`(?s)\.context-menu\s*\{[^}]*\}`)
133+ block := blockRe.FindString(css)
134+ if block == "" {
135+ t.Fatal("expected context menu block in CSS")
136+ }
137+
138+ assert.NotContains(t, block, "background-color:")
139+ assert.Contains(t, block, "padding: 0.125em 0;")
140+ assert.Contains(t, block, "min-width: 10.5em;")
141+ assert.NotContains(t, block, "border:")
142+ assert.NotContains(t, block, "border-radius:")
143+ assert.NotContains(t, block, "box-shadow:")
144+ assert.NotContains(t, block, "#")
145+ assert.NotContains(t, block, "black")
146+ assert.NotContains(t, block, "white")
147+
148+ itemBlockRe := regexp.MustCompile(`(?s)\.context-menu button\.context-menu-item\s*\{[^}]*\}`)
149+ itemBlock := itemBlockRe.FindString(css)
150+ if itemBlock == "" {
151+ t.Fatal("expected context menu item block in CSS")
152+ }
153+
154+ assert.Contains(t, itemBlock, "padding: 0.25em 0.75em;")
155+ assert.Contains(t, itemBlock, "min-height: 1.75em;")
156+}
157+
111158 func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
112159 css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
113160
@@ -108,6 +108,53 @@ func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
108 }108 }
109 }109 }
110 110
111+func TestGenerateCSS_ContextMenuUsesCompactSingleFrameStyling(t *testing.T) {
112+ css := GenerateCSS(DefaultDarkPalette())
113+
114+ assert.Contains(t, css, "popover.context-menu-popover > contents {")
115+ assert.Contains(t, css, ".context-menu {")
116+ assert.Contains(t, css, ".context-menu button.context-menu-item {")
117+ assert.Contains(t, css, ".context-menu button.context-menu-item:hover,")
118+ assert.Contains(t, css, ".context-menu button.context-menu-item:disabled {")
119+ assert.Contains(t, css, ".context-menu-separator {")
120+
121+ popoverBlockRe := regexp.MustCompile(`(?s)popover\.context-menu-popover\s*>\s*contents\s*\{[^}]*\}`)
122+ popoverBlock := popoverBlockRe.FindString(css)
123+ if popoverBlock == "" {
124+ t.Fatal("expected context menu popover contents block in CSS")
125+ }
126+
127+ assert.Contains(t, popoverBlock, "background-color: var(--surface);")
128+ assert.Contains(t, popoverBlock, "border: 0.0625em solid alpha(var(--border), 0.85);")
129+ assert.Contains(t, popoverBlock, "border-radius: 0.25em;")
130+ assert.Contains(t, popoverBlock, "box-shadow:")
131+
132+ blockRe := regexp.MustCompile(`(?s)\.context-menu\s*\{[^}]*\}`)
133+ block := blockRe.FindString(css)
134+ if block == "" {
135+ t.Fatal("expected context menu block in CSS")
136+ }
137+
138+ assert.NotContains(t, block, "background-color:")
139+ assert.Contains(t, block, "padding: 0.125em 0;")
140+ assert.Contains(t, block, "min-width: 10.5em;")
141+ assert.NotContains(t, block, "border:")
142+ assert.NotContains(t, block, "border-radius:")
143+ assert.NotContains(t, block, "box-shadow:")
144+ assert.NotContains(t, block, "#")
145+ assert.NotContains(t, block, "black")
146+ assert.NotContains(t, block, "white")
147+
148+ itemBlockRe := regexp.MustCompile(`(?s)\.context-menu button\.context-menu-item\s*\{[^}]*\}`)
149+ itemBlock := itemBlockRe.FindString(css)
150+ if itemBlock == "" {
151+ t.Fatal("expected context menu item block in CSS")
152+ }
153+
154+ assert.Contains(t, itemBlock, "padding: 0.25em 0.75em;")
155+ assert.Contains(t, itemBlock, "min-height: 1.75em;")
156+}
157+
111 func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {158 func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
112 css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)159 css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
113 160

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