Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,38 @@ Open the TUI:
t
```

### Configuration

Themes can now adapt to both light and dark terminals. By default `t` uses
`mode: "auto"` and switches palettes based on the detected background colour.
You can force either palette or customise the colours by editing `config.json`
in your config directory (typically `~/.config/t/config.json`):

```json
{
"theme": {
"mode": "auto",
"dark": {
"text": "#FFFFFF",
"muted": "#696969",
"highlight": "#58C5C7",
"success": "#99CC00",
"worry": "#FF7676"
},
"light": {
"text": "#121417",
"muted": "#61646B",
"highlight": "#205CBE",
"success": "#007A3B",
"worry": "#C62828"
}
}
}
```

Set `"mode": "dark"` or `"mode": "light"` to lock the palette regardless of
background detection.

### Development and testing

#### Requirements
Expand Down
40 changes: 30 additions & 10 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ func TestLoadFromDirReadsConfigFile(t *testing.T) {
dir := t.TempDir()
content := []byte(`{
"theme": {
"text": "#010101",
"muted": "#020202",
"highlight": "#030303",
"success": "#040404",
"worry": "#050505"
"mode": "auto",
"dark": {
"text": "#111111",
"muted": "#222222",
"highlight": "#333333",
"success": "#444444",
"worry": "#555555"
},
"light": {
"text": "#aaaaaa",
"muted": "#bbbbbb",
"highlight": "#cccccc",
"success": "#dddddd",
"worry": "#eeeeee"
}
}
}`)

Expand All @@ -46,11 +56,21 @@ func TestLoadFromDirReadsConfigFile(t *testing.T) {
}

want := theme.Config{
Text: "#010101",
Muted: "#020202",
Highlight: "#030303",
Success: "#040404",
Worry: "#050505",
Mode: theme.ModeAuto,
Dark: theme.PaletteConfig{
Text: "#111111",
Muted: "#222222",
Highlight: "#333333",
Success: "#444444",
Worry: "#555555",
},
Light: theme.PaletteConfig{
Text: "#aaaaaa",
Muted: "#bbbbbb",
Highlight: "#cccccc",
Success: "#dddddd",
Worry: "#eeeeee",
},
}

if cfg.Theme != want {
Expand Down
198 changes: 162 additions & 36 deletions internal/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,27 @@ import (
"github.com/charmbracelet/lipgloss"
)

// Mode determines which palette should be active.
type Mode string

const (
// ModeAuto chooses the palette based on the terminal background.
ModeAuto Mode = "auto"
// ModeDark always uses the dark palette.
ModeDark Mode = "dark"
// ModeLight always uses the light palette.
ModeLight Mode = "light"
)

// Config represents the raw theme configuration values.
type Config struct {
Mode Mode `json:"mode"`
Light PaletteConfig `json:"light"`
Dark PaletteConfig `json:"dark"`
}

// PaletteConfig describes a single colour palette.
type PaletteConfig struct {
Text string `json:"text"`
Muted string `json:"muted"`
Highlight string `json:"highlight"`
Expand All @@ -24,6 +43,15 @@ type Config struct {
// DefaultConfig returns the built-in theme configuration.
func DefaultConfig() Config {
return Config{
Mode: ModeAuto,
Light: DefaultLightPalette(),
Dark: DefaultDarkPalette(),
}
}

// DefaultDarkPalette returns the built-in palette optimised for dark backgrounds.
func DefaultDarkPalette() PaletteConfig {
return PaletteConfig{
Text: "#FFFFFF",
Muted: "#696969",
Highlight: "#58C5C7",
Expand All @@ -32,6 +60,123 @@ func DefaultConfig() Config {
}
}

// DefaultLightPalette returns the built-in palette optimised for light backgrounds.
func DefaultLightPalette() PaletteConfig {
return PaletteConfig{
Text: "#121417",
Muted: "#61646B",
Highlight: "#205CBE",
Success: "#007A3B",
Worry: "#C62828",
}
}

func (cfg *Config) withDefaults() Config {
if cfg == nil {
return DefaultConfig()
}

out := *cfg
def := DefaultConfig()

if strings.TrimSpace(string(out.Mode)) == "" {
out.Mode = def.Mode
}

out.Light = out.Light.withDefaults(def.Light)
out.Dark = out.Dark.withDefaults(def.Dark)

return out
}

func (pc *PaletteConfig) withDefaults(fallback PaletteConfig) PaletteConfig {
if pc == nil {
return fallback
}

out := *pc

if strings.TrimSpace(out.Text) == "" {
out.Text = fallback.Text
}
if strings.TrimSpace(out.Muted) == "" {
out.Muted = fallback.Muted
}
if strings.TrimSpace(out.Highlight) == "" {
out.Highlight = fallback.Highlight
}
if strings.TrimSpace(out.Success) == "" {
out.Success = fallback.Success
}
if strings.TrimSpace(out.Worry) == "" {
out.Worry = fallback.Worry
}

return out
}

func (cfg *Config) paletteForMode(hasDarkBackground bool) (PaletteConfig, error) {
if cfg == nil {
def := DefaultConfig()
return def.paletteForMode(hasDarkBackground)
}

mode := strings.ToLower(strings.TrimSpace(string(cfg.Mode)))
switch mode {
case "", string(ModeAuto):
if hasDarkBackground {
return cfg.Dark, nil
}
return cfg.Light, nil
case string(ModeDark):
return cfg.Dark, nil
case string(ModeLight):
return cfg.Light, nil
default:
return PaletteConfig{}, fmt.Errorf("unknown theme mode %q", cfg.Mode)
}
}

func (pc *PaletteConfig) toTheme() (Theme, error) {
if pc == nil {
return Theme{}, fmt.Errorf("palette configuration cannot be nil")
}

text, err := parsePaletteColor(pc.Text)
if err != nil {
return Theme{}, fmt.Errorf("parse text colour: %w", err)
}

muted, err := parsePaletteColor(pc.Muted)
if err != nil {
return Theme{}, fmt.Errorf("parse muted colour: %w", err)
}

highlight, err := parsePaletteColor(pc.Highlight)
if err != nil {
return Theme{}, fmt.Errorf("parse highlight colour: %w", err)
}

success, err := parsePaletteColor(pc.Success)
if err != nil {
return Theme{}, fmt.Errorf("parse success colour: %w", err)
}

worry, err := parsePaletteColor(pc.Worry)
if err != nil {
return Theme{}, fmt.Errorf("parse worry colour: %w", err)
}

return Theme{
Text: text,
Muted: muted,
Highlight: highlight,
Success: success,
Worry: worry,
CursorChar: "❯",
}, nil
}

// Theme defines the theme for the TUI and CLI help docs.
type Theme struct {
Text PaletteColor
Expand All @@ -51,23 +196,29 @@ type PaletteColor struct {
}

// LipGloss converts the color to a lipgloss.Color.
func (c PaletteColor) LipGloss() lipgloss.Color {
func (c *PaletteColor) LipGloss() lipgloss.Color {
if c == nil {
return lipgloss.Color("")
}
return lipgloss.Color(c.raw)
}

// RGBA exposes an image/color compliant representation of the color.
func (c PaletteColor) RGBA() color.RGBA {
func (c *PaletteColor) RGBA() color.RGBA {
if c == nil {
return color.RGBA{}
}
return c.rgba
}

// Default returns the default theme.
// Default returns the default theme optimised for dark backgrounds.
func Default() Theme {
return MustFromConfig(DefaultConfig())
return MustFromConfig(DefaultConfig(), true)
}

// MustFromConfig creates a Theme from a Config and panics if parsing fails.
func MustFromConfig(cfg Config) Theme {
t, err := FromConfig(cfg)
func MustFromConfig(cfg Config, hasDarkBackground bool) Theme {
t, err := FromConfig(cfg, hasDarkBackground)
if err != nil {
panic(fmt.Sprintf("invalid theme configuration: %v", err))
}
Expand All @@ -76,40 +227,15 @@ func MustFromConfig(cfg Config) Theme {
}

// FromConfig converts a Config into a ready-to-use Theme.
func FromConfig(cfg Config) (Theme, error) {
text, err := parsePaletteColor(cfg.Text)
if err != nil {
return Theme{}, fmt.Errorf("parse text colour: %w", err)
}

muted, err := parsePaletteColor(cfg.Muted)
if err != nil {
return Theme{}, fmt.Errorf("parse muted colour: %w", err)
}
func FromConfig(cfg Config, hasDarkBackground bool) (Theme, error) {
cfg = cfg.withDefaults()

highlight, err := parsePaletteColor(cfg.Highlight)
palette, err := cfg.paletteForMode(hasDarkBackground)
if err != nil {
return Theme{}, fmt.Errorf("parse highlight colour: %w", err)
}

success, err := parsePaletteColor(cfg.Success)
if err != nil {
return Theme{}, fmt.Errorf("parse success colour: %w", err)
return Theme{}, err
}

worry, err := parsePaletteColor(cfg.Worry)
if err != nil {
return Theme{}, fmt.Errorf("parse worry colour: %w", err)
}

return Theme{
Text: text,
Muted: muted,
Highlight: highlight,
Success: success,
Worry: worry,
CursorChar: "❯",
}, nil
return palette.toTheme()
}

func parsePaletteColor(input string) (PaletteColor, error) {
Expand Down
44 changes: 38 additions & 6 deletions internal/theme/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,50 @@ func TestParsePaletteColorHex(t *testing.T) {

func TestFromConfigInvalid(t *testing.T) {
cfg := Config{
Text: "bogus",
Muted: "#333",
Highlight: "#58C5C7",
Success: "#99CC00",
Worry: "#ff7676",
Mode: ModeDark,
Dark: PaletteConfig{
Text: "bogus",
Muted: "#333333",
Highlight: "#58C5C7",
Success: "#99CC00",
Worry: "#FF7676",
},
}

if _, err := FromConfig(cfg); err == nil {
if _, err := FromConfig(cfg, true); err == nil {
t.Fatal("expected FromConfig to error for invalid colour")
}
}

func TestFromConfigAutoUsesLightPalette(t *testing.T) {
cfg := Config{
Mode: ModeAuto,
Dark: PaletteConfig{
Text: "#010101",
Muted: "#020202",
Highlight: "#030303",
Success: "#040404",
Worry: "#050505",
},
Light: PaletteConfig{
Text: "#A0A0A0",
Muted: "#A1A1A1",
Highlight: "#A2A2A2",
Success: "#A3A3A3",
Worry: "#A4A4A4",
},
}

th, err := FromConfig(cfg, false)
if err != nil {
t.Fatalf("FromConfig() error = %v", err)
}

if got := th.Text.LipGloss(); got != "#A0A0A0" {
t.Fatalf("expected light palette text #A0A0A0, got %s", got)
}
}

func TestDefaultTheme(t *testing.T) {
th := Default()

Expand Down
Loading
Loading