Files
tia-love/internal/app/home.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

352 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"os"
"runtime"
"strings"
"time"
"charm.land/lipgloss/v2"
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/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
)
// homeGeom — geometri dashboard (satu sumber untuk render & hit-test klik).
// Baris: 3 kartu atas · area bawah (Kontainer | CTA | Aktivitas) · strip bawah.
func (m Model) homeGeom() (W, row1H, stripH, row2H, cw, k1, k2 int) {
W = max(m.width, 60)
bodyH := m.bodyHeight()
row1H, stripH = 9, 5
row2H = bodyH - row1H - stripH
if row2H < 5 {
row1H, stripH = 6, 4
row2H = max(bodyH-row1H-stripH, 3)
}
cw = min(max(int(float64(W)*0.20), 18), 26) // kolom tengah: CTA masuk workspace
k1 = (W - cw) / 2
k2 = W - cw - k1
return
}
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador dengan
// kolom tengah CTA ala mock pengguna: kartu Jam · Engine · Host di atas,
// Kontainer | CTA | Aktivitas di tengah, strip Disclaimer & Info aplikasi di bawah.
func (m Model) homeBody() string {
W, row1H, stripH, row2H, cw, k1, k2 := m.homeGeom()
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
w3 := W - w1 - w2
row1 := lipgloss.JoinHorizontal(lipgloss.Top,
m.clockPanel(w1, row1H),
m.dockerPanel(w2, row1H),
m.hostPanel(w3, row1H),
)
left := lipgloss.JoinVertical(lipgloss.Left,
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
m.miniContainerLines(row2H-2), k1, row2H, false),
m.disclaimerPanel(k1, 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.lines, "\n"), right)
}
// 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)
box := []string{border.Render("╭" + strings.Repeat("─", w-2) + "╮")}
for _, r := range rows {
pad := inner - lipgloss.Width(r)
l := max(pad/2, 0)
box = append(box, border.Render("│")+" "+strings.Repeat(" ", l)+
label.Render(r)+strings.Repeat(" ", max(pad-l, 0))+" "+border.Render("│"))
}
box = append(box, border.Render("╰"+strings.Repeat("─", w-2)+"╯"))
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 - top
switch {
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 homeCenter{
lines: lines,
ctaTop: top,
ctaH: len(box),
hintTop: top + len(box) + 1,
settingTop: top + len(box) + 2,
}
}
// disclaimerPanel — strip peringatan aksi destruktif.
func (m Model) disclaimerPanel(w, h int) string {
rows := wrapText(i18n.T("home.disclaimer"), max(w-4, 8))
if len(rows) > h-2 {
rows = rows[:h-2]
}
return panel(i18n.T("section.disclaimer"), "", rows, w, h, false)
}
const repoURL = "gitea.oxpoe.qzz.io/oxpoe/tia-love"
// appsPanel — strip info aplikasi: versi, repo, lokasi config.
func (m Model) appsPanel(w, h int) string {
rows := []string{
fieldW(i18n.T("field.version"), 7) + dim(truncate(version.String(), w-9)),
fieldW(i18n.T("field.repo"), 7) + dim(truncate(repoURL, w-9)),
fieldW(i18n.T("field.config"), 7) + dim(truncate(prettyPath(config.Path()), w-9)),
}
if len(rows) > h-2 {
rows = rows[:h-2]
}
return panel(i18n.T("section.appsInfo"), "", rows, w, h, false)
}
// prettyPath — tampilkan path config dengan "~" untuk home pengguna.
func prettyPath(p string) string {
home, err := os.UserHomeDir()
if err == nil && home != "" && strings.HasPrefix(p, home+"/") {
return "~" + strings.TrimPrefix(p, home)
}
return p
}
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
// panel Engine → bagian kontainer; kotak CTA → workspace (Stacks).
func (m *Model) homeClick(x, y int) tea.Cmd {
W, row1H, stripH, row2H, cw, k1, _ := m.homeGeom()
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
top := m.headerRows() + row1H // baris pertama area bawah
first := top + 1 // konten baris pertama (daftar kontainer)
hc := m.centerColumn(cw, row2H+stripH)
switch {
case y >= first && y < first+max(row2H-2, 0) && x < k1:
idx := y - first
if len(m.containers) == 0 || idx >= len(m.containers) {
return nil
}
m.jumpSection(sectionContainers)
m.setCur(idx)
m.sound.Play(audio.Click)
return m.restartData()
case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2:
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m.restartData()
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
}
func (m Model) clockPanel(w, h int) string {
now := time.Now()
rows := components.CenterBlock(components.RenderClock(now), w-4)
for i, r := range rows {
rows[i] = theme.Style(theme.Current().Accent).Bold(true).Render(r)
}
tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year())
lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST")))
if len(lines) > h-2 {
lines = lines[:h-2]
}
return panel(i18n.T("section.clock"), "", lines, w, h, false)
}
// engineTitle — judul panel runtime (Docker/Podman) sesuai hasil deteksi.
func (m Model) engineTitle() string {
if m.engineName == "podman" {
return "Podman"
}
return "Docker"
}
func (m Model) dockerPanel(w, h int) string {
var lines []string
switch {
case m.engineErr != nil:
lines = append(lines, red(i18n.T("error.fail")), "", dim(truncate(m.engineErr.Error(), w-6)))
case m.engine.ServerVersion == "":
lines = append(lines, dim(i18n.T("state.loading")))
default:
lines = append(lines,
fieldW(i18n.T("field.engine"), 9)+dim(m.engine.ServerVersion),
fieldW(i18n.T("field.api"), 9)+dim(m.engine.APIVersion),
fieldW(i18n.T("section.containers"), 9)+dim(i18n.Tf("fmt.dockerContainers", m.engine.Running, m.engine.Containers)),
fieldW(i18n.T("field.image"), 9)+dim(fmt.Sprintf("%d", m.engine.Images)),
"",
styled(theme.Current().Success, i18n.T("state.connected")),
)
}
return panel(m.engineTitle(), i18n.T("host.local"), lines, w, h, false)
}
func (m Model) hostPanel(w, h int) string {
s := m.sys
cpus := m.engine.CPUs
if cpus == 0 {
cpus = runtime.NumCPU()
}
pct := 0
if s.MemTotal > 0 {
pct = int((s.MemTotal - s.MemAvail) * 100 / s.MemTotal)
}
lines := []string{
fieldW(i18n.T("field.host"), 9) + dim(m.hostname),
fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)),
fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + pctBar(float64(pct), 10) +
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)),
}
return panel(i18n.T("section.host"), "", lines, w, h, false)
}
func (m Model) miniContainerLines(maxLines int) []string {
if m.loading && len(m.containers) == 0 {
return []string{dim(i18n.T("state.loading"))}
}
if len(m.containers) == 0 {
return []string{dim(i18n.T("hint.miniNoContainers"))}
}
max := maxLines
if len(m.containers) > max {
max--
}
lines := make([]string, 0, maxLines)
for i := 0; i < len(m.containers) && i < max; i++ {
c := m.containers[i]
glyph, color := stateStyle(c.State)
lines = append(lines, styled(color, glyph)+" "+text(truncate(c.Name, 24))+" "+dim(truncate(c.Image, 32)))
}
if extra := len(m.containers) - len(lines); extra > 0 {
lines = append(lines, dim(i18n.Tf("fmt.listMore", extra)))
}
return lines
}
// --- small formatting helpers ---
func humanKB(kb uint64) string {
f := float64(kb) * 1024
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i <= 1 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
func humanKBs(kb uint64) string {
return strings.Replace(humanKB(kb), " ", "", 1)
}
// humanBytes — ukuran byte → "1.4 GB" (untuk image).
func humanBytes(n int64) string {
f := float64(n)
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i <= 1 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
func humanDuration(sec float64) string {
d := time.Duration(sec) * time.Second
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
switch {
case days > 0:
return i18n.Tf("duration.days", days, hours)
case hours > 0:
return i18n.Tf("duration.hours", hours, mins)
default:
return i18n.Tf("duration.minutes", mins)
}
}
// pctBar — bar persentase; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%).
func pctBar(pct float64, w int) string {
fill := int(pct * float64(w) / 100)
if fill > w {
fill = w
}
th := theme.Current()
color := th.Success
switch {
case pct >= 85:
color = th.Danger
case pct >= 60:
color = th.Warning
}
return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill))
}