1package theme23import (4 "image/color"5 "reflect"67 "charm.land/lipgloss/v2"8 "github.com/charmbracelet/x/ansi"9)1011// barBlend is how far the menu and status bars move from the background12// toward the text color: enough to read as a band on any scheme, little13// enough that text on them keeps its contrast.14const barBlend = 0.121516// bandBlend is how far a table's banded rows move from the background17// toward the text color: a shade lighter than the bars, so a band reads18// as part of the grid rather than a bar.19const bandBlend = 0.072021// FromPalette draws the roles in a color scheme's colors. The roles are22// New's, on the ANSI slots; each slot becomes the scheme's color. Then,23// so every scheme reads well:24//25// - the selection uses the scheme's selection color when it has one;26// - the menu and status bars are bands of the background moved toward27// the text color, and the column header row is filled with the28// header color;29// - a background role too close to the screen's is moved apart;30// - text that falls short of its contrast minimum (4.5:1 for text, 3:131// for hints and lines, 2:1 for unavailable items) against its32// background is moved toward the scheme's text color just far enough33// to meet it, or toward black or white.34func FromPalette(p Palette) Theme {35 t := roles(p.Dark)36 t.Name, t.Palette = p.Name, &p37 if p.HighContrast {38 t.levels = aaa39 }40 text := readable(p.Foreground, p.Background, nil, t.levels.text)41 t.Screen = lipgloss.NewStyle().Background(p.Background).Foreground(text)42 eachRole(&t, func(_ string, s *lipgloss.Style) { *s = mapStyle(*s, &p) })43 if p.Selection != nil && contrast(p.Selection, p.Background) >= minDistinct {44 on := text45 if contrast(p.Background, p.Selection) > contrast(text, p.Selection) {46 on = p.Background47 }48 sel := lipgloss.NewStyle().Background(p.Selection).Foreground(on)49 t.Selection, t.HeaderSel = sel, sel50 }51 bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)52 t.MenuBarRow, t.StatusBarRow = bar, bar53 t.TableBand = lipgloss.NewStyle().Background(blend(p.Background, text, bandBlend)).Foreground(text)54 eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p, t.levels) })55 t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())56 t.standouts()57 return t58}5960// eachRole calls fn with every style role of t and its field name,61// including the chart series.62func eachRole(t *Theme, fn func(name string, s *lipgloss.Style)) {63 v := reflect.ValueOf(t).Elem()64 styleType := reflect.TypeOf(lipgloss.Style{})65 for i := range v.NumField() {66 f, name := v.Field(i), v.Type().Field(i).Name67 switch {68 case f.Type() == styleType:69 fn(name, f.Addr().Interface().(*lipgloss.Style))70 case f.Kind() == reflect.Array && f.Type().Elem() == styleType:71 for j := range f.Len() {72 fn(name, f.Index(j).Addr().Interface().(*lipgloss.Style))73 }74 }75 }76}7778// mapStyle replaces ANSI colors with the scheme's.79func mapStyle(s lipgloss.Style, p *Palette) lipgloss.Style {80 if c, ok := slot(s.GetForeground(), p); ok {81 s = s.Foreground(c)82 }83 if c, ok := slot(s.GetBackground(), p); ok {84 s = s.Background(c)85 }86 if c, ok := slot(s.GetUnderlineColor(), p); ok {87 s = s.UnderlineColor(c)88 }89 return s90}9192// slot returns the scheme's color for an ANSI color.93func slot(c color.Color, p *Palette) (color.Color, bool) {94 switch c := c.(type) {95 case ansi.BasicColor:96 return p.ANSI[c%16], true97 case ansi.IndexedColor:98 if c < 16 {99 return p.ANSI[c], true100 }101 }102 return nil, false103}104105// levels are the contrast minimums a theme's roles are held to.106type levels struct {107 text float64 // cell text, bars, menus, chips: every other role108 secondary float64 // hints, muted text, marks and chart labels109 lines float64 // borders, rules, chart axes and swatches, progress110 disabled float64 // unavailable items111}112113var (114 // aa is WCAG AA, every scheme's floor.115 aa = levels{text: minText, secondary: minSecondary, lines: minSecondary, disabled: minDisabled}116 // aaa is WCAG AAA, the high-contrast schemes': 7:1 for all text,117 // hints included, and 4.5:1 for lines and unavailable items.118 aaa = levels{text: minTextAAA, secondary: minTextAAA, lines: minText, disabled: minText}119)120121// minContrast is the contrast minimum for a role's text.122func minContrast(role string, lv levels) float64 {123 switch role {124 case "Disabled":125 return lv.disabled126 case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark", "CellHead", "Stale", "PeerMark", "Suggested":127 return lv.secondary128 case "Border", "FrozenLine", "ChartFrame", "ChartAxis", "Progress", "ProgressTodo", "Copied", "Series", "CellBorder", "CellBar", "CellBarEdit":129 return lv.lines130 }131 return lv.text132}133134// fixContrast keeps a role's background apart from the screen and its135// text readable on it.136func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette, lv levels) lipgloss.Style {137 if role == "Screen" {138 return s139 }140 bg := p.Background141 if c := s.GetBackground(); !isNoColor(c) {142 bg = c143 if role != "SeriesBg" {144 bg = distinct(c, p.Background, text)145 s = s.Background(bg)146 }147 }148 if c := s.GetForeground(); !isNoColor(c) && role != "SeriesBg" {149 s = s.Foreground(readable(c, bg, text, minContrast(role, lv)))150 } else if !isNoColor(s.GetBackground()) && role != "SeriesBg" {151 // Text on a background role with no color of its own is drawn in152 // the screen's text color.153 s = s.Foreground(readable(text, bg, nil, minContrast(role, lv)))154 }155 return s156}157158func isNoColor(c color.Color) bool {159 _, none := c.(lipgloss.NoColor)160 return c == nil || none161}162163// Resolve returns the theme called name: the terminal theme's or the164// high-contrast scheme's variant for a dark or light terminal, or a165// scheme found by Lookup in dir. An unknown scheme gives the terminal166// theme and the error.167func Resolve(name string, dark bool, dir string) (Theme, error) {168 if name == "" || name == Terminal {169 return New(dark), nil170 }171 if isHighContrast(name) {172 t := FromPalette(highContrastFor(dark))173 t.Name = HighContrast174 return t, nil175 }176 p, err := Lookup(name, dir)177 if err != nil {178 return New(dark), err179 }180 return FromPalette(p), nil181}