feat: layar Pengaturan (PRD §5.1.5) + tombol Setting beranda + toggle suara
CI / test (push) Successful in 39s

- 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).
This commit is contained in:
oxpoe committed 2026-10-16 04:07:33 +07:00
1 parent e903912029
commit 2d07935f83
10 files changed
+663 -62

No files matched your search

+99 -4
View File
@@ -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")
+35 -32
View File
@@ -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 {
+43 -13
View File
@@ -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
}
+3
View File
@@ -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)
}
+257
View File
@@ -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
}
+96
View File
@@ -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)
}
}
+80 -13
View File
@@ -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 {
+18
View File
@@ -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"
+18
View File
@@ -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"
+14
View File
@@ -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 ""