feat: layar Pengaturan (PRD §5.1.5) + tombol Setting beranda + toggle suara
CI / test (push) Successful in 39s
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:
1 parent
e903912029
commit
2d07935f83
10 files changed
+655
-54
No files matched your search
+99
-4
@@ -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")
|
||||
|
||||
+31
-28
@@ -28,6 +28,7 @@ type viewKind int
|
||||
const (
|
||||
viewHome viewKind = iota
|
||||
viewList
|
||||
viewSettings
|
||||
)
|
||||
|
||||
type focusKind int
|
||||
@@ -99,10 +100,14 @@ type Model struct {
|
||||
lastFPSAt time.Time
|
||||
|
||||
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+76
-9
@@ -26,19 +26,18 @@ type Engine struct {
|
||||
muted bool
|
||||
ready chan struct{}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in new issue
Block a user