git/intake/main
internal/tui/styles.go
265 lines7 KB last change raw · history
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))
}