package tui
import (
"image/color"
"os"
"strings"
"charm.land/lipgloss/v2"
)
type Theme struct {
Dark bool
Surface color.Color
Border color.Color
BorderFocus color.Color
TextBright color.Color
Text color.Color
TextMuted color.Color
Accent color.Color
Success color.Color
Warning color.Color
Danger color.Color
OnAccent color.Color
}
func ResolveTheme() Theme {
switch strings.ToLower(strings.TrimSpace(os.Getenv("INTAKE_THEME"))) {
case "dark":
return makeTheme(true)
case "light":
return makeTheme(false)
}
dark := true
if v := os.Getenv("COLORFGBG"); v != "" {
parts := strings.Split(v, ";")
if len(parts) >= 2 {
if bg := strings.TrimSpace(parts[len(parts)-1]); bg == "7" || bg == "15" {
dark = false
}
}
}
return makeTheme(dark)
}
func makeTheme(dark bool) Theme {
c := func(lightHex, darkHex string) color.Color {
if dark {
return lipgloss.Color(darkHex)
}
return lipgloss.Color(lightHex)
}
return Theme{
Dark: dark,
Surface: c("#F6F7F9", "#16181D"),
Border: c("#D6DAE0", "#2A2E36"),
BorderFocus: c("#5B3BB5", "#B79CFF"),
TextBright: c("#0B0D10", "#FFFFFF"),
Text: c("#24292F", "#D6D9DE"),
TextMuted: c("#5B6470", "#8B929C"),
Accent: c("#5B3BB5", "#B79CFF"),
Success: c("#1F7A4D", "#5FD79B"),
Warning: c("#8A5A00", "#F0B860"),
Danger: c("#B3261E", "#FF8A80"),
OnAccent: c("#FFFFFF", "#14161A"),
}
}
type Styles struct {
Theme Theme
AppName lipgloss.Style
Crumb lipgloss.Style
CrumbDim lipgloss.Style
Hint lipgloss.Style
HintKey lipgloss.Style
HintDesc lipgloss.Style
Status lipgloss.Style
Error lipgloss.Style
Title lipgloss.Style
Heading lipgloss.Style
Label lipgloss.Style
Value lipgloss.Style
Muted lipgloss.Style
Dim lipgloss.Style
Code lipgloss.Style
Accent lipgloss.Style
Success lipgloss.Style
Warning lipgloss.Style
Danger lipgloss.Style
Badge lipgloss.Style
Pill lipgloss.Style
Panel lipgloss.Style
PanelActive lipgloss.Style
Inner lipgloss.Style
Rule lipgloss.Style
Selected lipgloss.Style
SelectedDim lipgloss.Style
Marker lipgloss.Style
ListTitle lipgloss.Style
ListItem lipgloss.Style
ListSelected lipgloss.Style
ListDesc lipgloss.Style
ListDescSel lipgloss.Style
ListFilter lipgloss.Style
ListFilterCur lipgloss.Style
ListStatusBar lipgloss.Style
ListStatusKeys lipgloss.Style
ListStatusNums lipgloss.Style
ListSpinner lipgloss.Style
}
func DefaultStyles() Styles { return StylesFor(ResolveTheme()) }
func StylesFor(t Theme) Styles {
s := Styles{Theme: t}
s.AppName = lipgloss.NewStyle().Bold(true).Foreground(t.OnAccent).Background(t.Accent).Padding(0, 1)
s.Crumb = lipgloss.NewStyle().Foreground(t.Text)
s.CrumbDim = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Hint = lipgloss.NewStyle().Foreground(t.TextMuted)
s.HintKey = lipgloss.NewStyle().Foreground(t.Text).Bold(true)
s.HintDesc = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Status = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Error = lipgloss.NewStyle().Foreground(t.Danger)
s.Title = lipgloss.NewStyle().Bold(true).Foreground(t.TextBright)
s.Heading = lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
s.Label = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Value = lipgloss.NewStyle().Foreground(t.Text)
s.Muted = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Dim = lipgloss.NewStyle().Foreground(t.TextMuted).Faint(true)
s.Code = lipgloss.NewStyle().Foreground(t.Text).Background(t.Surface).Padding(0, 1)
s.Accent = lipgloss.NewStyle().Foreground(t.Accent)
s.Success = lipgloss.NewStyle().Foreground(t.Success)
s.Warning = lipgloss.NewStyle().Foreground(t.Warning)
s.Danger = lipgloss.NewStyle().Foreground(t.Danger)
s.Badge = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Padding(0, 1)
s.Pill = lipgloss.NewStyle().Foreground(t.TextMuted)
s.Panel = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.Border).
Padding(0, 1)
s.PanelActive = s.Panel.BorderForeground(t.BorderFocus)
s.Inner = lipgloss.NewStyle().Padding(0, 1)
s.Rule = lipgloss.NewStyle().Foreground(t.Border)
s.Selected = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Bold(true)
s.SelectedDim = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent)
s.Marker = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
s.ListTitle = lipgloss.NewStyle().Bold(true).Foreground(t.TextBright).Padding(0, 1)
s.ListItem = lipgloss.NewStyle().Foreground(t.Text).Padding(0, 1)
s.ListSelected = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Bold(true).Padding(0, 1)
s.ListDesc = lipgloss.NewStyle().Foreground(t.TextMuted).Padding(0, 1)
s.ListDescSel = lipgloss.NewStyle().Foreground(t.OnAccent).Padding(0, 1)
s.ListFilter = lipgloss.NewStyle().Foreground(t.Accent)
s.ListFilterCur = lipgloss.NewStyle().Foreground(t.TextBright)
s.ListStatusBar = lipgloss.NewStyle().Foreground(t.TextMuted)
s.ListStatusKeys = lipgloss.NewStyle().Foreground(t.Text).Bold(true)
s.ListStatusNums = lipgloss.NewStyle().Foreground(t.Text)
s.ListSpinner = lipgloss.NewStyle().Foreground(t.Accent)
return s
}
type Box struct{ W, H int }
const chrome = 2
func (m Module) body() Box {
h := m.height - chrome
if m.railShown() {
h--
}
if h < 3 {
h = 3
}
w := m.width
if w < 20 {
w = 20
}
return Box{W: w, H: h}
}
type mode int
const (
modeTiny mode = iota
modeMini
modeCompact
modeFull
)
func (m Module) mode() mode {
switch {
case m.height < 8:
return modeTiny
case m.width < 60 || m.height < 18:
return modeMini
case m.width < 100 || m.height < 28:
return modeCompact
default:
return modeFull
}
}
func truncateTo(s string, width int) string {
if width <= 0 {
return ""
}
if displayWidth(s) <= width {
return s
}
return truncateANSI(s, width)
}
func clamp(block string, width, height int) string {
lines := strings.Split(block, "\n")
if len(lines) > height {
lines = lines[:height]
}
for i, l := range lines {
lines[i] = truncateTo(l, width)
}
return strings.Join(lines, "\n")
}
func fitHeight(block string, height int) string {
lines := strings.Split(block, "\n")
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
func panelWith(s Styles, title, body string, width int, active bool) string {
style := s.Panel
if active {
style = s.PanelActive
}
inner := width - 4
if inner < 8 {
inner = 8
}
head := ""
if title != "" {
head = s.Heading.Render(title) + "\n"
}
return style.Render(clamp(head+body, inner, 1000))
}