cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / theme / palette.go
palette.go 11.4K
1package theme23import (4 _ "embed"5 "encoding/json"6 "errors"7 "fmt"8 "image/color"9 "os"10 "path/filepath"11 "slices"12 "strconv"13 "strings"14 "sync"15 "unicode"16)1718// A Palette is a terminal color scheme, the kind every terminal ships19// and Ghostty, kitty, iTerm2 and VHS share: the 16 ANSI colors, the20// background and foreground, and optionally the selection color. 012's21// roles are defined on these slots (see New), so any scheme works.22type Palette struct {23 Name string24 ANSI [16]color.Color25 Background color.Color26 Foreground color.Color27 Selection color.Color // may be nil28 Dark bool29 File string // the file it came from; empty for built-ins30 // HighContrast holds every role to WCAG AAA (7:1 for text) instead31 // of AA: the built-in high-contrast schemes.32 HighContrast bool33}3435// Terminal is the theme name that keeps the terminal's own palette.36const Terminal = "terminal"3738// themesJSON is VHS's theme catalog (MIT, Charmbracelet; see NOTICE),39// so names match VHS's Set Theme.40//41//go:embed themes.json42var themesJSON []byte4344// classic is 1-2-3's DOS look: CGA colors on blue.45const classic = `# 1-2-3 Classic: CGA colors on a blue screen.46background = #0000aa47foreground = #ffffff48selection-background = #00aaaa49palette = 0=#00000050palette = 1=#aa000051palette = 2=#00aa0052palette = 3=#aa550053palette = 4=#0000aa54palette = 5=#aa00aa55palette = 6=#00aaaa56palette = 7=#aaaaaa57palette = 8=#55555558palette = 9=#ff555559palette = 10=#55ff5560palette = 11=#ffff5561palette = 12=#5555ff62palette = 13=#ff55ff63palette = 14=#55ffff64palette = 15=#ffffff65`6667// ClassicName is the built-in 1-2-3 scheme.68const ClassicName = "1-2-3 Classic"6970var builtins = sync.OnceValue(func() []Palette {71 ps, err := parseJSON(themesJSON)72 if err != nil {73 panic("theme: embedded themes.json: " + err.Error())74 }75 c, _, err := parseText(classic)76 if err != nil {77 panic("theme: classic: " + err.Error())78 }79 c.Name = ClassicName80 return append(append([]Palette{c}, highContrastPalettes()...), ps...)81})8283// Builtins returns the built-in schemes.84func Builtins() []Palette { return builtins() }8586// Entry is a theme a picker can offer.87type Entry struct {88 Name string89 Dark bool90 User bool // a file in the themes directory91}9293// List returns the themes available: terminal and high-contrast first,94// then the files in dir (the user's themes directory, may be ""), then95// the built-ins, each group sorted by name case-insensitively. A file96// with a built-in's name hides the built-in.97func List(dir string) []Entry {98 out := []Entry{{Name: Terminal, Dark: true}, {Name: HighContrast, Dark: true}}99 seen := map[string]bool{}100 var user []Entry101 for _, p := range userPalettes(dir) {102 if key := strings.ToLower(p.Name); !seen[key] {103 seen[key] = true104 user = append(user, Entry{Name: p.Name, Dark: p.Dark, User: true})105 }106 }107 sortEntries(user)108 var bi []Entry109 for _, p := range Builtins() {110 if !seen[strings.ToLower(p.Name)] {111 bi = append(bi, Entry{Name: p.Name, Dark: p.Dark})112 }113 }114 sortEntries(bi)115 return append(append(out, user...), bi...)116}117118func sortEntries(es []Entry) {119 slices.SortStableFunc(es, func(a, b Entry) int {120 return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))121 })122}123124// userPalettes reads every theme file in dir, skipping ones that don't125// parse.126func userPalettes(dir string) []Palette {127 if dir == "" {128 return nil129 }130 files, err := os.ReadDir(dir)131 if err != nil {132 return nil133 }134 var out []Palette135 for _, f := range files {136 if f.IsDir() || strings.HasPrefix(f.Name(), ".") {137 continue138 }139 ps, err := ReadFile(filepath.Join(dir, f.Name()))140 if err == nil {141 out = append(out, ps...)142 }143 }144 return out145}146147// Lookup finds a scheme by name: a file in dir first (named exactly, or148// with .json or .conf), then a built-in. Names match exactly, then149// case-insensitively, then ignoring spaces, hyphens and underscores, so150// "tokyo-night" finds "TokyoNight" and "tokyonight" the scheme of that151// name.152func Lookup(name, dir string) (Palette, error) {153 if dir != "" && !strings.ContainsAny(name, `/\`) && name != "." && name != ".." {154 for _, file := range []string{name, name + ".json", name + ".conf"} {155 if ps, err := ReadFile(filepath.Join(dir, file)); err == nil {156 return pick(ps, name), nil157 } else if !errors.Is(err, os.ErrNotExist) {158 return Palette{}, err159 }160 }161 }162 all := append(userPalettes(dir), Builtins()...)163 same := func(s string) string { return s }164 for _, match := range []func(string) string{same, strings.ToLower, squash} {165 for _, p := range all {166 if match(p.Name) == match(name) {167 return p, nil168 }169 }170 }171 return Palette{}, fmt.Errorf("no theme %q (012 config themes lists them)", name)172}173174// pick returns the scheme called name from a file's schemes, or the175// first.176func pick(ps []Palette, name string) Palette {177 for _, p := range ps {178 if strings.EqualFold(p.Name, name) {179 return p180 }181 }182 return ps[0]183}184185func squash(s string) string {186 return strings.Map(func(r rune) rune {187 if r == ' ' || r == '-' || r == '_' {188 return -1189 }190 return unicode.ToLower(r)191 }, s)192}193194// ReadFile reads a theme file: Ghostty's format (palette = N=#rrggbb,195// background, foreground, selection-background), kitty's (color0 ...196// color15, background, foreground, selection_background), or VHS's JSON197// (one scheme or a list). A scheme without a name is named after the198// file.199func ReadFile(path string) ([]Palette, error) {200 data, err := os.ReadFile(path)201 if err != nil {202 return nil, err203 }204 var ps []Palette205 if t := strings.TrimSpace(string(data)); strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {206 ps, err = parseJSON(data)207 } else {208 var p Palette209 p, _, err = parseText(string(data))210 ps = []Palette{p}211 }212 if err != nil {213 return nil, fmt.Errorf("%s: %w", path, err)214 }215 for i := range ps {216 ps[i].File = path217 if ps[i].Name == "" {218 ps[i].Name = strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), ".json"), ".conf")219 }220 }221 return ps, nil222}223224// vhsTheme is a scheme in VHS's themes.json. Puzzletea's spellings225// (purple, cursorColor, selectionBackground) are accepted too.226type vhsTheme struct {227 Name string228 Black, Red, Green, Yellow, Blue, Magenta, Cyan, White string229 BrightBlack, BrightRed, BrightGreen, BrightYellow string230 BrightBlue, BrightMagenta, BrightCyan, BrightWhite string231 Purple, BrightPurple string232 Background, Foreground, Selection string233 SelectionBackground string234 Meta struct {235 IsDark *bool `json:"isDark"`236 }237}238239func parseJSON(data []byte) ([]Palette, error) {240 var list []vhsTheme241 if strings.HasPrefix(strings.TrimSpace(string(data)), "{") {242 var one vhsTheme243 if err := json.Unmarshal(data, &one); err != nil {244 return nil, err245 }246 list = []vhsTheme{one}247 } else if err := json.Unmarshal(data, &list); err != nil {248 return nil, err249 }250 out := make([]Palette, 0, len(list))251 for _, t := range list {252 p, err := t.palette()253 if err != nil {254 return nil, fmt.Errorf("%s: %w", t.Name, err)255 }256 out = append(out, p)257 }258 if len(out) == 0 {259 return nil, errors.New("no themes")260 }261 return out, nil262}263264func (t vhsTheme) palette() (Palette, error) {265 or := func(a, b string) string {266 if a != "" {267 return a268 }269 return b270 }271 slots := [16]string{t.Black, t.Red, t.Green, t.Yellow, t.Blue, or(t.Magenta, t.Purple), t.Cyan, t.White,272 t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue, or(t.BrightMagenta, t.BrightPurple), t.BrightCyan, t.BrightWhite}273 p := Palette{Name: t.Name}274 var err error275 for i, s := range slots {276 if p.ANSI[i], err = parseHex(s); err != nil {277 return p, fmt.Errorf("color %d: %w", i, err)278 }279 }280 if p.Background, err = parseHex(t.Background); err != nil {281 return p, fmt.Errorf("background: %w", err)282 }283 if p.Foreground, err = parseHex(t.Foreground); err != nil {284 return p, fmt.Errorf("foreground: %w", err)285 }286 if sel := or(t.Selection, t.SelectionBackground); sel != "" {287 p.Selection, _ = parseHex(sel)288 }289 p.Dark = isDark(p.Background)290 if t.Meta.IsDark != nil {291 p.Dark = *t.Meta.IsDark292 }293 return p, nil294}295296// parseText parses Ghostty's or kitty's format. Unknown keys (cursor297// colors, fonts) are ignored; they're reported in the warnings.298func parseText(s string) (Palette, []string, error) {299 var p Palette300 var warnings []string301 set := map[string]bool{}302 for n, line := range strings.Split(s, "\n") {303 line = strings.TrimSpace(line)304 if line == "" || strings.HasPrefix(line, "#") {305 continue306 }307 key, val, ok := strings.Cut(line, "=")308 if !ok || strings.ContainsAny(strings.TrimSpace(key), " \t") {309 key, val, ok = strings.Cut(line, " ") // kitty: "color0 #000000"310 }311 key, val = strings.TrimSpace(key), strings.TrimSpace(val)312 if !ok {313 warnings = append(warnings, fmt.Sprintf("line %d: expected key = value", n+1))314 continue315 }316 if err := p.setKey(key, val, set); err != nil {317 warnings = append(warnings, fmt.Sprintf("line %d: %v", n+1, err))318 }319 }320 if !set["background"] || !set["foreground"] {321 return p, warnings, errors.New("a theme needs background and foreground")322 }323 for i := range p.ANSI {324 if p.ANSI[i] == nil {325 p.ANSI[i] = xterm[i]326 }327 }328 p.Dark = isDark(p.Background)329 return p, warnings, nil330}331332// setKey applies one line of a Ghostty or kitty theme.333func (p *Palette) setKey(key, val string, set map[string]bool) error {334 slot := -1335 switch {336 case key == "palette": // Ghostty: palette = 4=#81a2be337 i, c, ok := strings.Cut(val, "=")338 if !ok {339 return fmt.Errorf("palette %q: expected N=#rrggbb", val)340 }341 slot, val = atoiOr(strings.TrimSpace(i), -1), strings.TrimSpace(c)342 case strings.HasPrefix(key, "color"): // kitty: color4 #81a2be343 if slot = atoiOr(strings.TrimPrefix(key, "color"), -1); slot < 0 {344 return nil345 }346 case key != "background" && key != "foreground" && key != "selection-background" && key != "selection_background":347 return nil // cursor, selection-foreground and the like: not used348 }349 if slot >= 16 {350 return nil // the 256-color cube isn't used351 }352 c, err := parseHex(val)353 if err != nil {354 return fmt.Errorf("%s: %w", key, err)355 }356 switch {357 case slot >= 0:358 p.ANSI[slot] = c359 case key == "background":360 p.Background = c361 case key == "foreground":362 p.Foreground = c363 default:364 p.Selection = c365 }366 set[key] = true367 return nil368}369370func atoiOr(s string, def int) int {371 n, err := strconv.Atoi(s)372 if err != nil || n < 0 {373 return def374 }375 return n376}377378// parseHex reads #rrggbb, rrggbb or #rgb.379func parseHex(s string) (color.Color, error) {380 h := strings.TrimPrefix(strings.TrimSpace(s), "#")381 if len(h) == 3 {382 h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})383 }384 v, err := strconv.ParseUint(h, 16, 32)385 if len(h) != 6 || err != nil {386 return nil, fmt.Errorf("%q isn't a #rrggbb color", s)387 }388 return color.RGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}, nil389}390391// xterm is the palette used for slots a theme file leaves out.392var xterm = [16]color.Color{393 color.RGBA{0, 0, 0, 255}, color.RGBA{205, 0, 0, 255}, color.RGBA{0, 205, 0, 255}, color.RGBA{205, 205, 0, 255},394 color.RGBA{0, 0, 238, 255}, color.RGBA{205, 0, 205, 255}, color.RGBA{0, 205, 205, 255}, color.RGBA{229, 229, 229, 255},395 color.RGBA{127, 127, 127, 255}, color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{255, 255, 0, 255},396 color.RGBA{92, 92, 255, 255}, color.RGBA{255, 0, 255, 255}, color.RGBA{0, 255, 255, 255}, color.RGBA{255, 255, 255, 255},397}398399func isDark(bg color.Color) bool {400 return contrast(bg, color.White) > contrast(bg, color.Black)401}