Files
lazysecrets/internal/ui/styles/theme.go
T
kebandClaude Opus 5.5 5987787f38
Release / Build and publish (push) Successful in 20s
Hello world
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:03:13 +02:00

201 lines
6.3 KiB
Go

package styles
import (
"fmt"
"image/color"
"slices"
"sort"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// A theme maps roles to a colour and attributes, like lazygit's gui.theme:
//
// activeBorderColor: ["#e0af68", bold]
// selectedLineBgColor: ["#2d3f76"]
//
// Role names follow lazygit where the meaning is the same, so an editor
// plugin can map them from highlight groups the way snacks.nvim does for
// lazygit. Colours are hex (#rrggbb), a palette number (0-255) or a name
// (black, red, green, yellow, blue, magenta, cyan, white, their bright
// variants like brightred, or default). Attributes: bold, italic,
// underline, reverse, faint.
// Roles lists the theme roles with what they colour.
var Roles = map[string]string{
"activeBorderColor": "border and title of the focused pane",
"inactiveBorderColor": "borders of other panes",
"selectedLineBgColor": "background of the cursor row in the focused pane",
"inactiveViewSelectedLineBgColor": "background of the cursor row in other panes",
"defaultFgColor": "default text colour",
"optionsTextColor": "keys in the help bar and menus, and labels in the detail pane",
"searchMatchColor": "matched characters when filtering",
"errorColor": "✗ errors",
"noticeColor": "notices, the clipboard countdown and current versions",
"successColor": "the ✓ before confirmations (copied, opened…)",
"warningColor": "⚠ messages: what can't be done here, and why",
"mutedColor": "secondary text",
"favouriteColor": "stars and questions",
"valueColor": "revealed values",
"azureColor": "Azure sources and vaults",
"databricksColor": "Databricks workspaces and scopes",
"repoColor": "repos and their secret files (SOPS)",
}
// FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default()
var fg color.Color
roles := make([]string, 0, len(theme))
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok {
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
}
v, err := parse(theme[role])
if err != nil {
return st, nil, fmt.Errorf("theme: %s: %w", role, err)
}
switch role {
case "activeBorderColor":
st.BorderFocused = v.fg(lipgloss.NewStyle())
st.TitleFocused = v.fg(lipgloss.NewStyle()).Bold(true)
case "inactiveBorderColor":
st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
st.themed = true
case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = true
case "defaultFgColor":
fg = v.color
case "optionsTextColor":
st.Label = v.fg(lipgloss.NewStyle())
case "searchMatchColor":
st.Match = v.fg(lipgloss.NewStyle())
case "errorColor":
st.Error = v.fg(lipgloss.NewStyle())
case "noticeColor":
st.Notice = v.fg(lipgloss.NewStyle())
case "warningColor":
st.Warning = v.fg(lipgloss.NewStyle())
case "successColor":
st.Success = v.fg(lipgloss.NewStyle()).Bold(true)
case "mutedColor":
st.Muted = v.fg(lipgloss.NewStyle())
st.Masked, st.Footer = st.Muted, st.Muted
case "favouriteColor":
st.Favourite = v.fg(lipgloss.NewStyle())
st.Prompt = v.fg(lipgloss.NewStyle()).Bold(true)
case "valueColor":
st.Value = v.fg(lipgloss.NewStyle())
case "azureColor":
st.KindAzure = v.fg(lipgloss.NewStyle())
case "databricksColor":
st.KindDatabricks = v.fg(lipgloss.NewStyle())
case "repoColor":
st.KindRepo = v.fg(lipgloss.NewStyle())
}
}
return st, fg, nil
}
func sortedRoles() []string {
var out []string
for r := range Roles {
out = append(out, r)
}
slices.Sort(out)
return out
}
// value is a parsed role: a colour (nil for default) and attributes.
type value struct {
color color.Color
bold, italic, underline, reverse, faint bool
}
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil {
s = s.Foreground(v.color)
}
return v.attrs(s)
}
func (v value) bg(s lipgloss.Style) lipgloss.Style {
if v.color != nil {
s = s.Background(v.color)
}
return v.attrs(s)
}
func (v value) attrs(s lipgloss.Style) lipgloss.Style {
return s.Bold(v.bold).Italic(v.italic).Underline(v.underline).Reverse(v.reverse).Faint(v.faint)
}
var names = map[string]color.Color{
"black": lipgloss.Black, "red": lipgloss.Red, "green": lipgloss.Green, "yellow": lipgloss.Yellow,
"blue": lipgloss.Blue, "magenta": lipgloss.Magenta, "cyan": lipgloss.Cyan, "white": lipgloss.White,
"brightblack": lipgloss.BrightBlack, "brightred": lipgloss.BrightRed, "brightgreen": lipgloss.BrightGreen,
"brightyellow": lipgloss.BrightYellow, "brightblue": lipgloss.BrightBlue,
"brightmagenta": lipgloss.BrightMagenta, "brightcyan": lipgloss.BrightCyan, "brightwhite": lipgloss.BrightWhite,
}
func parse(items []string) (value, error) {
var v value
for _, it := range items {
s := strings.ToLower(strings.TrimSpace(it))
switch s {
case "bold":
v.bold = true
case "italic":
v.italic = true
case "underline":
v.underline = true
case "reverse":
v.reverse = true
case "faint", "dim":
v.faint = true
case "default", "":
// the terminal's own colour
default:
c, err := parseColor(s)
if err != nil {
return v, err
}
v.color = c
}
}
return v, nil
}
func parseColor(s string) (color.Color, error) {
if c, ok := names[s]; ok {
return c, nil
}
if strings.HasPrefix(s, "#") {
if len(s) != 7 {
return nil, fmt.Errorf("%q: want #rrggbb", s)
}
if _, err := strconv.ParseUint(s[1:], 16, 32); err != nil {
return nil, fmt.Errorf("%q: want #rrggbb", s)
}
return lipgloss.Color(s), nil
}
if n, err := strconv.Atoi(s); err == nil {
if n < 0 || n > 255 {
return nil, fmt.Errorf("%q: palette numbers are 0-255", s)
}
return lipgloss.Color(s), nil
}
return nil, fmt.Errorf("%q isn't a colour or attribute", s)
}