Skip to content

Commit a71fea3

Browse files
committed
feat(styles): add light/dark mode support
Style.Mode() derives Light/Dark from the Background entry's luminosity. A new optional Counterpart field (and counterpart="..." XML attribute) points at the opposite-mode style. styles.GetForMode(name, mode) follows the counterpart, and RegisterPair links a Light/Dark pair both ways. Wires counterparts on the obvious paired bundled styles.
1 parent c3826f0 commit a71fea3

26 files changed

Lines changed: 198 additions & 33 deletions

style.go

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ const (
1919
No
2020
)
2121

22+
// Mode indicates whether a style is intended for a light or dark background.
23+
type Mode uint8
24+
25+
// Mode values.
26+
const (
27+
Light Mode = iota
28+
Dark
29+
)
30+
31+
func (m Mode) String() string {
32+
switch m {
33+
case Dark:
34+
return "dark"
35+
default:
36+
return "light"
37+
}
38+
}
39+
2240
func (t Trilean) String() string {
2341
switch t {
2442
case Yes:
@@ -149,15 +167,22 @@ func (s StyleEntry) IsZero() bool {
149167
//
150168
// Once built, a Style is immutable.
151169
type StyleBuilder struct {
152-
entries map[TokenType]string
153-
name string
154-
parent *Style
170+
entries map[TokenType]string
171+
name string
172+
counterpart string
173+
parent *Style
155174
}
156175

157176
func NewStyleBuilder(name string) *StyleBuilder {
158177
return &StyleBuilder{name: name, entries: map[TokenType]string{}}
159178
}
160179

180+
// Counterpart sets the lowercase name of the opposite-mode style.
181+
func (s *StyleBuilder) Counterpart(name string) *StyleBuilder {
182+
s.counterpart = strings.ToLower(name)
183+
return s
184+
}
185+
161186
func (s *StyleBuilder) AddAll(entries StyleEntries) *StyleBuilder {
162187
maps.Copy(s.entries, entries)
163188
return s
@@ -205,10 +230,15 @@ func (s *StyleBuilder) Transform(transform func(StyleEntry) StyleEntry) *StyleBu
205230
}
206231

207232
func (s *StyleBuilder) Build() (*Style, error) {
233+
counterpart := s.counterpart
234+
if counterpart == "" && s.parent != nil {
235+
counterpart = s.parent.Counterpart
236+
}
208237
style := &Style{
209-
Name: s.name,
210-
entries: map[TokenType]StyleEntry{},
211-
parent: s.parent,
238+
Name: s.name,
239+
Counterpart: counterpart,
240+
entries: map[TokenType]StyleEntry{},
241+
parent: s.parent,
212242
}
213243
for ttype, descriptor := range s.entries {
214244
entry, err := ParseStyleEntry(descriptor)
@@ -257,9 +287,23 @@ func MustNewStyle(name string, entries StyleEntries) *Style {
257287
//
258288
// See http://pygments.org/docs/styles/ for details. Semantics are intended to be identical.
259289
type Style struct {
260-
Name string
261-
entries map[TokenType]StyleEntry
262-
parent *Style
290+
Name string
291+
// Counterpart is the lowercase name of the style intended as this style's
292+
// opposite-mode pair (eg. "github-dark" for "github"). Resolved via
293+
// styles.GetForMode. May be empty.
294+
Counterpart string
295+
entries map[TokenType]StyleEntry
296+
parent *Style
297+
}
298+
299+
// Mode returns Light or Dark based on the brightness of the Background entry's
300+
// background colour. Styles with an unset Background default to Light.
301+
func (s *Style) Mode() Mode {
302+
bg := s.get(Background).Background
303+
if bg.IsSet() && bg.Brightness() < 0.5 {
304+
return Dark
305+
}
306+
return Light
263307
}
264308

265309
func (s *Style) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
@@ -268,6 +312,9 @@ func (s *Style) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
268312
}
269313
start.Name = xml.Name{Local: "style"}
270314
start.Attr = []xml.Attr{{Name: xml.Name{Local: "name"}, Value: s.Name}}
315+
if s.Counterpart != "" {
316+
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "counterpart"}, Value: s.Counterpart})
317+
}
271318
if err := e.EncodeToken(start); err != nil {
272319
return err
273320
}
@@ -295,9 +342,12 @@ func (s *Style) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
295342

296343
func (s *Style) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
297344
for _, attr := range start.Attr {
298-
if attr.Name.Local == "name" {
345+
switch attr.Name.Local {
346+
case "name":
299347
s.Name = attr.Value
300-
} else {
348+
case "counterpart":
349+
s.Counterpart = strings.ToLower(attr.Value)
350+
default:
301351
return fmt.Errorf("unexpected attribute %s", attr.Name.Local)
302352
}
303353
}

style_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,56 @@ func TestStyleBuilderTransform(t *testing.T) {
103103
assert.Equal(t, "#ff3300", deriv.Get(NameVariableGlobal).Colour.String())
104104
}
105105

106+
func TestStyleMode(t *testing.T) {
107+
tests := []struct {
108+
name string
109+
bg string
110+
want Mode
111+
}{
112+
{"DarkBackground", "bg:#000000", Dark},
113+
{"LightBackground", "bg:#ffffff", Light},
114+
{"UnsetBackgroundDefaultsToLight", "#000000", Light},
115+
{"NearMidLight", "bg:#888888", Light},
116+
{"NearMidDark", "bg:#777777", Dark},
117+
}
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
s, err := NewStyle("test", StyleEntries{Background: tt.bg})
121+
assert.NoError(t, err)
122+
assert.Equal(t, tt.want, s.Mode())
123+
})
124+
}
125+
}
126+
127+
func TestStyleCounterpartXMLRoundTrip(t *testing.T) {
128+
style, err := NewStyle("test", StyleEntries{Background: "bg:#ffffff"})
129+
assert.NoError(t, err)
130+
style.Counterpart = "other"
131+
data, err := xml.MarshalIndent(style, "", " ")
132+
assert.NoError(t, err)
133+
assert.Equal(t, `<style name="test" counterpart="other">
134+
<entry type="Background" style="bg:#ffffff"></entry>
135+
</style>`, string(data))
136+
got := &Style{}
137+
err = xml.Unmarshal(data, got)
138+
assert.NoError(t, err)
139+
assert.Equal(t, "other", got.Counterpart)
140+
}
141+
142+
func TestStyleBuilderCounterpart(t *testing.T) {
143+
parent, err := NewStyleBuilder("parent").Counterpart("DarkOne").Build()
144+
assert.NoError(t, err)
145+
assert.Equal(t, "darkone", parent.Counterpart)
146+
147+
child, err := parent.Builder().Build()
148+
assert.NoError(t, err)
149+
assert.Equal(t, "darkone", child.Counterpart, "child should inherit parent's counterpart")
150+
151+
override, err := parent.Builder().Counterpart("OtherDark").Build()
152+
assert.NoError(t, err)
153+
assert.Equal(t, "otherdark", override.Counterpart)
154+
}
155+
106156
func TestStyleMarshaller(t *testing.T) {
107157
expected, err := NewStyle("test", StyleEntries{
108158
Whitespace: "bg:#ffffff",

styles/api.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,32 @@ func Get(name string) *chroma.Style {
6464
}
6565
return Fallback
6666
}
67+
68+
// GetForMode returns the named style if it already matches mode, otherwise its
69+
// registered counterpart if one exists and matches mode. If neither matches,
70+
// the originally-requested style is returned (or Fallback if the name is
71+
// unknown), so callers always get something usable.
72+
func GetForMode(name string, mode chroma.Mode) *chroma.Style {
73+
style := Get(name)
74+
if style.Mode() == mode {
75+
return style
76+
}
77+
if style.Counterpart == "" {
78+
return style
79+
}
80+
counterpart, ok := Registry[style.Counterpart]
81+
if !ok || counterpart.Mode() != mode {
82+
return style
83+
}
84+
return counterpart
85+
}
86+
87+
// RegisterPair links two styles as light/dark counterparts of each other.
88+
//
89+
// Both styles are also registered if they are not already present.
90+
func RegisterPair(a, b *chroma.Style) {
91+
Register(a)
92+
Register(b)
93+
a.Counterpart = strings.ToLower(b.Name)
94+
b.Counterpart = strings.ToLower(a.Name)
95+
}

styles/api_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,39 @@ func TestRegisterCaseInsensitivity(t *testing.T) {
4141
assert.Equal(t, custom, Get("CUSTOMSTYLE"))
4242
assert.Equal(t, custom, Get("CustomStyle"))
4343
}
44+
45+
func TestGetForMode(t *testing.T) {
46+
tests := []struct {
47+
name string
48+
req string
49+
mode chroma.Mode
50+
want string
51+
}{
52+
{"AlreadyDark", "github-dark", chroma.Dark, "github-dark"},
53+
{"AlreadyLight", "github", chroma.Light, "github"},
54+
{"FollowsCounterpartToDark", "github", chroma.Dark, "github-dark"},
55+
{"FollowsCounterpartToLight", "github-dark", chroma.Light, "github"},
56+
{"NoCounterpartReturnsOriginal", "swapoff", chroma.Light, "swapoff"},
57+
{"UnknownReturnsFallback", "no-such-style", chroma.Dark, Fallback.Name},
58+
}
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
got := GetForMode(tt.req, tt.mode)
62+
assert.Equal(t, tt.want, got.Name)
63+
})
64+
}
65+
}
66+
67+
func TestRegisterPair(t *testing.T) {
68+
light := chroma.MustNewStyle("PairLight", chroma.StyleEntries{
69+
chroma.Background: "bg:#ffffff",
70+
})
71+
dark := chroma.MustNewStyle("PairDark", chroma.StyleEntries{
72+
chroma.Background: "bg:#000000",
73+
})
74+
RegisterPair(light, dark)
75+
assert.Equal(t, "pairdark", light.Counterpart)
76+
assert.Equal(t, "pairlight", dark.Counterpart)
77+
assert.Equal(t, dark, GetForMode("PairLight", chroma.Dark))
78+
assert.Equal(t, light, GetForMode("PairDark", chroma.Light))
79+
}

styles/catppuccin-latte.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="catppuccin-latte">
1+
<style name="catppuccin-latte" counterpart="catppuccin-mocha">
22
<entry type="Background" style="bg:#eff1f5 #4c4f69"/>
33
<entry type="CodeLine" style="#4c4f69"/>
44
<entry type="Error" style="#d20f39"/>

styles/catppuccin-mocha.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="catppuccin-mocha">
1+
<style name="catppuccin-mocha" counterpart="catppuccin-latte">
22
<entry type="Background" style="bg:#1e1e2e #cdd6f4"/>
33
<entry type="CodeLine" style="#cdd6f4"/>
44
<entry type="Error" style="#f38ba8"/>

styles/github-dark.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="github-dark">
1+
<style name="github-dark" counterpart="github">
22
<entry type="Error" style="#f85149"/>
33
<entry type="LineHighlight" style="bg:#6e7681"/>
44
<entry type="LineNumbers" style="#6e7681"/>

styles/github.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="github">
1+
<style name="github" counterpart="github-dark">
22
<entry type="Error" style="#f6f8fa bg:#82071e"/>
33
<entry type="Background" style="bg:#f7f7f7"/>
44
<entry type="Keyword" style="#cf222e"/>

styles/gruvbox-light.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="gruvbox-light">
1+
<style name="gruvbox-light" counterpart="gruvbox">
22
<entry type="Background" style="noinherit #3c3836 bg:#fbf1c7"/>
33
<entry type="Keyword" style="noinherit #af3a03"/>
44
<entry type="KeywordType" style="noinherit #b57614"/>

styles/gruvbox.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<style name="gruvbox">
1+
<style name="gruvbox" counterpart="gruvbox-light">
22
<entry type="Background" style="noinherit #ebdbb2 bg:#282828"/>
33
<entry type="Keyword" style="noinherit #fe8019"/>
44
<entry type="KeywordType" style="noinherit #fabd2f"/>

0 commit comments

Comments
 (0)