ludmila-ai-tui/theme.go
2026-06-03 15:05:38 +07:00

469 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"image/color"
"os"
"path/filepath"
"strconv"
"strings"
lipgloss "charm.land/lipgloss/v2"
)
type Token int
const (
TokenBg Token = iota
TokenFg
TokenFgMuted
TokenFgMostSubtle
TokenPrimary
TokenSecondary
TokenAccent
TokenSuccess
TokenSuccessSubtle
TokenUser
TokenAssistant
TokenBorder
TokenCodeBlock
TokenHeaderBg
TokenHeaderFg
TokenStreaming
TokenBgSubtle
TokenOnPrimary
)
func tokenKey(t Token) string {
switch t {
case TokenBg:
return "bg"
case TokenFg:
return "fg"
case TokenFgMuted:
return "fg_muted"
case TokenFgMostSubtle:
return "fg_most_subtle"
case TokenPrimary:
return "primary"
case TokenSecondary:
return "secondary"
case TokenAccent:
return "accent"
case TokenSuccess:
return "success"
case TokenSuccessSubtle:
return "success_subtle"
case TokenUser:
return "user"
case TokenAssistant:
return "assistant"
case TokenBorder:
return "border"
case TokenCodeBlock:
return "code_block"
case TokenHeaderBg:
return "header_bg"
case TokenHeaderFg:
return "header_fg"
case TokenStreaming:
return "streaming"
case TokenBgSubtle:
return "bg_subtle"
case TokenOnPrimary:
return "on_primary"
}
return ""
}
type Theme map[Token]string
func SystemTheme() Theme {
return Theme{
TokenBg: "#303030",
TokenFg: "#d7dae0",
TokenFgMuted: "#abb2bf",
TokenFgMostSubtle: "#565c64",
TokenPrimary: "#56b6c2",
TokenSecondary: "#e06c75",
TokenAccent: "#c678dd",
TokenSuccess: "#98c379",
TokenSuccessSubtle: "#98c379",
TokenUser: "#56b6c2",
TokenAssistant: "#98c379",
TokenBorder: "#353b45",
TokenCodeBlock: "#353b45",
TokenHeaderBg: "#232326",
TokenHeaderFg: "#d7dae0",
TokenStreaming: "#e06c75",
TokenBgSubtle: "#232326",
TokenOnPrimary: "#c8ccd4",
}
}
func LoadTheme(name, themesDirectory string) Theme {
if name == "" || name == "system" {
return SystemTheme()
}
path := filepath.Join(themesDirectory, name+".json")
data, err := os.ReadFile(path)
if err != nil {
return SystemTheme()
}
type rawTheme struct {
Colors map[string]string `json:"colors"`
}
var full rawTheme
if err := json.Unmarshal(data, &full); err != nil {
return SystemTheme()
}
t := SystemTheme()
for k, v := range full.Colors {
for tok := TokenBg; tok <= TokenOnPrimary; tok++ {
if tokenKey(tok) == k {
t[tok] = v
break
}
}
}
return t
}
func themeColor(val string) color.Color {
if val == "" {
return lipgloss.NoColor{}
}
if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 255 {
return lipgloss.Color(strconv.Itoa(n))
}
if strings.HasPrefix(val, "#") && len(val) == 7 {
return lipgloss.Color(val)
}
return lipgloss.NoColor{}
}
var thickLeftBorder = lipgloss.Border{Left: "▌"}
type Styles struct {
Bg color.Color
BgSubtle color.Color
Fg color.Color
Logo lipgloss.Style
LogoDiagonals lipgloss.Style
LogoCharm lipgloss.Style
LogoVersion lipgloss.Style
LogoGradFrom color.Color
LogoGradTo color.Color
HeaderDiagonals lipgloss.Style
HeaderSep lipgloss.Style
HeaderWrap lipgloss.Style
UserFocused lipgloss.Style
UserBlurred lipgloss.Style
AsstFocused lipgloss.Style
AsstBlurred lipgloss.Style
ModelInfoIcon lipgloss.Style
ModelInfoName lipgloss.Style
ModelInfoProvider lipgloss.Style
Help lipgloss.Style
Error lipgloss.Style
Streaming lipgloss.Style
CodeBlock lipgloss.Style
InlineBold lipgloss.Style
InlineItalic lipgloss.Style
InlineCode lipgloss.Style
Heading lipgloss.Style
H1 lipgloss.Style
DialogTitle lipgloss.Style
DialogTitleAccent lipgloss.Style
DialogView lipgloss.Style
DialogSelected lipgloss.Style
DialogNormal lipgloss.Style
DialogContentBg lipgloss.Style
InputBorder lipgloss.Style
ScrollbarThumb lipgloss.Style
ScrollbarTrack lipgloss.Style
ModelPickerBorder color.Color
PromptFocused lipgloss.Style
PromptBlurred lipgloss.Style
BtnFocused lipgloss.Style
BtnBlurred lipgloss.Style
SelCursor lipgloss.Style
SelVisual lipgloss.Style
SelCursorBg string
SelVisualBg string
}
func BuildStyles(t Theme) Styles {
return Styles{
Bg: themeColor(t[TokenBg]),
BgSubtle: themeColor(t[TokenBgSubtle]),
Fg: themeColor(t[TokenFg]),
Logo: lipgloss.NewStyle(),
LogoDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])),
LogoCharm: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])).Bold(true),
LogoVersion: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])),
LogoGradFrom: themeColor(t[TokenSecondary]),
LogoGradTo: themeColor(t[TokenPrimary]),
HeaderDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])),
HeaderSep: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])),
HeaderWrap: lipgloss.NewStyle().
Background(themeColor(t[TokenHeaderBg])).
Foreground(themeColor(t[TokenHeaderFg])).
Padding(0, 1),
UserFocused: lipgloss.NewStyle().
Border(thickLeftBorder, false, false, false, true).
BorderForeground(themeColor(t[TokenUser])).
Foreground(themeColor(t[TokenFgMuted])).
Padding(0, 0, 0, 1),
UserBlurred: lipgloss.NewStyle().
BorderLeft(true).
BorderForeground(themeColor(t[TokenUser])).
Foreground(themeColor(t[TokenFgMuted])).
Padding(0, 0, 0, 1),
AsstFocused: lipgloss.NewStyle().
Border(thickLeftBorder, false, false, false, true).
BorderForeground(themeColor(t[TokenAssistant])).
Padding(0, 0, 0, 1),
AsstBlurred: lipgloss.NewStyle().
BorderLeft(true).
BorderForeground(themeColor(t[TokenAssistant])).
Padding(0, 0, 0, 1),
ModelInfoIcon: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMostSubtle])),
ModelInfoName: lipgloss.NewStyle().Foreground(themeColor(t[TokenFg])),
ModelInfoProvider: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])),
Help: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])),
Error: lipgloss.NewStyle().Foreground(themeColor(t[TokenSecondary])),
Streaming: lipgloss.NewStyle().Foreground(themeColor(t[TokenStreaming])),
CodeBlock: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(themeColor(t[TokenCodeBlock])).
Background(themeColor(t[TokenBgSubtle])).
Padding(0, 1),
InlineBold: lipgloss.NewStyle().
Bold(true).
Foreground(themeColor(t[TokenFg])),
InlineItalic: lipgloss.NewStyle().
Foreground(themeColor(t[TokenSuccess])),
InlineCode: lipgloss.NewStyle().
Foreground(themeColor(t[TokenPrimary])).
Background(themeColor(t[TokenCodeBlock])),
Heading: lipgloss.NewStyle().
Bold(true).
Foreground(themeColor(t[TokenPrimary])),
H1: lipgloss.NewStyle().
Bold(true).
Background(themeColor(t[TokenPrimary])).
Foreground(themeColor(t[TokenOnPrimary])),
DialogTitle: lipgloss.NewStyle().Padding(0, 1).Foreground(themeColor(t[TokenPrimary])),
DialogTitleAccent: lipgloss.NewStyle().Foreground(themeColor(t[TokenSuccess])).Bold(true),
DialogView: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(themeColor(t[TokenPrimary])),
DialogSelected: lipgloss.NewStyle().Padding(0, 1).
Background(themeColor(t[TokenPrimary])).
Foreground(themeColor(t[TokenOnPrimary])),
DialogNormal: lipgloss.NewStyle().Padding(0, 1).
Foreground(themeColor(t[TokenFg])),
DialogContentBg: lipgloss.NewStyle().
Background(themeColor(t[TokenBgSubtle])).
Foreground(themeColor(t[TokenFg])).
Padding(1, 2),
InputBorder: lipgloss.NewStyle().
Background(themeColor(t[TokenBgSubtle])).
Padding(2, 3),
ScrollbarThumb: lipgloss.NewStyle().
Foreground(themeColor(t[TokenSecondary])),
ScrollbarTrack: lipgloss.NewStyle().
Foreground(themeColor(t[TokenFgMuted])),
ModelPickerBorder: themeColor(t[TokenPrimary]),
PromptFocused: lipgloss.NewStyle().Foreground(themeColor(t[TokenAccent])),
PromptBlurred: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])),
BtnFocused: lipgloss.NewStyle().Padding(0, 2).
Foreground(themeColor(t[TokenOnPrimary])).
Background(themeColor(t[TokenSecondary])),
BtnBlurred: lipgloss.NewStyle().Padding(0, 2).
Foreground(themeColor(t[TokenFg])).
Background(themeColor(t[TokenBgSubtle])),
SelCursor: lipgloss.NewStyle().
Background(lipgloss.Color("#2d3f41")),
SelVisual: lipgloss.NewStyle().
Background(lipgloss.Color("#3a2540")),
SelCursorBg: hexToAnsiBg("#2d3f41"),
SelVisualBg: hexToAnsiBg("#3a2540"),
}
}
func hexToAnsiBg(hex string) string {
if len(hex) < 7 || hex[0] != '#' {
return ""
}
r, _ := strconv.ParseUint(hex[1:3], 16, 8)
g, _ := strconv.ParseUint(hex[3:5], 16, 8)
b, _ := strconv.ParseUint(hex[5:7], 16, 8)
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
}
func foregroundGrad(base lipgloss.Style, input string, bold bool, c1, c2 color.Color) []string {
if input == "" {
return []string{""}
}
if len(input) == 1 {
return []string{base.Foreground(c1).Bold(bold).Render(input)}
}
runes := []rune(input)
ramp := lipgloss.Blend1D(len(runes), c1, c2)
result := make([]string, len(runes))
for i, c := range ramp {
s := base.Foreground(c)
if bold {
s = s.Bold(true)
}
result[i] = s.Render(string(runes[i]))
}
return result
}
func applyForegroundGrad(base lipgloss.Style, input string, c1, c2 color.Color) string {
clusters := foregroundGrad(base, input, false, c1, c2)
return strings.Join(clusters, "")
}
func applyBoldGrad(base lipgloss.Style, input string, c1, c2 color.Color) string {
clusters := foregroundGrad(base, input, true, c1, c2)
return strings.Join(clusters, "")
}
func renderCompactLogo(sty Styles) string {
name := applyBoldGrad(sty.Logo, "LUDMILA", sty.LogoGradFrom, sty.LogoGradTo)
diag := sty.LogoDiagonals.Render("")
return name + " " + diag
}
func renderHeader(sty Styles, model string, width int, mode string) string {
logoPart := renderCompactLogo(sty)
modelStyled := lipgloss.NewStyle().
Foreground(sty.LogoGradTo).
Bold(true).
Render(" " + model + " ")
fixed := lipgloss.Width(logoPart) + 1 + lipgloss.Width(modelStyled)
var modePart string
if mode != "" {
modeBg := lipgloss.Color("#e06c75")
modeFg := lipgloss.Color("#c8ccd4")
if mode == "--VISUAL--" {
modeBg = lipgloss.Color("#c678dd")
}
modePart = lipgloss.NewStyle().
Background(modeBg).
Foreground(modeFg).
Bold(true).
Padding(0, 1).
Render(mode) + " "
fixed += lipgloss.Width(modePart)
}
gap := width - fixed
if gap < 0 {
gap = 0
}
return sty.HeaderWrap.Width(width).Render(
modePart + logoPart + " " + modelStyled + strings.Repeat(" ", gap),
)
}
func wrapText(text string, width int) string {
if width <= 0 {
return text
}
var b strings.Builder
lines := strings.Split(text, "\n")
for li, line := range lines {
if li > 0 {
b.WriteByte('\n')
}
runes := []rune(line)
pos := 0
for pos < len(runes) {
end := pos + width
if end > len(runes) {
end = len(runes)
}
b.WriteString(string(runes[pos:end]))
pos = end
if pos < len(runes) {
b.WriteByte('\n')
}
}
}
return b.String()
}
func renderScrollbar(height, contentSize, viewportSize, offset int, thumb, track lipgloss.Style) string {
if height <= 0 || contentSize <= viewportSize {
return ""
}
thumbSize := max(1, height*viewportSize/contentSize)
maxOffset := contentSize - viewportSize
if maxOffset <= 0 {
return ""
}
trackSpace := height - thumbSize
thumbPos := 0
if trackSpace > 0 && maxOffset > 0 {
thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
}
var sb strings.Builder
for i := range height {
if i > 0 {
sb.WriteByte('\n')
}
if i >= thumbPos && i < thumbPos+thumbSize {
sb.WriteString(thumb.Render("┃"))
} else {
sb.WriteString(track.Render("│"))
}
}
return sb.String()
}
func dialogTitle(title string, gradientAccent lipgloss.Style, width int, from, to color.Color) string {
titleRendered := lipgloss.NewStyle().Foreground(from).Render(title)
remaining := width - lipgloss.Width(titleRendered) - 1
if remaining < 0 {
remaining = 0
}
diagLine := applyForegroundGrad(gradientAccent, strings.Repeat("", remaining), from, to)
return titleRendered + " " + diagLine
}