Files
tia-love/internal/app/settings.go
T
oxpoe 2d07935f83
CI / test (push) Successful in 39s
feat: layar Pengaturan (PRD §5.1.5) + tombol Setting beranda + toggle suara
- 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).
2026-10-16 04:07:33 +07:00

258 lines
7.1 KiB
Go

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
}