feat(theme): token semantik Catppuccin (Mocha/Latte/terminal) + mode auto via tea.BackgroundColorMsg + tombol T
CI / test (push) Successful in 11s
CI / test (push) Successful in 11s
This commit is contained in:
1 parent
c65d2cb75d
commit
8b2a86877e
13 files changed
+428
-37
No files matched your search
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user