diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8c1036f..c402cde 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/docs/ASSETS.md b/docs/ASSETS.md index f14eb26..47c5e11 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 | diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index baf6e1a..f3f1938 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index a096708..4813f04 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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))) } diff --git a/internal/app/home.go b/internal/app/home.go index d81a76f..c3b1a12 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -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)) } diff --git a/internal/config/config.go b/internal/config/config.go index 675f4d4..47416ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 04c6c37..8b3b957 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 51f5446..112e47e 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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)." diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 7e8f69f..d346053 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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)." diff --git a/internal/ui/components/toast.go b/internal/ui/components/toast.go index 61c38c7..6626b78 100644 --- a/internal/ui/components/toast.go +++ b/internal/ui/components/toast.go @@ -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") diff --git a/internal/ui/theme/palette.go b/internal/ui/theme/palette.go new file mode 100644 index 0000000..74cc917 --- /dev/null +++ b/internal/ui/theme/palette.go @@ -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"), + } +} diff --git a/internal/ui/theme/theme.go b/internal/ui/theme/theme.go new file mode 100644 index 0000000..9ecd73f --- /dev/null +++ b/internal/ui/theme/theme.go @@ -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 + } +} diff --git a/internal/ui/theme/theme_test.go b/internal/ui/theme/theme_test.go new file mode 100644 index 0000000..c7904d8 --- /dev/null +++ b/internal/ui/theme/theme_test.go @@ -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) + } +}