469 lines
12 KiB
Go
469 lines
12 KiB
Go
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
|
||
}
|