Skip to content

Commit dcd1681

Browse files
authored
Add custom color theme support (#57)
* Add custom color theme support * fix hex regex casing * update README
1 parent 520effc commit dcd1681

8 files changed

Lines changed: 178 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ By default andcli will choose the first system clipboard tool found. For Linux,
5151

5252
The configuration will get persisted in the default user home config directory. For Linux, this is `$HOME/.config/andcli`. For MacOS, it's `$HOME/Library/Application Support/andcli` and for Windows it should be in `C:\Users\$USER\AppData\Roaming\andcli`.
5353

54+
## Theming
55+
56+
It's possible to adapt the application colors to your preferred color scheme. To do that, update the values in the config file found under `theme`. Each color has to be a valid hexadecimal string (i.e. `#cc0000`). If there are errors parsing your custom colors on application startup, default values will be used instead.
57+
5458
## Options
5559

5660
```text

‎internal/config/config.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type (
1919
Type vaults.Type `yaml:"type"`
2020
ClipboardCmd string `yaml:"clipboard_cmd"`
2121
Options *Opts `yaml:"options"`
22+
Theme *Theme `yaml:"theme"`
2223
//
2324
path string
2425
passwordFromStdin bool
@@ -53,6 +54,7 @@ func create(dir string) (*Config, error) {
5354
ShowUsernames: true,
5455
ShowTokens: false,
5556
},
57+
Theme: &DefaultTheme,
5658
}
5759

5860
if err := cfg.mergeExisting(); err != nil {
@@ -110,6 +112,11 @@ func (cfg *Config) mergeExisting() error {
110112
cfg.Options = existing.Options
111113
}
112114

115+
if existing.Theme != nil {
116+
cfg.Theme = existing.Theme
117+
cfg.Theme.validate()
118+
}
119+
113120
return nil
114121
}
115122

‎internal/config/config_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ func Test_create(t *testing.T) {
218218
ShowUsernames: true,
219219
ShowTokens: false,
220220
},
221-
path: filepath.Join(cfgDir, buildinfo.AppName, "config.yaml"),
221+
Theme: &DefaultTheme,
222+
path: filepath.Join(cfgDir, buildinfo.AppName, "config.yaml"),
222223
}
223224

224225
if !reflect.DeepEqual(cfg, want) {

‎internal/config/theme.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package config
2+
3+
import (
4+
"log"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
type Theme struct {
10+
Base string `yaml:"base"`
11+
Green string `yaml:"green"`
12+
Yellow string `yaml:"yellow"`
13+
Red string `yaml:"red"`
14+
Grey string `yaml:"grey"`
15+
Black string `yaml:"black"`
16+
White string `yaml:"white"`
17+
}
18+
19+
var DefaultTheme = Theme{
20+
Base: "#39A02E",
21+
Green: "#39A02E",
22+
Yellow: "#DB9F1F",
23+
Red: "#f10000",
24+
Grey: "#424242",
25+
Black: "#000000",
26+
White: "#FFFFFF",
27+
}
28+
29+
var hexRx = regexp.MustCompile(`^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$`)
30+
31+
func (t *Theme) validate() {
32+
invalid := []string{}
33+
34+
if !hexRx.MatchString(t.Base) {
35+
t.Base = DefaultTheme.Base
36+
invalid = append(invalid, "base")
37+
}
38+
39+
if !hexRx.MatchString(t.Green) {
40+
t.Green = DefaultTheme.Green
41+
invalid = append(invalid, "green")
42+
}
43+
44+
if !hexRx.MatchString(t.Yellow) {
45+
t.Yellow = DefaultTheme.Yellow
46+
invalid = append(invalid, "yellow")
47+
}
48+
49+
if !hexRx.MatchString(t.Red) {
50+
t.Red = DefaultTheme.Red
51+
invalid = append(invalid, "red")
52+
}
53+
54+
if !hexRx.MatchString(t.Grey) {
55+
t.Grey = DefaultTheme.Grey
56+
invalid = append(invalid, "grey")
57+
}
58+
59+
if !hexRx.MatchString(t.Black) {
60+
t.Black = DefaultTheme.Black
61+
invalid = append(invalid, "black")
62+
}
63+
64+
if !hexRx.MatchString(t.White) {
65+
t.White = DefaultTheme.White
66+
invalid = append(invalid, "white")
67+
}
68+
69+
if len(invalid) > 0 {
70+
msg := "Theme errors where found and substituted by fallbacks"
71+
log.Printf("andcli: config: %s. Invalid fields: %s\n", msg, strings.Join(invalid, ", "))
72+
}
73+
}

‎internal/config/theme_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package config
2+
3+
import (
4+
"bytes"
5+
"log"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestTheme_validate(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
theme *Theme
14+
expectedLogs string
15+
fails bool
16+
}{
17+
{
18+
"valid theme",
19+
&DefaultTheme,
20+
"",
21+
false,
22+
},
23+
{
24+
"invalid theme",
25+
&Theme{
26+
Base: "xxx",
27+
Green: "yyy",
28+
Yellow: "",
29+
Red: "#ff0000",
30+
White: "#fff",
31+
Grey: "§$%a",
32+
Black: "000",
33+
},
34+
"Invalid fields: base, green, yellow, grey",
35+
true,
36+
},
37+
}
38+
39+
buf := new(bytes.Buffer)
40+
log.SetOutput(buf)
41+
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
tt.theme.validate()
45+
if tt.fails && !strings.Contains(buf.String(), tt.expectedLogs) {
46+
t.Errorf("theme.validate(): want %q, got %q", tt.expectedLogs, buf.String())
47+
}
48+
})
49+
buf.Reset()
50+
}
51+
}

‎internal/model/delegate.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ import (
1010
"github.com/tjblackheart/andcli/v2/internal/vaults"
1111
)
1212

13-
type itemDelegate struct{ style *defaultStyle }
13+
type itemDelegate struct{ style *appStyle }
1414

1515
func (d itemDelegate) Height() int { return 1 }
1616
func (d itemDelegate) Spacing() int { return 0 }
1717
func (d itemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
1818

1919
func (d itemDelegate) Render(w io.Writer, m list.Model, idx int, li list.Item) {
20-
2120
entry, _ := li.(vaults.Entry)
2221
text := d.style.listItem.Render(entry.Title())
2322

‎internal/model/model.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
)
1818

1919
type (
20-
// tea.Model implementation
2120
Model struct {
2221
list list.Model
2322
style lipgloss.Style
@@ -33,14 +32,16 @@ type (
3332
)
3433

3534
var (
36-
style = newDefaultStyle()
35+
style = new(appStyle)
3736
state = new(appState)
3837
cb = new(clipboard.Clipboard)
39-
copyOK = ns().Foreground(green).Render(`✓`)
40-
copyErr = ns().Foreground(red).Render(`✕`)
38+
copyOK = lipgloss.NewStyle().Foreground(green).Render(`✓`)
39+
copyErr = lipgloss.NewStyle().Foreground(red).Render(`✕`)
4140
)
4241

4342
func New(entries []vaults.Entry, cfg *config.Config) Model {
43+
style = newThemedStyle(cfg.Theme)
44+
4445
state.showToken = cfg.Options.ShowTokens
4546
state.showUsernames = cfg.Options.ShowUsernames
4647

‎internal/model/style.go‎

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,52 @@
11
package model
22

3-
import "github.com/charmbracelet/lipgloss"
4-
5-
// TODO: configurable colors via config file
6-
var (
7-
base = lipgloss.Color("#39A02E")
8-
green = lipgloss.Color("#39A02E")
9-
yellow = lipgloss.Color("#DB9F1F")
10-
red = lipgloss.Color("#f10000")
11-
grey = lipgloss.Color("#424242")
12-
black = lipgloss.Color("#000000")
13-
white = lipgloss.Color("#ffffff")
3+
import (
4+
"github.com/charmbracelet/lipgloss"
5+
"github.com/tjblackheart/andcli/v2/internal/config"
146
)
157

16-
type defaultStyle struct {
8+
type appStyle struct {
179
title, listItem, activeItem lipgloss.Style
1810
username, filterCursor lipgloss.Style
1911
filterPrompt, token, until lipgloss.Style
2012
}
2113

22-
var ns = lipgloss.NewStyle
14+
var (
15+
base = lipgloss.Color(config.DefaultTheme.Base)
16+
green = lipgloss.Color(config.DefaultTheme.Green)
17+
yellow = lipgloss.Color(config.DefaultTheme.Yellow)
18+
red = lipgloss.Color(config.DefaultTheme.Red)
19+
grey = lipgloss.Color(config.DefaultTheme.Grey)
20+
black = lipgloss.Color(config.DefaultTheme.Black)
21+
white = lipgloss.Color(config.DefaultTheme.White)
22+
)
2323

24-
func newDefaultStyle() *defaultStyle {
25-
return &defaultStyle{
26-
title: ns().Background(base).Padding(0, 1),
27-
listItem: ns().PaddingLeft(2).Faint(true),
28-
username: ns().Background(grey),
29-
filterPrompt: ns().Foreground(base),
30-
filterCursor: ns().Background(base),
31-
token: ns().Bold(true).Padding(0, 1, 0, 1),
32-
until: ns().Bold(true),
33-
activeItem: ns().
24+
func newStyle() *appStyle {
25+
return &appStyle{
26+
title: lipgloss.NewStyle().Background(base).Padding(0, 1),
27+
listItem: lipgloss.NewStyle().PaddingLeft(2).Faint(true),
28+
username: lipgloss.NewStyle().Background(grey),
29+
filterPrompt: lipgloss.NewStyle().Foreground(base),
30+
filterCursor: lipgloss.NewStyle().Background(base),
31+
token: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 1),
32+
until: lipgloss.NewStyle().Bold(true),
33+
activeItem: lipgloss.NewStyle().
3434
Padding(0, 1).
3535
Bold(true).
3636
Background(grey).
3737
Border(lipgloss.ThickBorder(), false, false, false, true).
3838
Faint(false),
3939
}
4040
}
41+
42+
func newThemedStyle(theme *config.Theme) *appStyle {
43+
base = lipgloss.Color(theme.Base)
44+
green = lipgloss.Color(theme.Green)
45+
yellow = lipgloss.Color(theme.Yellow)
46+
red = lipgloss.Color(theme.Red)
47+
grey = lipgloss.Color(theme.Grey)
48+
black = lipgloss.Color(theme.Black)
49+
white = lipgloss.Color(theme.White)
50+
51+
return newStyle()
52+
}

0 commit comments

Comments
 (0)