From 2d07935f832e327bd444ed59c08fc4f67f5541f1 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:07:33 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20layar=20Pengaturan=20(PRD=20=C2=A75.1.5?= =?UTF-8?q?)=20+=20tombol=20Setting=20beranda=20+=20toggle=20suara?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layar Pengaturan (',' atau klik chip Pengaturan beranda): bahasa, mode tema, efek suara, volume, animasi, timestamp — semua berlaku langsung & tersimpan ke config (config.Save), plus path config + catatan. - Audio live: Engine.SetEnabled/SetVolume/Enabled/Volume; init idempoten (readyOnce/started) sehingga engine yang lahir mati bisa dinyalakan tanpa restart — komentar 'shortcut' F-024 lama dihapus. - Beranda: kolom tengah kini CTA + hint '[m] suara: …' (klik = toggle) + chip Pengaturan — draf dashboard lengkap. - Tombol lama 'T'/'a'/'S' kini lewat applySetting yang sama (DRY + persist). - TIA_LANG dihapus (tak perlu lagi): e2e memakai XDG_CONFIG_HOME temp (config bersih per tes) → tes tidak tergantung config pengguna. - Toast: klik di luar kotak toast tidak lagi menutupnya (hanya klik pada toast) — tombol lain tetap bisa diklik saat notifikasi tampil. - Tes: unit settings (apply/persist/jepit volume, baris plain==styled tanpa ANSI) + e2e TestSettingsScreen (klik chip, toggle, bahasa→Inggris, verifikasi config.yaml). --- e2e/e2e_test.go | 103 ++++++++++++- internal/app/app.go | 67 +++++---- internal/app/home.go | 56 +++++-- internal/app/layout_test.go | 3 + internal/app/settings.go | 257 ++++++++++++++++++++++++++++++++ internal/app/settings_test.go | 96 ++++++++++++ internal/audio/audio.go | 93 ++++++++++-- internal/i18n/locales/en.yaml | 18 +++ internal/i18n/locales/id.yaml | 18 +++ internal/ui/components/toast.go | 14 ++ 10 files changed, 663 insertions(+), 62 deletions(-) create mode 100644 internal/app/settings.go create mode 100644 internal/app/settings_test.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index deed708..308f92c 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -64,9 +64,26 @@ type tui struct { waited bool } -// startTUI memakai TIA_LANG=id eksplisit — deterministik walau mesin -// pengembang/testing punya config.yaml sendiri. -func startTUI(t *testing.T) *tui { return startTUIEnv(t, "TIA_LANG=id") } +// startTUI memakai config bersih di XDG temp (bahasa id) — hasil tes tidak +// bergantung config pengguna yang sebenarnya. +func startTUI(t *testing.T) *tui { + return startTUIEnv(t, "XDG_CONFIG_HOME="+writeTestConfig(t, "id")) +} + +// writeTestConfig — config.yaml minimal di XDG temp; kembalikan direktori XDG. +func writeTestConfig(t *testing.T, lang string) string { + t.Helper() + xdg := t.TempDir() + dir := filepath.Join(xdg, "tia") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + cfg := "version: 1\nui:\n language: " + lang + "\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + return xdg +} // envWith — gabung env proses + override tanpa duplikat key // (Linux getenv memakai kemunculan pertama, jadi duplikat berbahaya). @@ -166,6 +183,37 @@ func (u *tui) waitContains(substr string, d time.Duration) { u.t.Fatalf("layar tidak memuat %q dalam %v.\n--- snapshot layar ---\n%s", substr, d, u.screen()) } +// clickUntil — klik target berulang sampai substr tujuan muncul. Layar bisa +// tertinggal satu frame tepat saat toast menutup; klik ulang aman (klik yang +// tidak kena apa-apa tidak berefek). +func (u *tui) clickUntil(clickSubstr, want string, d time.Duration) { + u.t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if strings.Contains(u.screen(), want) { + return + } + if !u.clickText(clickSubstr) { + u.t.Fatalf("target klik %q tidak ditemukan", clickSubstr) + } + time.Sleep(120 * time.Millisecond) + } + u.waitContains(want, time.Second) // gagalkan dengan snapshot +} + +// waitGone — tunggu sampai substr hilang dari layar (mis. toast selesai). +func (u *tui) waitGone(substr string, d time.Duration) { + u.t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if !strings.Contains(u.screen(), substr) { + return + } + time.Sleep(50 * time.Millisecond) + } + u.t.Fatalf("layar masih memuat %q setelah %v", substr, d) +} + // waitHighlighted — menunggu baris yang memuat substr ber-latar (SGR bg), // yaitu penanda baris terpilih di daftar. func (u *tui) waitHighlighted(substr string, d time.Duration) { @@ -743,6 +791,53 @@ func TestWorkspaceBar(t *testing.T) { } } +// TestSettingsScreen — layar Pengaturan: tombol di beranda membuka, hint suara +// & toggle berlaku, ganti bahasa langsung + tersimpan ke config. +func TestSettingsScreen(t *testing.T) { + xdg := writeTestConfig(t, "id") + u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg) + u.waitContains("beranda", 5*time.Second) + + if !u.clickText("Pengaturan") { // chip di kolom tengah beranda + t.Fatal("tombol Pengaturan tidak ditemukan di beranda") + } + u.waitContains("Efek suara", 3*time.Second) + + // efek suara: nyala → mati (toast + baris) + if !u.clickText("nyala") { + t.Fatal("nilai efek suara tidak ditemukan") + } + u.waitContains("Efek suara: mati", 3*time.Second) + + // `,` tutup → kembali ke beranda; hint "[m] suara: mati" ikut berubah + u.send(",") + u.waitContains("suara: mati", 3*time.Second) + u.waitGone("Efek suara: mati", 6*time.Second) // tunggu toast tutup (layout stabil) + + // klik hint suara = toggle (mouse-native): mati → nyala + u.clickUntil("suara: mati", "suara: nyala", 6*time.Second) + + // `,` buka lagi; ganti bahasa → UI langsung Inggris + u.send(",") + u.waitContains("Konfigurasi", 3*time.Second) // penanda layar Pengaturan (bukan chip beranda) + u.clickUntil("Indonesia", "[ English ]", 6*time.Second) // nilai baris = penanda tetap + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } + + // perubahan tersimpan ke config + b, err := os.ReadFile(filepath.Join(xdg, "tia", "config.yaml")) + if err != nil { + t.Fatalf("config tidak terbaca: %v", err) + } + for _, want := range []string{"language: en", "sound_enabled: true"} { + if !strings.Contains(string(b), want) { + t.Fatalf("config tidak memuat %q:\n%s", want, b) + } + } +} + // TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas. func TestConvertCommand(t *testing.T) { out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput() @@ -872,7 +967,7 @@ func TestThemeCycle(t *testing.T) { } func TestEnglishLanguage(t *testing.T) { - u := startTUIEnv(t, "TIA_LANG=en") + u := startTUIEnv(t, "XDG_CONFIG_HOME="+writeTestConfig(t, "en")) u.waitContains("· home", 5*time.Second) u.waitContains("Clock", 5*time.Second) u.send("1") diff --git a/internal/app/app.go b/internal/app/app.go index bd30feb..dd94cbb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,6 +28,7 @@ type viewKind int const ( viewHome viewKind = iota viewList + viewSettings ) type focusKind int @@ -98,10 +99,14 @@ type Model struct { fps int lastFPSAt time.Time - view viewKind - section section - focus focusKind - tab int + view viewKind + prevView viewKind // tujuan tombol esc dari layar Pengaturan + section section + focus focusKind + tab int + + cfg config.Config // sumber pengaturan; disimpan saat diubah + settingsRow int // baris terpilih di layar Pengaturan searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item query string // isi pencarian; kosong = tidak menyaring @@ -177,11 +182,6 @@ type Model struct { func New() Model { cfg, cfgErr := config.Load() - // shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa - // tetap config (ui.language) — dihapus saat layar Pengaturan jadi. - if l := os.Getenv("TIA_LANG"); l != "" { - cfg.UI.Language = l - } i18n.SetLang(cfg.UI.Language) theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent) @@ -200,6 +200,7 @@ func New() Model { section: sectionStacks, focus: focusSidebar, tab: tabLogs, + cfg: cfg, hostname: host, sys: sysinfo.Read(), cl: cl, @@ -550,8 +551,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.modal != nil { return m, m.modalClick(msg.X, msg.Y) // klik masuk ke modal saja } - if m.toast.Active() { - m.toast = components.NewToast() // klik = tutup toast + if m.toast.Active() && m.toast.Hit(msg.X, msg.Y, max(m.width, 20)) { + m.toast = components.NewToast() // klik pada toast = tutup + return m, nil } // Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar, // kolom aksi tab, panel beranda. @@ -955,6 +957,9 @@ func (m Model) View() tea.View { case viewHome: header += dim(" · " + i18n.T("nav.home")) body = m.homeBody() + case viewSettings: + header += dim(" · " + i18n.T("settings.title")) + body = m.settingsBody() default: header += dim(" · " + strings.ToLower(m.sectionName())) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) @@ -1144,6 +1149,9 @@ func hintStyledText(hs []hint, dropped bool, bg theme.Color) string { } func (m Model) hintText() string { + if m.view == viewSettings { + return i18n.T("hint.footerSettings") + } if m.view == viewList { if m.section == sectionStacks { return i18n.T("hint.footerStacks") @@ -1202,6 +1210,10 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { } return nil, false } + // 1b) Layar Pengaturan: baris opsi + zona volume. + if m.view == viewSettings { + return m.settingsClick(x, y), true + } // 2) Bar workspace (di bawah header): cari + tombol stack + tautan seksi. if m.view == viewList && y >= m.headerRows()-2 && y <= m.headerRows()-1 { return m.workspaceBarKeyAt(x, y), true @@ -1989,6 +2001,10 @@ func colorizeLog(line string) string { // handleKey — SATU jalur aksi, dipakai keyboard DAN klik mouse // (tab bar, hint footer, baris beranda). Tambah aksi = tambah case di sini. func (m *Model) handleKey(key string) tea.Cmd { + // Layar Pengaturan menangkap tombolnya sendiri. + if m.view == viewSettings { + return m.settingsKey(key) + } // Konfirmasi destruktif (turunkan stack) menangkap input lebih dulu. if m.stackConfirm != "" { switch key { @@ -2053,12 +2069,8 @@ func (m *Model) handleKey(key string) tea.Cmd { m.jumpSection(sectionNetworks) m.sound.Play(audio.Click) return m.restartData() - case "a": // reduced motion - m.animating = !m.animating - if m.animating { - m.tickID = animation.NewID() - return animation.Next(m.tickID) - } + case "a": // reduced motion (tersimpan lewat applySetting) + return m.applySetting("animation", 0) case "r": // muat ulang semua data docker + stream event if m.cl == nil { break @@ -2071,13 +2083,11 @@ func (m *Model) handleKey(key string) tea.Cmd { cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) return tea.Batch(cmds...) 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) + return m.applySetting("theme", 1) + case "m": // tombol cepat efek suara (mock beranda: "shortcut on/off sound") + return m.applySetting("sound", 0) + case ",": // layar Pengaturan (PRD §5.1.5) + m.openSettings() case "t": // demo toast (sampai modal asli datang) m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody")) m.sound.Play(audio.NotifySuccess) @@ -2128,14 +2138,7 @@ func (m *Model) handleKey(key string) tea.Cmd { m.sound.Play(audio.Click) } case "S": // toggle timestamp log - m.showTimestamps = !m.showTimestamps - label := i18n.T("state.off") - if m.showTimestamps { - label = i18n.T("state.on") - } - m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint")) - m.sound.Play(audio.Click) - return m.restartData() + return m.applySetting("timestamps", 0) case "j", "down": if m.view == viewList && m.focus == focusContent { switch m.tab { diff --git a/internal/app/home.go b/internal/app/home.go index e09f21e..c99dc86 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -59,26 +59,33 @@ func (m Model) homeBody() string { m.miniContainerLines(row2H-2), k1, row2H, false), m.disclaimerPanel(k1, stripH), ) - mid, _, _ := m.centerColumn(cw, row2H+stripH) + mid := m.centerColumn(cw, row2H+stripH) right := lipgloss.JoinVertical(lipgloss.Left, panel(i18n.T("section.activity"), fmt.Sprintf("(%d)", len(m.activity)), m.activityLines(k2-4, row2H-2), k2, row2H, false), m.appsPanel(k2, stripH), ) - return row1 + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Join(mid, "\n"), right) + return row1 + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Join(mid.lines, "\n"), right) } -// centerColumn — kolom tengah: kotak CTA "masuk workspace" (klikable; paritas -// dengan tombol yang tampil). Kembalikan juga posisi kotak untuk hit-test. -func (m Model) centerColumn(w, h int) (lines []string, ctaTop, ctaH int) { +// homeCenter — isi kolom tengah beranda + posisi zona klik (satu sumber). +type homeCenter struct { + lines []string + ctaTop int // kotak CTA "masuk workspace" (klik → workspace) + ctaH int + hintTop int // "[m] suara: …" (klik → toggle efek suara) + settingTop int // tombol Pengaturan (klik → layar Pengaturan) +} + +// centerColumn — kolom tengah: kotak CTA, hint suara, tombol Pengaturan. +func (m Model) centerColumn(w, h int) homeCenter { th := theme.Current() border := theme.Style(th.Accent) label := theme.Style(th.Accent).Bold(true) inner := max(w-4, 8) rows := wrapText(i18n.T("home.enterWorkspace"), inner) - ctaH = len(rows) + 2 box := []string{border.Render("╭" + strings.Repeat("─", w-2) + "╮")} for _, r := range rows { pad := inner - lipgloss.Width(r) @@ -88,18 +95,36 @@ func (m Model) centerColumn(w, h int) (lines []string, ctaTop, ctaH int) { } box = append(box, border.Render("╰"+strings.Repeat("─", w-2)+"╯")) - ctaTop = max((h-ctaH)/2, 0) - lines = make([]string, 0, h) + hint := truncate(fmt.Sprintf("[m] %s: %s", i18n.T("home.soundHint"), onOffText(m.sound.Enabled())), w) + setting := components.Chip(i18n.T("settings.title")) + + block := append([]string{}, box...) + block = append(block, "", hint, setting) + top := max((h-len(block))/2, 0) + + lines := make([]string, 0, h) for i := 0; i < h; i++ { - idx := i - ctaTop + idx := i - top switch { - case idx >= 0 && idx < ctaH: + case idx >= 0 && idx < len(box): lines = append(lines, box[idx]) + case idx == len(box): + lines = append(lines, strings.Repeat(" ", w)) + case idx == len(box)+1: + lines = append(lines, lipgloss.PlaceHorizontal(w, lipgloss.Center, dim(hint))) + case idx == len(box)+2: + lines = append(lines, lipgloss.PlaceHorizontal(w, lipgloss.Center, setting)) default: lines = append(lines, strings.Repeat(" ", w)) } } - return + return homeCenter{ + lines: lines, + ctaTop: top, + ctaH: len(box), + hintTop: top + len(box) + 1, + settingTop: top + len(box) + 2, + } } // disclaimerPanel — strip peringatan aksi destruktif. @@ -145,7 +170,7 @@ func (m *Model) homeClick(x, y int) tea.Cmd { top := m.headerRows() + row1H // baris pertama area bawah first := top + 1 // konten baris pertama (daftar kontainer) - _, ctaTop, ctaH := m.centerColumn(cw, row2H+stripH) + hc := m.centerColumn(cw, row2H+stripH) switch { case y >= first && y < first+max(row2H-2, 0) && x < k1: idx := y - first @@ -160,10 +185,15 @@ func (m *Model) homeClick(x, y int) tea.Cmd { m.jumpSection(sectionContainers) m.sound.Play(audio.Click) return m.restartData() - case y >= top+ctaTop && y < top+ctaTop+ctaH && x >= k1 && x < k1+cw: + case y >= top+hc.ctaTop && y < top+hc.ctaTop+hc.ctaH && x >= k1 && x < k1+cw: m.jumpSection(sectionStacks) m.sound.Play(audio.Click) return m.restartData() + case y == top+hc.hintTop && x >= k1 && x < k1+cw: // "[m] suara: …" = toggle + return m.applySetting("sound", 0) + case y == top+hc.settingTop && x >= k1 && x < k1+cw: // tombol Pengaturan + m.openSettings() + return nil } return nil } diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index 4de7965..919ec91 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -34,6 +34,9 @@ func TestWorkspaceBar(t *testing.T) { for _, width := range []int{40, 80, 120} { m := Model{width: width, view: viewList} subLine, searchLine := m.workspaceBarPlain() + if strings.ContainsRune(subLine, 0x1b) || strings.ContainsRune(searchLine, 0x1b) { + t.Fatalf("w=%d: baris plain tidak boleh memuat ANSI", width) + } if !strings.Contains(subLine, i18n.T("toolbar.addStack")) || !strings.Contains(subLine, i18n.T("toolbar.convert")) { t.Fatalf("w=%d: tombol stack/konversi tidak ada: %q", width, subLine) } diff --git a/internal/app/settings.go b/internal/app/settings.go new file mode 100644 index 0000000..519600a --- /dev/null +++ b/internal/app/settings.go @@ -0,0 +1,257 @@ +package app + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "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" +) + +// Layar Pengaturan (PRD §5.1.5) — dibuka dengan `,` atau tombol Pengaturan di +// beranda. Semua perubahan berlaku langsung dan tersimpan ke config (tanpa +// restart, tanpa edit file manual). +// +// shortcut: baris teks (bukan form huh) + opsi inti dulu; warna aksen, +// direktori stack, mute-SSH menyusul. + +// settingsRowIDs — urutan baris pengaturan. +var settingsRowIDs = []string{"language", "theme", "sound", "volume", "animation", "timestamps"} + +// settingsEnv — terjemahan label baris. +func (m Model) settingsEnv(id string) (label, value string) { + switch id { + case "language": + value = "Indonesia" + if m.cfg.UI.Language == "en" { + value = "English" + } + case "theme": + value = i18n.T("theme." + string(theme.Current().Mode)) + case "sound": + value = onOffText(m.sound.Enabled()) + case "volume": + value = fmt.Sprintf("%d%%", m.sound.Volume()) + case "animation": + value = onOffText(m.animating) + case "timestamps": + value = onOffText(m.showTimestamps) + } + return i18n.T("settings." + id), value +} + +func onOffText(on bool) string { + if on { + return i18n.T("state.on") + } + return i18n.T("state.off") +} + +// settingsRowSegments — satu baris sebagai segmen PLAIN (tanpa ANSI!): satu +// sumber untuk render, teks hit-test klik, dan zona tombol (- / +). Escape +// warna tidak boleh masuk ke sini — zona klik dihitung dari teks polos. +func (m Model) settingsRowSegments(i int) []string { + label, value := m.settingsEnv(settingsRowIDs[i]) + label = fmt.Sprintf("%-*s", 16, label) + if settingsRowIDs[i] == "volume" { + return []string{label, "[ - ]", value, "[ + ]"} + } + return []string{label, "[ " + value + " ]"} +} + +func (m Model) settingsRowPlain(i int) string { + return strings.Join(m.settingsRowSegments(i), " ") +} + +func (m Model) settingsRowLine(i, w int) string { + th := theme.Current() + sel := i == m.settingsRow + segs := m.settingsRowSegments(i) + out := make([]string, 0, len(segs)) + for k, s := range segs { + c := th.Accent + switch { + case k == 0: + c = th.Subtext + case settingsRowIDs[i] == "volume" && k == 2: + c = th.Text + } + out = append(out, paintRow(sel, c, s)) + } + line := strings.Join(out, " ") + if sel { + line = selPad(line, w) + } + return line +} + +// settingsBody — layar penuh: panel daftar opsi + info config. +func (m Model) settingsBody() string { + w, h := m.width, m.bodyHeight() + lines := []string{} + for i := range settingsRowIDs { + lines = append(lines, m.settingsRowLine(i, w-4)) + } + lines = append(lines, "", + fieldW(i18n.T("settings.config"), 16)+" "+dim(prettyPath(config.Path())), + dim(i18n.T("settings.applyNote")), + ) + return panel(i18n.T("settings.title"), "esc", lines, w, h, true) +} + +func (m *Model) openSettings() { + if m.view != viewSettings { + m.prevView = m.view + } + m.view = viewSettings + m.settingsRow = 0 + m.sound.Play(audio.ModalOpen) +} + +// applySetting — ubah satu opsi: efek langsung + simpan ke config. +// delta: 0 = aktifkan (toggle/maju), -1 = mundur (pilihan), ±10 = volume. +func (m *Model) applySetting(id string, delta int) tea.Cmd { + th := theme.Current() + switch id { + case "language": + next := "en" + if m.cfg.UI.Language == "en" { + next = "id" + } + m.cfg.UI.Language = next + i18n.SetLang(next) + name := "Indonesia" + if next == "en" { + name = "English" + } + m.toast.Show(components.LevelInfo, i18n.Tf("toast.language", name), i18n.T("theme.switchHint")) + case "theme": + next := theme.CycleMode(th.Mode) + if delta < 0 { + next = cycleBack(th.Mode) + } + m.cfg.UI.ThemeMode = string(next) + theme.Set(next, th.AccentName) + m.toast.Show(components.LevelInfo, + i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))), + i18n.T("theme.switchHint")) + case "sound": + on := !m.sound.Enabled() + m.sound.SetEnabled(on) + m.cfg.UI.SoundEnabled = on + m.toast.Show(components.LevelInfo, i18n.Tf("toast.sound", onOffText(on)), "") + case "volume": + if delta == 0 { + break + } + v := min(max(m.sound.Volume()+delta, 0), 100) + m.sound.SetVolume(v) + m.cfg.UI.SoundVolume = v + m.toast.Show(components.LevelInfo, i18n.Tf("toast.volume", v), "") + case "animation": + m.animating = !m.animating + m.cfg.UI.AnimationEnabled = m.animating + m.toast.Show(components.LevelInfo, i18n.Tf("toast.animation", onOffText(m.animating)), "") + if m.animating { + m.tickID = animation.NewID() + m.saveCfg() + m.sound.Play(audio.Click) + return animation.Next(m.tickID) + } + case "timestamps": + m.showTimestamps = !m.showTimestamps + m.cfg.UI.ShowTimestamps = m.showTimestamps + m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", onOffText(m.showTimestamps)), "") + m.saveCfg() + m.sound.Play(audio.Click) + return m.restartData() + default: + return nil + } + m.saveCfg() + m.sound.Play(audio.Click) + return nil +} + +// cycleBack — mode tema mundur (auto ← dark ← light ← terminal). +func cycleBack(mode theme.Mode) theme.Mode { + order := []theme.Mode{theme.ModeAuto, theme.ModeDark, theme.ModeLight, theme.ModeTerminal} + for i, mm := range order { + if mm == mode { + return order[(i+len(order)-1)%len(order)] + } + } + return theme.ModeAuto +} + +// saveCfg — tulis config; gagal = toast (perubahan tetap hidup di sesi ini). +func (m *Model) saveCfg() { + if err := config.Save(m.cfg); err != nil { + m.toast.Show(components.LevelWarning, i18n.T("toast.configSave"), err.Error()) + } +} + +// settingsKey — tombol di layar Pengaturan (layar ini menangkap sendiri). +func (m *Model) settingsKey(key string) tea.Cmd { + last := len(settingsRowIDs) - 1 + switch key { + case "esc", ",": + m.view = m.prevView + m.sound.Play(audio.ModalClose) + case "j", "down": + m.settingsRow = min(m.settingsRow+1, last) + m.sound.Play(audio.Navigate) + case "k", "up": + m.settingsRow = max(m.settingsRow-1, 0) + m.sound.Play(audio.Navigate) + case "enter", "space": + return m.applySetting(settingsRowIDs[m.settingsRow], 0) + case "l", "right", "]": + delta := 1 + if settingsRowIDs[m.settingsRow] == "volume" { + delta = 10 + } + return m.applySetting(settingsRowIDs[m.settingsRow], delta) + case "h", "left", "[": + delta := -1 + if settingsRowIDs[m.settingsRow] == "volume" { + delta = -10 + } + return m.applySetting(settingsRowIDs[m.settingsRow], delta) + case "q", "ctrl+c": + m.stopLogs() + if m.eventsCancel != nil { + m.eventsCancel() + } + m.sound.Play(audio.ModalClose) + return tea.Quit + } + return nil +} + +// settingsClick — klik baris: pilih + aktifkan; zona [-] [+] = volume. +func (m *Model) settingsClick(x, y int) tea.Cmd { + i := y - (m.headerRows() + 1) + if i < 0 || i >= len(settingsRowIDs) { + return nil + } + m.settingsRow = i + if label, ok := labelZoneAt(m.settingsRowPlain(i), x-2); ok { + switch label { + case "-": + return m.applySetting(settingsRowIDs[i], -10) + case "+": + return m.applySetting(settingsRowIDs[i], 10) + default: + return m.applySetting(settingsRowIDs[i], 0) + } + } + m.sound.Play(audio.Click) + return nil +} diff --git a/internal/app/settings_test.go b/internal/app/settings_test.go new file mode 100644 index 0000000..460d3df --- /dev/null +++ b/internal/app/settings_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "os" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" + "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" +) + +// TestSettingsApply — setiap perubahan pengaturan berlaku + tersimpan ke config +// (tanpa restart, tanpa edit file); volume dijepit 0-100. +func TestSettingsApply(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := Model{ + cfg: config.Default(), + sound: audio.New(audio.Options{Enabled: true, Volume: 70}), + toast: components.NewToast(), + } + + // volume: +10 lalu jepit di 100 + m.applySetting("volume", 10) + if got := m.sound.Volume(); got != 80 { + t.Fatalf("volume = %d, mau 80", got) + } + m.sound.SetVolume(95) + m.cfg.UI.SoundVolume = 95 + m.applySetting("volume", 10) + if got := m.sound.Volume(); got != 100 { + t.Fatalf("volume harus dijepit 100, dapat %d", got) + } + + // efek suara: nyala → mati + m.applySetting("sound", 0) + if m.sound.Enabled() { + t.Fatal("efek suara harus mati setelah toggle") + } + + // bahasa: id → en (katalog aktif ikut berubah) + m.applySetting("language", 0) + if m.cfg.UI.Language != "en" { + t.Fatalf("bahasa config = %q, mau en", m.cfg.UI.Language) + } + if got := i18n.T("settings.language"); got != "Language" { + t.Fatalf("katalog belum ikut ganti: %q", got) + } + i18n.SetLang("id") // kembalikan default untuk tes lain + + // tema mundur: auto ← terminal + if got := cycleBack(theme.ModeAuto); got != theme.ModeTerminal { + t.Fatalf("cycleBack(auto) = %q", got) + } + + // semua tercatat di config.yaml + b, err := os.ReadFile(config.Path()) + if err != nil { + t.Fatalf("config tidak terbaca: %v", err) + } + for _, want := range []string{"language: en", "sound_enabled: false", "sound_volume: 100"} { + if !strings.Contains(string(b), want) { + t.Fatalf("config tidak memuat %q:\n%s", want, b) + } + } +} + +// TestSettingsRowGeometry — teks baris (setelah ANSI dibuang) harus sama dengan +// teks plain yang dipakai hit-test klik, termasuk zona volume - / +. +func TestSettingsRowGeometry(t *testing.T) { + m := Model{cfg: config.Default(), sound: audio.New(audio.Options{Volume: 70}), settingsRow: -1} + for i := range settingsRowIDs { + plain := m.settingsRowPlain(i) + if plain == "" { + t.Fatalf("baris %d kosong", i) + } + if strings.ContainsRune(plain, 0x1b) { + t.Fatalf("baris %d (plain) tidak boleh memuat ANSI: %q", i, plain) + } + if got := ansi.Strip(m.settingsRowLine(i, 60)); got != plain { + t.Fatalf("baris %d: styled=%q plain=%q", i, got, plain) + } + } + m.settingsRow = 0 + if got := ansi.Strip(m.settingsRowLine(0, 60)); !strings.HasPrefix(got, m.settingsRowPlain(0)) { + t.Fatalf("baris terpilih tidak selaras: %q", got) + } + vol := m.settingsRowPlain(3) + if !strings.Contains(vol, "[ - ]") || !strings.Contains(vol, "[ + ]") { + t.Fatalf("baris volume tanpa zona -/+: %q", vol) + } +} diff --git a/internal/audio/audio.go b/internal/audio/audio.go index 4f969be..d415278 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -26,19 +26,18 @@ type Engine struct { muted bool ready chan struct{} - mu sync.RWMutex - ctx *oto.Context - sounds map[Event][]byte - initErr error + readyOnce sync.Once + mu sync.RWMutex + ctx *oto.Context + sounds map[Event][]byte + initErr error + started bool } const initTimeout = 3 * time.Second // New membuat Engine dan — bila aktif — memulai init audio di goroutine // terpisah sehingga waktu startup aplikasi tidak terpengaruh. -// -// shortcut: engine yang disabled/muted tidak dapat diaktifkan ulang tanpa -// restart; upgrade saat layar Pengaturan (Phase 1, F-024) butuh toggle langsung. func New(opts Options) *Engine { e := &Engine{ opts: opts, @@ -48,18 +47,32 @@ func New(opts Options) *Engine { switch { case !opts.Enabled: e.setErr(errors.New("efek suara dimatikan")) - close(e.ready) + e.closeReady() case e.muted: e.setErr(errors.New("auto-mute: sesi SSH")) - close(e.ready) + e.closeReady() default: - go e.init() + e.start() } return e } +// start — jalankan init sekali saja (New dan SetEnabled memakai jalur ini). +func (e *Engine) start() { + e.mu.Lock() + if e.started { + e.mu.Unlock() + return + } + e.started = true + e.mu.Unlock() + go e.init() +} + +func (e *Engine) closeReady() { e.readyOnce.Do(func() { close(e.ready) }) } + func (e *Engine) init() { - defer close(e.ready) + defer e.closeReady() sounds, err := loadSounds() if err != nil { @@ -96,6 +109,56 @@ func (e *Engine) setErr(err error) { e.mu.Unlock() } +// SetEnabled menyalakan/mematikan efek suara saat runtime (layar Pengaturan, +// F-024). Menyalakan engine yang lahir mati memicu init sekali; engine yang +// sudah gagal init tidak dicoba ulang. +func (e *Engine) SetEnabled(on bool) { + if e == nil { + return + } + e.mu.Lock() + e.opts.Enabled = on + e.mu.Unlock() + if on { + e.start() + } +} + +// SetVolume mengatur volume 0-100 (langsung berlaku untuk Play berikutnya). +func (e *Engine) SetVolume(v int) { + if e == nil { + return + } + if v < 0 { + v = 0 + } else if v > 100 { + v = 100 + } + e.mu.Lock() + e.opts.Volume = v + e.mu.Unlock() +} + +// Enabled melaporkan status on/off efek suara. +func (e *Engine) Enabled() bool { + if e == nil { + return false + } + e.mu.RLock() + defer e.mu.RUnlock() + return e.opts.Enabled +} + +// Volume mengembalikan volume aktif (0-100). +func (e *Engine) Volume() int { + if e == nil { + return 0 + } + e.mu.RLock() + defer e.mu.RUnlock() + return e.opts.Volume +} + // Ready melaporkan apakah audio benar-benar siap memutar suara. func (e *Engine) Ready() bool { e.mu.RLock() @@ -147,16 +210,20 @@ func (e *Engine) Status() string { // Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun. // No-op bila engine belum siap / dimatikan / muted. func (e *Engine) Play(ev Event) { - if e == nil || e.muted || !e.opts.Enabled { + if e == nil { return } e.mu.RLock() + if e.muted || !e.opts.Enabled { + e.mu.RUnlock() + return + } ctx, pcm := e.ctx, e.sounds[ev] + vol := float64(e.opts.Volume) / 100 e.mu.RUnlock() if ctx == nil || len(pcm) == 0 { return } - vol := float64(e.opts.Volume) / 100 if vol < 0 { vol = 0 } else if vol > 1 { diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index cd2e479..cfc5e5c 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -23,8 +23,20 @@ section: home: enterWorkspace: "enter workspace" + soundHint: "sound" disclaimer: "Destructive actions (bring a stack down, delete resources) cannot be undone — double-check." +settings: + title: "Settings" + language: "Language" + theme: "Theme mode" + sound: "Sound effects" + volume: "Volume" + animation: "Animation" + timestamps: "Log timestamps" + config: "Config" + applyNote: "Changes apply immediately and are saved automatically." + toolbar: search: "Search" addStack: "+ stack" @@ -150,6 +162,7 @@ hint: footerHome: "[1/enter] stacks · [t] toast · [T] theme · [c] convert · [a] reduced-motion · [q] quit" footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [j/k] select · [/] search · [0] home · [q] quit" footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit" + footerSettings: "[j/k] select · [enter] change · [h/l] adjust · [esc] close · [q] quit" footer: statusShort: "anim %s fps · audio: %s" @@ -161,6 +174,11 @@ toast: configError: "⚠ [ WARNING ] Config problem — using defaults" themeMode: "Theme mode: %s" timestamps: "Log timestamps: %s" + language: "Language: %s" + sound: "Sound effects: %s" + volume: "Volume: %d%%" + animation: "Animation: %s" + configSave: "Failed to save config" stackOk: "Stack '%s': %s done" stackFail: "Stack '%s' failed" stackSaved: "Stack '%s' saved" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 1a4b4b8..43d0ca8 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -23,8 +23,20 @@ section: home: enterWorkspace: "masuk workspace" + soundHint: "suara" disclaimer: "Aksi destruktif (turunkan stack, hapus resource) tidak bisa dibatalkan — periksa dua kali." +settings: + title: "Pengaturan" + language: "Bahasa" + theme: "Mode tema" + sound: "Efek suara" + volume: "Volume" + animation: "Animasi" + timestamps: "Timestamp log" + config: "Konfigurasi" + applyNote: "Perubahan berlaku langsung dan tersimpan otomatis." + toolbar: search: "Cari" addStack: "+ stack" @@ -150,6 +162,7 @@ hint: footerHome: "[1/enter] stack · [t] toast · [T] tema · [c] konversi · [a] reduced-motion · [q] keluar" footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [j/k] pilih · [/] cari · [0] beranda · [q] keluar" footerContainers: "[1-5] bagian · [j/k] pilih · [/] cari · [Tab] fokus · [h/l] tab · [T] tema · [r] muat ulang · [0] beranda · [q] keluar" + footerSettings: "[j/k] pilih · [enter] ubah · [h/l] geser · [esc] tutup · [q] keluar" footer: statusShort: "animasi %s fps · audio: %s" @@ -161,6 +174,11 @@ toast: configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default" themeMode: "Mode tema: %s" timestamps: "Timestamp log: %s" + language: "Bahasa: %s" + sound: "Efek suara: %s" + volume: "Volume: %d%%" + animation: "Animasi: %s" + configSave: "Gagal menyimpan config" stackOk: "Stack '%s': %s selesai" stackFail: "Stack '%s' gagal" stackSaved: "Stack '%s' tersimpan" diff --git a/internal/ui/components/toast.go b/internal/ui/components/toast.go index 6626b78..9c1f907 100644 --- a/internal/ui/components/toast.go +++ b/internal/ui/components/toast.go @@ -108,6 +108,20 @@ func (t Toast) borderColor() theme.Color { // View menggambar toast setinggi tetap (toastHeight baris, di-center // horizontal); baris yang tergeser keluar area menjadi kosong. +// Hit — apakah klik (x, y) kena kotak toast? Klik di luar toast tidak boleh +// tertelan: pengguna sedang mengklik tombol lain saat notifikasi masih tampil. +func (t Toast) Hit(x, y, width int) bool { + if !t.Active() || y < 0 || y >= toastHeight { + return false + } + boxW := 46 + if boxW > width-2 { + boxW = width - 2 + } + x0 := (width - boxW) / 2 + return x >= x0 && x < x0+boxW +} + func (t Toast) View(width int) string { if t.state == toastHidden { return ""