Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6fab41703e
111 files changed
+22988
No files matched your search
@@ -0,0 +1,76 @@
|
||||
// Package styles holds the lipgloss theme. It sticks to the 16 ANSI colours
|
||||
// so it follows the user's terminal palette in both light and dark themes.
|
||||
package styles
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
BorderFocused lipgloss.Style
|
||||
BorderBlurred lipgloss.Style
|
||||
TitleFocused lipgloss.Style
|
||||
TitleBlurred lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
|
||||
Selected lipgloss.Style // cursor row in the focused pane
|
||||
SelectedBlurred lipgloss.Style // cursor row in other panes
|
||||
Muted lipgloss.Style
|
||||
Error lipgloss.Style
|
||||
Notice lipgloss.Style
|
||||
Success lipgloss.Style // the ✓ before confirmations
|
||||
Warning lipgloss.Style // "can't do that here" messages
|
||||
Match lipgloss.Style // filter matches
|
||||
Favourite lipgloss.Style
|
||||
Prompt lipgloss.Style // questions in the footer
|
||||
Label lipgloss.Style
|
||||
Masked lipgloss.Style
|
||||
Value lipgloss.Style
|
||||
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks lipgloss.Style
|
||||
KindRepo lipgloss.Style
|
||||
}
|
||||
|
||||
func Default() Styles {
|
||||
green := lipgloss.Green
|
||||
return Styles{
|
||||
BorderFocused: lipgloss.NewStyle().Foreground(green),
|
||||
BorderBlurred: lipgloss.NewStyle().Faint(true),
|
||||
TitleFocused: lipgloss.NewStyle().Foreground(green).Bold(true),
|
||||
TitleBlurred: lipgloss.NewStyle(),
|
||||
Footer: lipgloss.NewStyle().Faint(true),
|
||||
|
||||
// A background rather than reverse, so the row keeps its colours.
|
||||
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
||||
Muted: lipgloss.NewStyle().Faint(true),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Match: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Favourite: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Label: lipgloss.NewStyle().Foreground(lipgloss.Blue).Bold(true),
|
||||
Masked: lipgloss.NewStyle().Faint(true),
|
||||
Value: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
|
||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||
}
|
||||
}
|
||||
|
||||
// Kind is the colour of a provider's sources, vaults and paths.
|
||||
func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
||||
switch k {
|
||||
case provider.KindDatabricks:
|
||||
return s.KindDatabricks
|
||||
case provider.KindRepo:
|
||||
return s.KindRepo
|
||||
}
|
||||
return s.KindAzure
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
case "inactiveViewSelectedLineBgColor":
|
||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func TestFromTheme(t *testing.T) {
|
||||
st, fg, err := FromTheme(map[string][]string{
|
||||
"activeBorderColor": {"#e0af68", "bold"},
|
||||
"selectedLineBgColor": {"#2d3f76"},
|
||||
"defaultFgColor": {"#c8d3f5"},
|
||||
"azureColor": {"75"},
|
||||
"databricksColor": {"brightred"},
|
||||
"searchMatchColor": {"default", "underline"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fg == nil {
|
||||
t.Error("defaultFgColor not returned")
|
||||
}
|
||||
if got := st.BorderFocused.GetForeground(); got != lipgloss.Color("#e0af68") || !st.BorderFocused.GetBold() {
|
||||
t.Errorf("active border = %v bold=%v", got, st.BorderFocused.GetBold())
|
||||
}
|
||||
// The theme's selected-line background replaces the default one.
|
||||
if st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.Color("#2d3f76") {
|
||||
t.Errorf("selected = reverse %v bg %v", st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
if st.KindAzure.GetForeground() != lipgloss.Color("75") || st.KindDatabricks.GetForeground() != lipgloss.BrightRed {
|
||||
t.Error("provider colours not applied")
|
||||
}
|
||||
if !st.Match.GetUnderline() {
|
||||
t.Error("attribute without colour not applied")
|
||||
}
|
||||
// Roles the theme leaves out keep their defaults.
|
||||
if st.Error.GetForeground() != lipgloss.Red {
|
||||
t.Error("default error colour changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromThemeRejectsMistakes(t *testing.T) {
|
||||
for name, theme := range map[string]map[string][]string{
|
||||
"role": {"activeBorder": {"red"}},
|
||||
"colour": {"errorColor": {"redish"}},
|
||||
"hex": {"errorColor": {"#fff"}},
|
||||
"palette": {"errorColor": {"300"}},
|
||||
} {
|
||||
if _, _, err := FromTheme(theme); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
|
||||
t.Errorf("unknown role error should list the known ones: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyThemeIsDefault(t *testing.T) {
|
||||
st, fg, err := FromTheme(nil)
|
||||
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user