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 | | lipgloss | v2.0.6 | MIT |
| oto | v3.5.1 | Apache-2.0 | | oto | v3.5.1 | Apache-2.0 |
| harmonica | v0.2.0 | MIT | | harmonica | v0.2.0 | MIT |
| Catppuccin (data palet Mocha/Latte) | — | MIT |
| Docker SDK for Go (client) | v28.5.2+incompatible | Apache-2.0 | | Docker SDK for Go (client) | v28.5.2+incompatible | Apache-2.0 |
| docker/go-connections | v0.8.2 | Apache-2.0 | | docker/go-connections | v0.8.2 | Apache-2.0 |
| go-yaml v3 | v3.0.1 | MIT | | 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 | | 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) | | 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` | | 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 | | 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) { func TestEnglishLanguage(t *testing.T) {
u := startTUIEnv(t, "TIA_LANG=en") u := startTUIEnv(t, "TIA_LANG=en")
u.waitContains("· home", 5*time.Second) 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/sysinfo"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation" "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/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
) )
type viewKind int type viewKind int
@@ -72,6 +73,7 @@ func New() Model {
cfg.UI.Language = l cfg.UI.Language = l
} }
i18n.SetLang(cfg.UI.Language) i18n.SetLang(cfg.UI.Language)
theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent)
cl, err := docker.New() cl, err := docker.New()
host, _ := os.Hostname() host, _ := os.Hostname()
@@ -91,7 +93,7 @@ func New() Model {
loading: true, loading: true,
} }
if cfgErr != nil { 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 { if err != nil {
m.loadErr = err m.loadErr = err
@@ -102,7 +104,7 @@ func New() Model {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
if m.animating { if m.animating {
cmds = append(cmds, animation.Next(m.tickID)) 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 m.loading = false
if msg.err != nil { if msg.err != nil {
m.loadErr = msg.err 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) m.sound.Play(audio.NotifyError)
} else { } else {
m.loadErr = nil m.loadErr = nil
@@ -175,6 +177,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil 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: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height 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.loading = true
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl)) 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) 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) m.sound.Play(audio.NotifySuccess)
case "j", "down": case "j", "down":
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 { 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 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 { switch state {
case "running": case "running":
return "●", "#a6e3a1" return "●", th.Success
case "paused": case "paused":
return "⏸", "#f9e2af" return "⏸", th.Warning
case "restarting", "created": case "restarting", "created":
return "◌", "#89b4fa" return "◌", th.Info
case "exited", "dead": case "exited", "dead":
return "○", "#f38ba8" return "○", th.Danger
default: default:
return "○", "#6c7086" return "○", th.Overlay
} }
} }
// --- styling helpers --- // --- styling helpers (semua via token tema — F-asi §5.4) ---
func dim(s string) string { 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 { 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 { 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 { func styled(c theme.Color, s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(s) return theme.Style(c).Render(s)
} }
func truncate(s string, w int) string { 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 { if w < 6 || h < 2 {
return "" return ""
} }
bc := "#6c7086" th := theme.Current()
bc := th.Overlay
if focused { 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) titleS := text(title)
left := border.Render("╭─ ") + titleS left := border.Render("╭─ ") + titleS
@@ -382,8 +399,7 @@ func panel(title, meta string, lines []string, w, h int, focused bool) string {
// --- views --- // --- views ---
func (m Model) View() tea.View { func (m Model) View() tea.View {
header := lipgloss.NewStyle().Bold(true). header := theme.Style(theme.Current().Accent).Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
Render("tia — Terminal Is A My Way") Render("tia — Terminal Is A My Way")
var body, footer string var body, footer string
@@ -442,7 +458,7 @@ func (m Model) sidebarView() string {
glyph, color := stateStyle(c.State) glyph, color := stateStyle(c.State)
mark := " " mark := " "
if i == m.cursor { 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))) 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/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" "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: // homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
@@ -51,7 +52,7 @@ func (m Model) clockPanel(w, h int) string {
now := time.Now() now := time.Now()
rows := components.CenterBlock(components.RenderClock(now), w-4) rows := components.CenterBlock(components.RenderClock(now), w-4)
for i, r := range rows { 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()) tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year())
lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST"))) 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("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)), 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) return panel("Docker", i18n.T("host.local"), lines, w, h, false)
@@ -174,12 +175,13 @@ func memBar(pct, w int) string {
if fill > w { if fill > w {
fill = w fill = w
} }
color := "#a6e3a1" th := theme.Current()
color := th.Success
switch { switch {
case pct >= 85: case pct >= 85:
color = "#f38ba8" color = th.Danger
case pct >= 60: case pct >= 60:
color = "#f9e2af" color = th.Warning
} }
return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill)) 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 { 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"` SoundEnabled bool `yaml:"sound_enabled"`
SoundVolume int `yaml:"sound_volume"` // 0-100 SoundVolume int `yaml:"sound_volume"` // 0-100
SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"` SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"`
@@ -35,6 +37,8 @@ func Default() Config {
StacksDir: filepath.Join(home, ".tia", "stacks"), StacksDir: filepath.Join(home, ".tia", "stacks"),
UI: UI{ UI: UI{
Language: "id", Language: "id",
ThemeMode: "auto",
ThemeAccent: "mauve",
SoundEnabled: true, SoundEnabled: true,
SoundVolume: 70, SoundVolume: 70,
SoundMuteOnSSH: true, SoundMuteOnSSH: true,
@@ -102,6 +106,14 @@ func (c *Config) normalize() {
default: default:
c.UI.Language = "id" 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 { if c.UI.SoundVolume < 0 {
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") path := filepath.Join(t.TempDir(), "sub", "config.yaml")
in := Default() in := Default()
in.UI.Language = "en" in.UI.Language = "en"
in.UI.ThemeMode = "terminal"
in.UI.SoundVolume = 42 in.UI.SoundVolume = 42
in.StacksDir = "/data/stacks" 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" { if out.UI.Language != "en" || out.UI.SoundVolume != 42 || out.StacksDir != "/data/stacks" {
t.Fatalf("roundtrip salah: %+v", out) 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) { func TestPartialYamlKeepsDefaults(t *testing.T) {
@@ -66,6 +70,9 @@ func TestNormalizeInvalidValues(t *testing.T) {
if c.UI.Language != "id" { if c.UI.Language != "id" {
t.Fatalf("language rusak harus fallback id: %s", c.UI.Language) 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 { if c.UI.SoundVolume != 100 {
t.Fatalf("volume harus diklamp 100: %d", c.UI.SoundVolume) t.Fatalf("volume harus diklamp 100: %d", c.UI.SoundVolume)
} }
+10 -2
View File
@@ -16,6 +16,13 @@ section:
host: host:
local: "local" local: "local"
theme:
auto: "Auto"
dark: "Dark"
light: "Light"
terminal: "Terminal"
switchHint: "Applied instantly — no restart."
field: field:
id: "ID" id: "ID"
image: "Image" image: "Image"
@@ -51,8 +58,8 @@ hint:
noContainers: "No containers yet — run something with Docker, then press r." noContainers: "No containers yet — run something with Docker, then press r."
miniNoContainers: "no containers yet — press 1" miniNoContainers: "no containers yet — press 1"
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2." tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
footerHome: "[1/enter] containers · [t] toast · [a] reduced-motion · [q] quit" footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [q] quit" footerContainers: "[j/k] select · [r] reload · [0/esc] home · [T] theme · [q] quit"
footer: footer:
status: "%s animation %s fps · audio: %s" status: "%s animation %s fps · audio: %s"
@@ -62,6 +69,7 @@ toast:
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/" demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
dockerError: "✖ [ FAILED ] Docker" dockerError: "✖ [ FAILED ] Docker"
configError: "⚠ [ WARNING ] Config problem — using defaults" configError: "⚠ [ WARNING ] Config problem — using defaults"
themeMode: "Theme mode: %s"
activity: activity:
coming: "Docker events listener coming (Phase 2)." coming: "Docker events listener coming (Phase 2)."
+10 -2
View File
@@ -16,6 +16,13 @@ section:
host: host:
local: "lokal" local: "lokal"
theme:
auto: "Otomatis"
dark: "Gelap"
light: "Terang"
terminal: "Terminal"
switchHint: "Berlaku langsung — tanpa restart."
field: field:
id: "ID" id: "ID"
image: "Image" image: "Image"
@@ -51,8 +58,8 @@ hint:
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r." noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
miniNoContainers: "belum ada kontainer — tekan 1" miniNoContainers: "belum ada kontainer — tekan 1"
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2." tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
footerHome: "[1/enter] kontainer · [t] toast · [a] reduced-motion · [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 · [q] keluar" footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [T] tema · [q] keluar"
footer: footer:
status: "%s animasi %s fps · audio: %s" status: "%s animasi %s fps · audio: %s"
@@ -62,6 +69,7 @@ toast:
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/" demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
dockerError: "✖ [ GAGAL ] Docker" dockerError: "✖ [ GAGAL ] Docker"
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default" configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
themeMode: "Mode tema: %s"
activity: activity:
coming: "Listener Docker events menyusul (Phase 2)." coming: "Listener Docker events menyusul (Phase 2)."
+32 -4
View File
@@ -10,12 +10,23 @@ import (
"github.com/charmbracelet/harmonica" "github.com/charmbracelet/harmonica"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation" "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). // 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 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 type toastState int
const ( const (
@@ -29,6 +40,7 @@ type Toast struct {
spring harmonica.Spring spring harmonica.Spring
pos, vel float64 // baris offset: 0 = terlihat penuh, -toastHeight = tersembunyi pos, vel float64 // baris offset: 0 = terlihat penuh, -toastHeight = tersembunyi
state toastState state toastState
level Level
title string title string
body string body string
elapsed time.Duration elapsed time.Duration
@@ -44,7 +56,8 @@ func NewToast() Toast {
} }
// Show menampilkan toast baru (slide-in dari atas). // 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.title, t.body = title, body
t.elapsed = 0 t.elapsed = 0
t.state = toastIn 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 // View menggambar toast setinggi tetap (toastHeight baris, di-center
// horizontal); baris yang tergeser keluar area menjadi kosong. // horizontal); baris yang tergeser keluar area menjadi kosong.
func (t Toast) View(width int) string { func (t Toast) View(width int) string {
@@ -101,10 +128,11 @@ func (t Toast) View(width int) string {
return s return s
} }
th := theme.Current()
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#a6e3a1")). BorderForeground(lipgloss.Color(string(t.borderColor()))).
Foreground(lipgloss.Color("#cdd6f4")). Foreground(lipgloss.Color(string(th.Text))).
Width(inner). Width(inner).
Padding(0, 1) Padding(0, 1)
lines := strings.Split(style.Render(clip(t.title)+"\n"+clip(t.body)), "\n") 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)
}
}