detached 12c4fc5

refactor(theme): move pulse and MRU color math into theme package

commit
12c4fc5d35de00795a3e424a1ea758a200c0e326
author
brice · · 2026-07-17 08:02 UTC
parent
5c7684b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +168 -15
M internal/usecase/daemon/status.go +2 -15
@@ -41,14 +41,7 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
4141 distance = -distance
4242 }
4343 intensity := 1 - float64(distance)/float64(peak)
44- style := base
45- style.Bold = true
46- if base.HasForegroundRGB && base.HasBackgroundRGB {
47- style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
48- } else {
49- style.Foreground = 244 + int(intensity*11)
50- }
51- return style, true
44+ return themeui.PulseColor(base, intensity), true
5245 }
5346
5447 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
@@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int {
434427 }
435428
436429 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
437- if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
438- return base
439- }
440- amount := (float64(i) / float64(count-1)) * 0.6
441- base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
442- base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
443- return base
430+ return themeui.MRUFade(base, t, i, count)
444431 }
445432
446433 // writeBell draws the attention glyph on its visible beat, or a plain cell in
@@ -41,14 +41,7 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
41 distance = -distance41 distance = -distance
42 }42 }
43 intensity := 1 - float64(distance)/float64(peak)43 intensity := 1 - float64(distance)/float64(peak)
44- style := base44+ return themeui.PulseColor(base, intensity), true
45- style.Bold = true
46- if base.HasForegroundRGB && base.HasBackgroundRGB {
47- style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
48- } else {
49- style.Foreground = 244 + int(intensity*11)
50- }
51- return style, true
52 }45 }
53 46
54 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {47 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
@@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int {
434 }427 }
435 428
436 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {429 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
437- if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {430+ return themeui.MRUFade(base, t, i, count)
438- return base
439- }
440- amount := (float64(i) / float64(count-1)) * 0.6
441- base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
442- base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
443- return base
444 }431 }
445 432
446 // writeBell draws the attention glyph on its visible beat, or a plain cell in433 // writeBell draws the attention glyph on its visible beat, or a plain cell in
M internal/usecase/theme/styles.go +25 -0
@@ -2,6 +2,31 @@ package theme
22
33 import "github.com/bnema/vev/pkg/renderer"
44
5+// PulseColor derives the attention pulse color at intensity while preserving
6+// the base style's remaining attributes.
7+func PulseColor(base renderer.Style, intensity float64) renderer.Style {
8+ style := base
9+ style.Bold = true
10+ if base.HasForegroundRGB && base.HasBackgroundRGB {
11+ style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
12+ } else {
13+ style.Foreground = 244 + int(intensity*11)
14+ }
15+ return style
16+}
17+
18+// MRUFade fades an MRU style by position, reaching 60 percent of the terminal
19+// background color at the oldest entry.
20+func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
21+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
22+ return base
23+ }
24+ amount := (float64(i) / float64(count-1)) * 0.6
25+ base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount)
26+ base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount)
27+ return base
28+}
29+
530 // Styles is the resolved set of terminal chrome styles for a client theme.
631 type Styles struct {
732 StatusBar renderer.Style
@@ -2,6 +2,31 @@ package theme
2 2
3 import "github.com/bnema/vev/pkg/renderer"3 import "github.com/bnema/vev/pkg/renderer"
4 4
5+// PulseColor derives the attention pulse color at intensity while preserving
6+// the base style's remaining attributes.
7+func PulseColor(base renderer.Style, intensity float64) renderer.Style {
8+ style := base
9+ style.Bold = true
10+ if base.HasForegroundRGB && base.HasBackgroundRGB {
11+ style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity)
12+ } else {
13+ style.Foreground = 244 + int(intensity*11)
14+ }
15+ return style
16+}
17+
18+// MRUFade fades an MRU style by position, reaching 60 percent of the terminal
19+// background color at the oldest entry.
20+func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
21+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
22+ return base
23+ }
24+ amount := (float64(i) / float64(count-1)) * 0.6
25+ base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount)
26+ base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount)
27+ return base
28+}
29+
5 // Styles is the resolved set of terminal chrome styles for a client theme.30 // Styles is the resolved set of terminal chrome styles for a client theme.
6 type Styles struct {31 type Styles struct {
7 StatusBar renderer.Style32 StatusBar renderer.Style
M internal/usecase/theme/styles_test.go +141 -0
@@ -7,6 +7,147 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10+func TestPulseColor(t *testing.T) {
11+ rgbBase := renderer.Style{
12+ Italic: true,
13+ Foreground: 7,
14+ Background: 8,
15+ HasForegroundRGB: true,
16+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
17+ HasBackgroundRGB: true,
18+ BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60},
19+ HasUnderlineColor: true,
20+ UnderlineColor: 4,
21+ HasUnderlineColorRGB: false,
22+ }
23+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
24+
25+ tests := []struct {
26+ name string
27+ base renderer.Style
28+ intensity float64
29+ want renderer.Style
30+ }{
31+ {
32+ name: "RGB starts at the background",
33+ base: rgbBase,
34+ intensity: 0,
35+ want: func() renderer.Style {
36+ style := rgbBase
37+ style.Bold = true
38+ style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60}
39+ return style
40+ }(),
41+ },
42+ {
43+ name: "RGB blends halfway with rounding",
44+ base: rgbBase,
45+ intensity: 0.5,
46+ want: func() renderer.Style {
47+ style := rgbBase
48+ style.Bold = true
49+ style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55}
50+ return style
51+ }(),
52+ },
53+ {
54+ name: "RGB reaches the foreground",
55+ base: rgbBase,
56+ intensity: 1,
57+ want: func() renderer.Style {
58+ style := rgbBase
59+ style.Bold = true
60+ return style
61+ }(),
62+ },
63+ {
64+ name: "indexed color uses xterm grayscale start",
65+ base: indexedBase,
66+ intensity: 0,
67+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1},
68+ },
69+ {
70+ name: "indexed color truncates the grayscale ramp",
71+ base: indexedBase,
72+ intensity: 0.5,
73+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1},
74+ },
75+ {
76+ name: "indexed color reaches xterm grayscale end",
77+ base: indexedBase,
78+ intensity: 1,
79+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1},
80+ },
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity))
86+ })
87+ }
88+}
89+
90+func TestMRUFade(t *testing.T) {
91+ base := renderer.Style{
92+ Bold: true,
93+ Foreground: 7,
94+ Background: 8,
95+ HasForegroundRGB: true,
96+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
97+ HasBackgroundRGB: true,
98+ BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80},
99+ HasUnderlineColorRGB: true,
100+ UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3},
101+ }
102+ terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}}
103+
104+ tests := []struct {
105+ name string
106+ base renderer.Style
107+ theme Theme
108+ i int
109+ count int
110+ want renderer.Style
111+ }{
112+ {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base},
113+ {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base},
114+ {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()},
115+ {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base},
116+ {
117+ name: "middle entry fades thirty percent toward terminal background",
118+ base: base,
119+ theme: terminalTheme,
120+ i: 1,
121+ count: 3,
122+ want: func() renderer.Style {
123+ style := base
124+ style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44}
125+ style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65}
126+ return style
127+ }(),
128+ },
129+ {
130+ name: "oldest entry fades sixty percent toward terminal background",
131+ base: base,
132+ theme: terminalTheme,
133+ i: 2,
134+ count: 3,
135+ want: func() renderer.Style {
136+ style := base
137+ style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38}
138+ style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50}
139+ return style
140+ }(),
141+ },
142+ }
143+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count))
147+ })
148+ }
149+}
150+
10151 func TestNewStylesComposesSemanticStyles(t *testing.T) {
11152 theme := Theme{
12153 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
@@ -7,6 +7,147 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10+func TestPulseColor(t *testing.T) {
11+ rgbBase := renderer.Style{
12+ Italic: true,
13+ Foreground: 7,
14+ Background: 8,
15+ HasForegroundRGB: true,
16+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
17+ HasBackgroundRGB: true,
18+ BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60},
19+ HasUnderlineColor: true,
20+ UnderlineColor: 4,
21+ HasUnderlineColorRGB: false,
22+ }
23+ indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
24+
25+ tests := []struct {
26+ name string
27+ base renderer.Style
28+ intensity float64
29+ want renderer.Style
30+ }{
31+ {
32+ name: "RGB starts at the background",
33+ base: rgbBase,
34+ intensity: 0,
35+ want: func() renderer.Style {
36+ style := rgbBase
37+ style.Bold = true
38+ style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60}
39+ return style
40+ }(),
41+ },
42+ {
43+ name: "RGB blends halfway with rounding",
44+ base: rgbBase,
45+ intensity: 0.5,
46+ want: func() renderer.Style {
47+ style := rgbBase
48+ style.Bold = true
49+ style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55}
50+ return style
51+ }(),
52+ },
53+ {
54+ name: "RGB reaches the foreground",
55+ base: rgbBase,
56+ intensity: 1,
57+ want: func() renderer.Style {
58+ style := rgbBase
59+ style.Bold = true
60+ return style
61+ }(),
62+ },
63+ {
64+ name: "indexed color uses xterm grayscale start",
65+ base: indexedBase,
66+ intensity: 0,
67+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1},
68+ },
69+ {
70+ name: "indexed color truncates the grayscale ramp",
71+ base: indexedBase,
72+ intensity: 0.5,
73+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1},
74+ },
75+ {
76+ name: "indexed color reaches xterm grayscale end",
77+ base: indexedBase,
78+ intensity: 1,
79+ want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1},
80+ },
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity))
86+ })
87+ }
88+}
89+
90+func TestMRUFade(t *testing.T) {
91+ base := renderer.Style{
92+ Bold: true,
93+ Foreground: 7,
94+ Background: 8,
95+ HasForegroundRGB: true,
96+ ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
97+ HasBackgroundRGB: true,
98+ BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80},
99+ HasUnderlineColorRGB: true,
100+ UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3},
101+ }
102+ terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}}
103+
104+ tests := []struct {
105+ name string
106+ base renderer.Style
107+ theme Theme
108+ i int
109+ count int
110+ want renderer.Style
111+ }{
112+ {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base},
113+ {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base},
114+ {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()},
115+ {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base},
116+ {
117+ name: "middle entry fades thirty percent toward terminal background",
118+ base: base,
119+ theme: terminalTheme,
120+ i: 1,
121+ count: 3,
122+ want: func() renderer.Style {
123+ style := base
124+ style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44}
125+ style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65}
126+ return style
127+ }(),
128+ },
129+ {
130+ name: "oldest entry fades sixty percent toward terminal background",
131+ base: base,
132+ theme: terminalTheme,
133+ i: 2,
134+ count: 3,
135+ want: func() renderer.Style {
136+ style := base
137+ style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38}
138+ style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50}
139+ return style
140+ }(),
141+ },
142+ }
143+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count))
147+ })
148+ }
149+}
150+
10 func TestNewStylesComposesSemanticStyles(t *testing.T) {151 func TestNewStylesComposesSemanticStyles(t *testing.T) {
11 theme := Theme{152 theme := Theme{
12 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},153 Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},

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 12

Tags 11

↑↓ moveenter openesc close