// Package theme turns config.Theme (the YAML-facing schema) into concrete // lipgloss.Style values the TUI renders with. package theme import ( "image/color" "os" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/textinput" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/term" "git.morlana.online/f.weber/vault-tui/internal/config" ) // Styles is the materialised set of lipgloss styles and colors the whole // TUI renders with. Built once at startup from config.Theme plus a // light/dark decision, and passed down by value (styles are cheap, // immutable value types) to every screen. type Styles struct { // Raw colors, exposed for the few call sites that need to compose a // style lipgloss.Style alone can't express (e.g. list.Styles). Bg color.Color Surface color.Color SurfaceRaised color.Color Overlay color.Color Text color.Color TextMuted color.Color TextFaint color.Color TextInverted color.Color Border color.Color BorderSubtle color.Color BorderActive color.Color Header color.Color Selection color.Color SelectionBg color.Color Accent color.Color Error color.Color Warning color.Color Success color.Color Info color.Color Muted color.Color Masked color.Color Directory color.Color Leaf color.Color MaskChar string Mono bool // true when colors are suppressed (--no-color / NO_COLOR) // Chrome HeaderBar lipgloss.Style AppBadge lipgloss.Style Crumb lipgloss.Style CrumbSep lipgloss.Style CrumbActive lipgloss.Style Pill lipgloss.Style PillOK lipgloss.Style PillWarn lipgloss.Style PillDanger lipgloss.Style Footer lipgloss.Style StatusBar lipgloss.Style ReadOnly lipgloss.Style // Panels Panel lipgloss.Style PanelActive lipgloss.Style PanelTitle lipgloss.Style PanelSubtle lipgloss.Style SidePanel lipgloss.Style EmptyState lipgloss.Style // Rows / tables Row lipgloss.Style RowSelected lipgloss.Style RowDim lipgloss.Style Badge lipgloss.Style TableHeader lipgloss.Style KeyCell lipgloss.Style ValueCell lipgloss.Style // Text roles Title lipgloss.Style Help lipgloss.Style ErrorText lipgloss.Style WarnText lipgloss.Style SuccessText lipgloss.Style MaskedText lipgloss.Style DirText lipgloss.Style LeafText lipgloss.Style Selected lipgloss.Style // Overlays Modal lipgloss.Style ModalDanger lipgloss.Style ModalTitle lipgloss.Style Backdrop lipgloss.Style // Toasts ToastInfo lipgloss.Style ToastSuccess lipgloss.Style ToastWarn lipgloss.Style ToastError lipgloss.Style // Inputs InputFocused lipgloss.Style InputBlurred lipgloss.Style isDark bool } // defaultPalette is the built-in fallback: an adaptive light/dark set for // every named color config.Theme.Colors can override. func defaultPalette(dark bool) map[string]string { if dark { return map[string]string{ "bg": "#0E1015", "surface": "#151821", "surface_raised": "#1C2130", "overlay": "#20263A", "text": "#E4E7F1", "text_muted": "#9AA2B8", "text_faint": "#5B6272", "text_inverted": "#0E1015", "border": "#2A3040", "border_subtle": "#1C2130", "border_active": "#A78BFA", "header": "#9BA3C4", "selection": "#0E1015", "selection_bg": "#A78BFA", "accent": "#A78BFA", "error": "#F87171", "warning": "#FBBF24", "success": "#4ADE80", "info": "#6EC1FF", "muted": "#9AA2B8", "masked": "#5B6272", "directory": "#6EC1FF", "leaf": "#E4E7F1", } } return map[string]string{ "bg": "#FFFFFF", "surface": "#F6F7FB", "surface_raised": "#EDEFF7", "overlay": "#E3E6F3", "text": "#1F2430", "text_muted": "#6C7086", "text_faint": "#9AA0AE", "text_inverted": "#FFFFFF", "border": "#C9CCD6", "border_subtle": "#DEE1EA", "border_active": "#5B34D6", "header": "#3A3F58", "selection": "#FFFFFF", "selection_bg": "#5B34D6", "accent": "#5B34D6", "error": "#B3261E", "warning": "#8A6100", "success": "#1B6B3A", "info": "#0B5FA5", "muted": "#6C7086", "masked": "#9AA0AE", "directory": "#0B5FA5", "leaf": "#1F2430", } } // IsDark decides light vs. dark once at startup, synchronously — a // deliberate simplification versus wiring bubbletea's async // BackgroundColorMsg round trip, since the terminal's background rarely // changes mid-session. cfg.Appearance can force it. func IsDark(cfg config.Theme, appearance string) bool { switch appearance { case "dark": return true case "light": return false default: return lipgloss.HasDarkBackground(term.File(os.Stdin), term.File(os.Stdout)) } } // NoColor reports whether ANSI color should be suppressed: the NO_COLOR // convention (https://no-color.org) or an explicit request. func NoColor(explicit bool) bool { if explicit { return true } _, set := os.LookupEnv("NO_COLOR") return set } func colorOf(cfg config.Theme, defaults map[string]string, dark bool, key string) color.Color { if c, ok := cfg.Colors[key]; ok { if dark && c.Dark != "" { return lipgloss.Color(c.Dark) } if !dark && c.Light != "" { return lipgloss.Color(c.Light) } } return lipgloss.Color(defaults[key]) } // Build materialises Styles from cfg and the light/dark decision. When mono // is true (NO_COLOR / --no-color), every color collapses to the terminal's // default foreground and hierarchy is carried by weight/reverse instead. func Build(cfg config.Theme, dark bool, mono bool) *Styles { def := defaultPalette(dark) c := func(key string) color.Color { return colorOf(cfg, def, dark, key) } s := &Styles{isDark: dark, Mono: mono} s.Bg, s.Surface, s.SurfaceRaised, s.Overlay = c("bg"), c("surface"), c("surface_raised"), c("overlay") s.Text, s.TextMuted, s.TextFaint, s.TextInverted = c("text"), c("text_muted"), c("text_faint"), c("text_inverted") s.Border, s.BorderSubtle, s.BorderActive, s.Header = c("border"), c("border_subtle"), c("border_active"), c("header") s.Selection, s.SelectionBg, s.Accent = c("selection"), c("selection_bg"), c("accent") s.Error, s.Warning, s.Success, s.Info = c("error"), c("warning"), c("success"), c("info") s.Muted, s.Masked, s.Directory, s.Leaf = c("muted"), c("masked"), c("directory"), c("leaf") s.MaskChar = cfg.MaskChar if s.MaskChar == "" { s.MaskChar = "•" } if mono { s.flatten() } border := borderFor(cfg.BorderStyle) pillFG := s.TextInverted s.HeaderBar = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Padding(0, 1) s.AppBadge = lipgloss.NewStyle().Foreground(pillFG).Background(s.Accent).Bold(true).Padding(0, 1) s.Crumb = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface) s.CrumbSep = lipgloss.NewStyle().Foreground(s.TextFaint).Background(s.Surface) s.CrumbActive = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Bold(true) s.Pill = lipgloss.NewStyle().Foreground(s.Text).Background(s.SurfaceRaised).Padding(0, 1) s.PillOK = s.Pill.Foreground(pillFG).Background(s.Success) s.PillWarn = s.Pill.Foreground(pillFG).Background(s.Warning) s.PillDanger = s.Pill.Foreground(pillFG).Background(s.Error) s.Footer = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface).Padding(0, 1) s.StatusBar = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface) s.ReadOnly = s.PillWarn.Bold(true) s.Panel = lipgloss.NewStyle().Border(border).BorderForeground(s.Border).Padding(0, 1) s.PanelActive = s.Panel.BorderForeground(s.BorderActive) s.PanelTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) s.PanelSubtle = lipgloss.NewStyle().Foreground(s.TextMuted) s.SidePanel = lipgloss.NewStyle().Border(border).BorderForeground(s.BorderSubtle).Padding(0, 1) s.EmptyState = lipgloss.NewStyle().Foreground(s.TextFaint).Italic(true) s.Row = lipgloss.NewStyle().Foreground(s.Text) s.RowSelected = lipgloss.NewStyle().Foreground(s.Selection).Background(s.SelectionBg).Bold(true) s.RowDim = lipgloss.NewStyle().Foreground(s.TextFaint) s.Badge = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.SurfaceRaised).Padding(0, 1) s.TableHeader = lipgloss.NewStyle().Foreground(s.TextMuted).Bold(true) s.KeyCell = lipgloss.NewStyle().Foreground(s.Text).Bold(true) s.ValueCell = lipgloss.NewStyle().Foreground(s.TextMuted) s.Title = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) s.Help = lipgloss.NewStyle().Foreground(s.TextMuted) s.ErrorText = lipgloss.NewStyle().Foreground(s.Error).Bold(true) s.WarnText = lipgloss.NewStyle().Foreground(s.Warning) s.SuccessText = lipgloss.NewStyle().Foreground(s.Success) s.MaskedText = lipgloss.NewStyle().Foreground(s.Masked) s.DirText = lipgloss.NewStyle().Foreground(s.Directory) s.LeafText = lipgloss.NewStyle().Foreground(s.Leaf) s.Selected = s.RowSelected s.Modal = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.Accent). Background(s.Overlay).Foreground(s.Text).Padding(1, 2) s.ModalDanger = s.Modal.BorderForeground(s.Error) s.ModalTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) s.Backdrop = lipgloss.NewStyle().Foreground(s.TextFaint) s.ToastInfo = lipgloss.NewStyle().Foreground(pillFG).Background(s.Info).Padding(0, 1) s.ToastSuccess = lipgloss.NewStyle().Foreground(pillFG).Background(s.Success).Padding(0, 1) s.ToastWarn = lipgloss.NewStyle().Foreground(pillFG).Background(s.Warning).Padding(0, 1) s.ToastError = lipgloss.NewStyle().Foreground(pillFG).Background(s.Error).Padding(0, 1) s.InputFocused = lipgloss.NewStyle().Foreground(s.Accent) s.InputBlurred = lipgloss.NewStyle().Foreground(s.TextMuted) return s } // flatten drops every color to the terminal's default foreground (mono // mode). Called before styles are built so every lipgloss.Style below // inherits plain colors and relies on Bold/Italic/Reverse for hierarchy. func (s *Styles) flatten() { none := lipgloss.Color("") *s = Styles{ isDark: s.isDark, Mono: true, MaskChar: s.MaskChar, Bg: none, Surface: none, SurfaceRaised: none, Overlay: none, Text: none, TextMuted: none, TextFaint: none, TextInverted: none, Border: none, BorderSubtle: none, BorderActive: none, Header: none, Selection: none, SelectionBg: none, Accent: none, Error: none, Warning: none, Success: none, Info: none, Muted: none, Masked: none, Directory: none, Leaf: none, } } func borderFor(style string) lipgloss.Border { switch style { case "normal": return lipgloss.NormalBorder() case "thick": return lipgloss.ThickBorder() case "double": return lipgloss.DoubleBorder() case "hidden": return lipgloss.HiddenBorder() case "ascii": return lipgloss.Border{Top: "-", Bottom: "-", Left: "|", Right: "|", TopLeft: "+", TopRight: "+", BottomLeft: "+", BottomRight: "+"} default: return lipgloss.RoundedBorder() } } // HelpStyles adapts bubbles/help's palette to the theme. func (s *Styles) HelpStyles() help.Styles { hs := help.DefaultStyles(s.isDark) hs.ShortKey = lipgloss.NewStyle().Foreground(s.Accent) hs.ShortDesc = lipgloss.NewStyle().Foreground(s.TextMuted) hs.ShortSeparator = lipgloss.NewStyle().Foreground(s.TextFaint) hs.FullKey = hs.ShortKey hs.FullDesc = hs.ShortDesc hs.FullSeparator = hs.ShortSeparator hs.Ellipsis = lipgloss.NewStyle().Foreground(s.TextFaint) return hs } // TextInputStyles adapts bubbles/textinput's palette to the theme. func (s *Styles) TextInputStyles() textinput.Styles { ti := textinput.DefaultStyles(s.isDark) ti.Focused.Text = lipgloss.NewStyle().Foreground(s.Text) ti.Focused.Prompt = lipgloss.NewStyle().Foreground(s.Accent) ti.Focused.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint) ti.Blurred.Text = lipgloss.NewStyle().Foreground(s.TextMuted) ti.Blurred.Prompt = lipgloss.NewStyle().Foreground(s.TextFaint) ti.Blurred.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint) ti.Cursor.Color = s.Accent return ti } // ListStyles adapts bubbles/list's chrome (filter prompt/cursor, "no // items", help line) to the theme. The list's per-row rendering is handled // entirely by our own list.ItemDelegate, not by this. func (s *Styles) ListStyles() list.Styles { ls := list.DefaultStyles(s.isDark) ls.Filter = s.TextInputStyles() ls.NoItems = s.EmptyState ls.StatusEmpty = s.EmptyState ls.HelpStyle = s.Help ls.StatusBarActiveFilter = lipgloss.NewStyle().Foreground(s.Text) ls.StatusBarFilterCount = lipgloss.NewStyle().Foreground(s.TextFaint) ls.PaginationStyle = lipgloss.NewStyle().Foreground(s.TextFaint) ls.DefaultFilterCharacterMatch = lipgloss.NewStyle().Foreground(s.Accent).Underline(true) return ls }