feat(theme): token semantik Catppuccin (Mocha/Latte/terminal) + mode auto via tea.BackgroundColorMsg + tombol T
CI / test (push) Successful in 11s

This commit is contained in:
oxpoe committed 2026-10-15 13:32:55 +07:00
1 parent c65d2cb75d
commit 8b2a86877e
13 files changed
+428 -37

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ Repo referensi hanya ada di `reference/` (git-ignored).
| lipgloss | v2.0.6 | MIT |
| oto | v3.5.1 | Apache-2.0 |
| harmonica | v0.2.0 | MIT |
| Catppuccin (data palet Mocha/Latte) | — | MIT |
| Docker SDK for Go (client) | v28.5.2+incompatible | Apache-2.0 |
| docker/go-connections | v0.8.2 | Apache-2.0 |
| go-yaml v3 | v3.0.1 | MIT |
+1 -1
View File
@@ -6,7 +6,7 @@ tersedia atribusinya dari menu **Tentang** di aplikasi.
| Aset | Sumber | Lisensi | Tanggal | Status | File target |
|---|---|---|---|---|---|
| Palet Catppuccin (Mocha/Latte) | https://github.com/catppuccin/catppuccin | MIT | — | planned (Phase 1) | `internal/ui/theme/catppuccin.go` |
| Palet Catppuccin (Mocha/Latte + 14 aksen) | https://github.com/catppuccin/catppuccin | MIT | 2026-10-15 | **dipakai** (Phase 1) | `internal/ui/theme/palette.go` |
| Efek suara UI — **final** (click, navigate, modal open/close, notif success/warning/error, exec) | Kenney Interface Sounds 1.0 — https://kenney.nl/assets/interface-sounds | CC0 1.0 (file: `internal/audio/assets/kenney-license.txt`) | 2026-10-15 | **dipakai** (T-0.6) | `internal/audio/assets/*.wav` (8 file, total 176 KB) |
| Logo ASCII `tia` | buatan sendiri | ikut lisensi tia | 2026-10 | planned (Phase 1) | `internal/ui/animation/logo.go` |
| Ikon/glyph | Unicode standar (●, ○, ▶, ⟳, ✔, ⚠, ✕) | bebas | 2026-10 | dipakai | di kode UI |
+13
View File
@@ -275,6 +275,19 @@ func TestDoctorUsesConfigStacksDir(t *testing.T) {
}
}
func TestThemeCycle(t *testing.T) {
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.send("T") // auto → dark, toast memberi umpan balik visual
u.waitContains("Mode tema", 3*time.Second)
u.send("T") // dark → light
u.send("T") // light → terminal
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
func TestEnglishLanguage(t *testing.T) {
u := startTUIEnv(t, "TIA_LANG=en")
u.waitContains("· home", 5*time.Second)
+38 -22
View File
@@ -17,6 +17,7 @@ import (
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
)
type viewKind int
@@ -72,6 +73,7 @@ func New() Model {
cfg.UI.Language = l
}
i18n.SetLang(cfg.UI.Language)
theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent)
cl, err := docker.New()
host, _ := os.Hostname()
@@ -91,7 +93,7 @@ func New() Model {
loading: true,
}
if cfgErr != nil {
m.toast.Show(i18n.T("toast.configError"), cfgErr.Error())
m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error())
}
if err != nil {
m.loadErr = err
@@ -102,7 +104,7 @@ func New() Model {
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
if m.animating {
cmds = append(cmds, animation.Next(m.tickID))
}
@@ -154,7 +156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = false
if msg.err != nil {
m.loadErr = msg.err
m.toast.Show(i18n.T("toast.dockerError"), msg.err.Error())
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), msg.err.Error())
m.sound.Play(audio.NotifyError)
} else {
m.loadErr = nil
@@ -175,6 +177,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
theme.SetDetectedDark(msg.IsDark())
return m, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
@@ -209,8 +216,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current()
next := theme.CycleMode(cur.Mode)
theme.Set(next, cur.AccentName)
m.toast.Show(components.LevelInfo,
i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))),
i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
case "t": // demo toast (sampai modal asli datang)
m.toast.Show(i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.sound.Play(audio.NotifySuccess)
case "j", "down":
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
@@ -285,37 +300,38 @@ func (m Model) scrollStart() int {
return m.cursor - rows + 1
}
func stateStyle(state string) (glyph, color string) {
func stateStyle(state string) (glyph string, c theme.Color) {
th := theme.Current()
switch state {
case "running":
return "●", "#a6e3a1"
return "●", th.Success
case "paused":
return "⏸", "#f9e2af"
return "⏸", th.Warning
case "restarting", "created":
return "◌", "#89b4fa"
return "◌", th.Info
case "exited", "dead":
return "○", "#f38ba8"
return "○", th.Danger
default:
return "○", "#6c7086"
return "○", th.Overlay
}
}
// --- styling helpers ---
// --- styling helpers (semua via token tema — F-asi §5.4) ---
func dim(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
return theme.Style(theme.Current().Subtext).Render(s)
}
func text(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
return theme.Style(theme.Current().Text).Render(s)
}
func red(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render(s)
return theme.Style(theme.Current().Danger).Render(s)
}
func styled(color, s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(s)
func styled(c theme.Color, s string) string {
return theme.Style(c).Render(s)
}
func truncate(s string, w int) string {
@@ -339,11 +355,12 @@ func panel(title, meta string, lines []string, w, h int, focused bool) string {
if w < 6 || h < 2 {
return ""
}
bc := "#6c7086"
th := theme.Current()
bc := th.Overlay
if focused {
bc = "#cba6f7"
bc = th.Accent
}
border := lipgloss.NewStyle().Foreground(lipgloss.Color(bc))
border := lipgloss.NewStyle().Foreground(lipgloss.Color(string(bc)))
titleS := text(title)
left := border.Render("╭─ ") + titleS
@@ -382,8 +399,7 @@ func panel(title, meta string, lines []string, w, h int, focused bool) string {
// --- views ---
func (m Model) View() tea.View {
header := lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
header := theme.Style(theme.Current().Accent).Bold(true).
Render("tia — Terminal Is A My Way")
var body, footer string
@@ -442,7 +458,7 @@ func (m Model) sidebarView() string {
glyph, color := stateStyle(c.State)
mark := " "
if i == m.cursor {
mark = styled("#cba6f7", "▶ ")
mark = styled(theme.Current().Accent, "▶ ")
}
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
}
+7 -5
View File
@@ -10,6 +10,7 @@ import (
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
)
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
@@ -51,7 +52,7 @@ func (m Model) clockPanel(w, h int) string {
now := time.Now()
rows := components.CenterBlock(components.RenderClock(now), w-4)
for i, r := range rows {
rows[i] = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cba6f7")).Render(r)
rows[i] = theme.Style(theme.Current().Accent).Bold(true).Render(r)
}
tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year())
lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST")))
@@ -75,7 +76,7 @@ func (m Model) dockerPanel(w, h int) string {
fieldW(i18n.T("section.containers"), 9)+dim(i18n.Tf("fmt.dockerContainers", m.engine.Running, m.engine.Containers)),
fieldW(i18n.T("field.image"), 9)+dim(fmt.Sprintf("%d", m.engine.Images)),
"",
styled("#a6e3a1", i18n.T("state.connected")),
styled(theme.Current().Success, i18n.T("state.connected")),
)
}
return panel("Docker", i18n.T("host.local"), lines, w, h, false)
@@ -174,12 +175,13 @@ func memBar(pct, w int) string {
if fill > w {
fill = w
}
color := "#a6e3a1"
th := theme.Current()
color := th.Success
switch {
case pct >= 85:
color = "#f38ba8"
color = th.Danger
case pct >= 60:
color = "#f9e2af"
color = th.Warning
}
return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill))
}
+13 -1
View File
@@ -21,7 +21,9 @@ type Config struct {
}
type UI struct {
Language string `yaml:"language"` // id | en
Language string `yaml:"language"` // id | en
ThemeMode string `yaml:"theme_mode"` // auto | dark | light | terminal
ThemeAccent string `yaml:"theme_accent"` // 14 warna aksen Catppuccin (validasi di theme)
SoundEnabled bool `yaml:"sound_enabled"`
SoundVolume int `yaml:"sound_volume"` // 0-100
SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"`
@@ -35,6 +37,8 @@ func Default() Config {
StacksDir: filepath.Join(home, ".tia", "stacks"),
UI: UI{
Language: "id",
ThemeMode: "auto",
ThemeAccent: "mauve",
SoundEnabled: true,
SoundVolume: 70,
SoundMuteOnSSH: true,
@@ -102,6 +106,14 @@ func (c *Config) normalize() {
default:
c.UI.Language = "id"
}
switch c.UI.ThemeMode {
case "auto", "dark", "light", "terminal":
default:
c.UI.ThemeMode = "auto"
}
if c.UI.ThemeAccent == "" {
c.UI.ThemeAccent = "mauve"
}
if c.UI.SoundVolume < 0 {
c.UI.SoundVolume = 0
}
+7
View File
@@ -24,6 +24,7 @@ func TestSaveLoadRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "config.yaml")
in := Default()
in.UI.Language = "en"
in.UI.ThemeMode = "terminal"
in.UI.SoundVolume = 42
in.StacksDir = "/data/stacks"
@@ -37,6 +38,9 @@ func TestSaveLoadRoundtrip(t *testing.T) {
if out.UI.Language != "en" || out.UI.SoundVolume != 42 || out.StacksDir != "/data/stacks" {
t.Fatalf("roundtrip salah: %+v", out)
}
if out.UI.ThemeMode != "terminal" || out.UI.ThemeAccent != "mauve" {
t.Fatalf("roundtrip tema salah: %+v", out.UI)
}
}
func TestPartialYamlKeepsDefaults(t *testing.T) {
@@ -66,6 +70,9 @@ func TestNormalizeInvalidValues(t *testing.T) {
if c.UI.Language != "id" {
t.Fatalf("language rusak harus fallback id: %s", c.UI.Language)
}
if c.UI.ThemeMode != "auto" {
t.Fatalf("theme_mode rusak harus fallback auto: %s", c.UI.ThemeMode)
}
if c.UI.SoundVolume != 100 {
t.Fatalf("volume harus diklamp 100: %d", c.UI.SoundVolume)
}
+10 -2
View File
@@ -16,6 +16,13 @@ section:
host:
local: "local"
theme:
auto: "Auto"
dark: "Dark"
light: "Light"
terminal: "Terminal"
switchHint: "Applied instantly — no restart."
field:
id: "ID"
image: "Image"
@@ -51,8 +58,8 @@ hint:
noContainers: "No containers yet — run something with Docker, then press r."
miniNoContainers: "no containers yet — press 1"
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
footerHome: "[1/enter] containers · [t] toast · [a] reduced-motion · [q] quit"
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [q] quit"
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [T] theme · [q] quit"
footer:
status: "%s animation %s fps · audio: %s"
@@ -62,6 +69,7 @@ toast:
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
dockerError: "✖ [ FAILED ] Docker"
configError: "⚠ [ WARNING ] Config problem — using defaults"
themeMode: "Theme mode: %s"
activity:
coming: "Docker events listener coming (Phase 2)."
+10 -2
View File
@@ -16,6 +16,13 @@ section:
host:
local: "lokal"
theme:
auto: "Otomatis"
dark: "Gelap"
light: "Terang"
terminal: "Terminal"
switchHint: "Berlaku langsung — tanpa restart."
field:
id: "ID"
image: "Image"
@@ -51,8 +58,8 @@ hint:
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
miniNoContainers: "belum ada kontainer — tekan 1"
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
footerHome: "[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar"
footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar"
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [T] tema · [q] keluar"
footer:
status: "%s animasi %s fps · audio: %s"
@@ -62,6 +69,7 @@ toast:
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
dockerError: "✖ [ GAGAL ] Docker"
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
themeMode: "Mode tema: %s"
activity:
coming: "Listener Docker events menyusul (Phase 2)."
+32 -4
View File
@@ -10,12 +10,23 @@ import (
"github.com/charmbracelet/harmonica"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
)
// Toast — notifikasi melayang dari tengah atas layar (F-025).
// Versi spike T-0.4: slide masuk/keluar ditenagai spring harmonica.
// Versi spike T-0.4 + warna dari token tema; slide via spring harmonica.
const toastHeight = 4 // 2 baris konten + 2 border
// Level — kategori status toast (mewarnai border).
type Level int
const (
LevelSuccess Level = iota
LevelWarning
LevelError
LevelInfo
)
type toastState int
const (
@@ -29,6 +40,7 @@ type Toast struct {
spring harmonica.Spring
pos, vel float64 // baris offset: 0 = terlihat penuh, -toastHeight = tersembunyi
state toastState
level Level
title string
body string
elapsed time.Duration
@@ -44,7 +56,8 @@ func NewToast() Toast {
}
// Show menampilkan toast baru (slide-in dari atas).
func (t *Toast) Show(title, body string) {
func (t *Toast) Show(level Level, title, body string) {
t.level = level
t.title, t.body = title, body
t.elapsed = 0
t.state = toastIn
@@ -79,6 +92,20 @@ func (t *Toast) Update(dt time.Duration) {
}
}
func (t Toast) borderColor() theme.Color {
th := theme.Current()
switch t.level {
case LevelWarning:
return th.Warning
case LevelError:
return th.Danger
case LevelInfo:
return th.Info
default:
return th.Success
}
}
// View menggambar toast setinggi tetap (toastHeight baris, di-center
// horizontal); baris yang tergeser keluar area menjadi kosong.
func (t Toast) View(width int) string {
@@ -101,10 +128,11 @@ func (t Toast) View(width int) string {
return s
}
th := theme.Current()
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#a6e3a1")).
Foreground(lipgloss.Color("#cdd6f4")).
BorderForeground(lipgloss.Color(string(t.borderColor()))).
Foreground(lipgloss.Color(string(th.Text))).
Width(inner).
Padding(0, 1)
lines := strings.Split(style.Render(clip(t.title)+"\n"+clip(t.body)), "\n")
+94
View File
@@ -0,0 +1,94 @@
package theme
// Palet Catppuccin (Mocha & Latte) + mode terminal ANSI.
// Sumber data: catppuccin/catppuccin (MIT) — lihat THIRD_PARTY_NOTICES.
type palette map[string]string
var mocha = palette{
"base": "#1e1e2e", "mantle": "#181825", "crust": "#11111b",
"surface0": "#313244", "surface1": "#45475a", "surface2": "#585b70",
"overlay0": "#6c7086", "overlay1": "#7f849c", "overlay2": "#9399b2",
"subtext0": "#a6adc8", "subtext1": "#bac2de", "text": "#cdd6f4",
"rosewater": "#f5e0dc", "flamingo": "#f2cdcd", "pink": "#f5c2e7",
"mauve": "#cba6f7", "red": "#f38ba8", "maroon": "#eba0ac",
"peach": "#fab387", "yellow": "#f9e2af", "green": "#a6e3a1",
"teal": "#94e2d5", "sky": "#89dceb", "sapphire": "#74c7ec",
"blue": "#89b4fa", "lavender": "#b4befe",
}
var latte = palette{
"base": "#eff1f5", "mantle": "#e6e9ef", "crust": "#dce0e8",
"surface0": "#ccd0da", "surface1": "#bcc0cc", "surface2": "#acb0be",
"overlay0": "#9ca0b0", "overlay1": "#8c8fa1", "overlay2": "#7c7f93",
"subtext0": "#6c6f85", "subtext1": "#5c5f77", "text": "#4c4f69",
"rosewater": "#dc8a78", "flamingo": "#dd7878", "pink": "#ea76cb",
"mauve": "#8839ef", "red": "#d20f39", "maroon": "#e64553",
"peach": "#fe640b", "yellow": "#df8e1d", "green": "#40a02b",
"teal": "#179299", "sky": "#04a5e5", "sapphire": "#209fb5",
"blue": "#1e66f5", "lavender": "#7287fd",
}
// Accents — 14 warna aksen Catppuccin yang boleh dipilih (PRD §7.2).
var Accents = []string{
"rosewater", "flamingo", "pink", "mauve", "red", "maroon",
"peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender",
}
// accentANSI — pemetaan aksen → warna ANSI untuk mode terminal.
var accentANSI = map[string]string{
"rosewater": "13", "flamingo": "13", "pink": "13", "mauve": "13",
"red": "9", "maroon": "9",
"peach": "11", "yellow": "11",
"green": "10", "teal": "10",
"sky": "12", "sapphire": "12", "blue": "12", "lavender": "12",
}
func validAccent(name string) string {
for _, a := range Accents {
if a == name {
return name
}
}
return "mauve"
}
// build — rakit tema dari mode + aksen (dark hanya relevan untuk auto).
func build(mode Mode, accent string, dark bool) Theme {
accent = validAccent(accent)
if mode == ModeTerminal {
return Theme{
Mode: mode,
AccentName: accent,
// Background tidak diubah; teks utama pakai default terminal.
Base: "", Surface: "0", Overlay: "8",
Text: "", Subtext: "8",
Accent: Color(accentANSI[accent]),
Success: "10", Warning: "11", Danger: "9",
Info: "12", Hover: "8", Selection: "8",
}
}
p := mocha
if mode == ModeLight || (mode == ModeAuto && !dark) {
p = latte
}
g := func(k string) Color { return Color(p[k]) }
return Theme{
Mode: mode,
AccentName: accent,
Base: g("base"),
Surface: g("surface0"),
Overlay: g("overlay0"),
Text: g("text"),
Subtext: g("subtext0"),
Accent: g(accent),
Success: g("green"),
Warning: g("yellow"),
Danger: g("red"),
Info: g("blue"),
Hover: g("surface1"),
Selection: g("surface2"),
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package theme — token warna semantik (PRD §5.4).
//
// Komponen TIDAK boleh memakai kode warna langsung; semua lewat token
// (Base, Surface, Text, Accent, Success, …) yang dipetakan ke palet aktif
// (Mocha/Latte/ANSI terminal), sehingga ganti mode tidak mengubah view.
package theme
import (
"sync"
"charm.land/lipgloss/v2"
)
type Mode string
const (
ModeAuto Mode = "auto" // deteksi background → Mocha (gelap) / Latte (terang)
ModeDark Mode = "dark" // Catppuccin Mocha
ModeLight Mode = "light" // Catppuccin Latte
ModeTerminal Mode = "terminal" // palet ANSI terminal pengguna
)
// Color — "#rrggbb", indeks ANSI ("1".."255"), atau "" = default terminal
// (dipakai mode terminal: background & warna dasar tidak diubah).
type Color string
// Paint mewarnai teks; Color kosong = teks apa adanya (tanpa escape).
func (c Color) Paint(s string) string {
if c == "" {
return s
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(string(c))).Render(s)
}
// Theme — token semantik aktif.
type Theme struct {
Mode Mode
AccentName string
Base, Surface, Overlay Color
Text, Subtext Color
Accent Color
Success, Warning, Danger Color
Info, Hover, Selection Color
}
var (
mu sync.RWMutex
current = build(ModeDark, "mauve", true)
// detectedDark diisi lewat SetDetectedDark saat tea.BackgroundColorMsg
// tiba (mode auto). Default gelap = fallback PRD §5.4.
detectedDark = true
)
// Init dipanggil sekali saat startup (dari config).
func Init(mode Mode, accent string) { Set(mode, accent) }
// Set mengganti tema aktif (validasi nilai; dipakai juga saat runtime).
func Set(mode Mode, accent string) {
m := normalizeMode(mode)
mu.RLock()
dark := detectedDark
mu.RUnlock()
t := build(m, accent, dark)
mu.Lock()
current = t
mu.Unlock()
}
// SetDetectedDark — dipanggil dari Update saat tea.BackgroundColorMsg tiba;
// merakit ulang tema bila mode aktif adalah auto.
// (Query background dilakukan oleh Bubble Tea sendiri — bukan lipgloss langsung,
// karena hanya framework yang boleh membaca stdin di dalam loop input.)
func SetDetectedDark(dark bool) {
mu.Lock()
detectedDark = dark
cur := current
mu.Unlock()
if cur.Mode == ModeAuto {
Set(ModeAuto, cur.AccentName)
}
}
func Current() Theme {
mu.RLock()
defer mu.RUnlock()
return current
}
// CycleMode — urutan tombol cepat `T`: auto → dark → light → terminal.
func CycleMode(m Mode) Mode {
switch m {
case ModeAuto:
return ModeDark
case ModeDark:
return ModeLight
case ModeLight:
return ModeTerminal
default:
return ModeAuto
}
}
// Style — style dasar berwarna token; Color kosong = tanpa warna.
func Style(c Color) lipgloss.Style {
if c == "" {
return lipgloss.NewStyle()
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(string(c)))
}
func normalizeMode(m Mode) Mode {
switch m {
case ModeAuto, ModeDark, ModeLight, ModeTerminal:
return m
default:
return ModeDark
}
}
+82
View File
@@ -0,0 +1,82 @@
package theme
import (
"testing"
)
func TestBuildPaletteMapping(t *testing.T) {
mo := build(ModeDark, "mauve", true)
if mo.Base != "#1e1e2e" || mo.Text != "#cdd6f4" || mo.Accent != "#cba6f7" {
t.Fatalf("mocha salah: %+v", mo)
}
if mo.Success != "#a6e3a1" || mo.Danger != "#f38ba8" || mo.Warning != "#f9e2af" {
t.Fatalf("token status salah: %+v", mo)
}
la := build(ModeLight, "mauve", true) // mode light menang atas flag dark
if la.Base != "#eff1f5" || la.Text != "#4c4f69" || la.Accent != "#8839ef" {
t.Fatalf("latte salah: %+v", la)
}
au := build(ModeAuto, "mauve", false) // auto + bg terang → Latte
if au.Base != "#eff1f5" {
t.Fatalf("auto(terang) harus Latte: %+v", au)
}
au = build(ModeAuto, "mauve", true)
if au.Base != "#1e1e2e" {
t.Fatalf("auto(gelap) harus Mocha: %+v", au)
}
}
func TestAccentValidationAndAllAccents(t *testing.T) {
if th := build(ModeDark, "klingon", true); th.AccentName != "mauve" || th.Accent != "#cba6f7" {
t.Fatalf("aksen tak dikenal harus fallback mauve: %+v", th)
}
for _, a := range Accents {
th := build(ModeDark, a, true)
if th.Accent == "" || th.AccentName != a {
t.Fatalf("aksen %s tidak terpetakan", a)
}
}
}
func TestTerminalMode(t *testing.T) {
th := build(ModeTerminal, "green", true)
if th.Text != "" {
t.Fatalf("terminal: teks harus default (kosong), dapat %q", th.Text)
}
if th.Accent != "10" {
t.Fatalf("terminal: aksen green → ANSI 10, dapat %q", th.Accent)
}
if th.Danger != "9" || th.Warning != "11" || th.Info != "12" {
t.Fatalf("terminal: token status salah: %+v", th)
}
if paint := Color("").Paint("x"); paint != "x" {
t.Fatalf("warna kosong harus tanpa escape: %q", paint)
}
if paint := Color("9").Paint("x"); paint == "x" {
t.Fatal("warna ANSI harus menghasilkan escape")
}
}
func TestCycleMode(t *testing.T) {
order := []Mode{ModeAuto, ModeDark, ModeLight, ModeTerminal, ModeAuto}
m := ModeAuto
for i := 1; i < len(order); i++ {
m = CycleMode(m)
if m != order[i] {
t.Fatalf("cycle %d: dapat %s, mau %s", i, m, order[i])
}
}
}
func TestSetCurrent(t *testing.T) {
Set(ModeLight, "red")
if cur := Current(); cur.Base != "#eff1f5" || cur.AccentName != "red" {
t.Fatalf("Set tidak berlaku: %+v", cur)
}
Set("mode-rusak", "mauve") // fallback dark
if cur := Current(); cur.Mode != ModeDark {
t.Fatalf("mode rusak harus fallback dark: %+v", cur.Mode)
}
}