Hello world 👋
Release / Build and publish (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-12 10:29:42 +02:00
commit 46d6d37dc2
130 files changed
+26739

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
// 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 (
"image/color"
"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
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
themed, themedBlurred bool
}
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;
// in every pane, so each shows what's selected in it. The focused
// pane's is bold too. Palette 8 until the terminal says what its
// background is (OnBackground).
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
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
}
// OnBackground shades the selection colours from the terminal's
// background, a little lighter on a dark one and darker on a light one:
// a quiet band that keeps the row's colours readable, where palette 8 is
// often too bright. Colours the theme sets are kept.
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
shade := func(p float64) color.Color {
if dark {
return lipgloss.Lighten(bg, p)
}
return lipgloss.Darken(bg, p)
}
if !s.themed {
s.Selected = s.Selected.Background(shade(0.09))
}
if !s.themedBlurred {
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
}
return s
}
+200
View File
@@ -0,0 +1,200 @@
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)
st.themed = true
case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = 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)
}
+94
View File
@@ -0,0 +1,94 @@
package styles
import (
"fmt"
"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())
}
}
func TestOnBackground(t *testing.T) {
hex := func(c any) string {
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
t.Errorf("dark: selected %s", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("dark: blurred %s", got)
}
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
t.Errorf("light: selected %s", got)
}
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
if err != nil {
t.Fatal(err)
}
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
t.Errorf("themed: selected %s (want the theme's)", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("themed: blurred %s (not themed: shaded)", got)
}
}