- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
329 lines
12 KiB
Go
329 lines
12 KiB
Go
// 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
|
|
}
|