Files
tia-love/internal/app/app.go
T

494 lines
12 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 (
"context"
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/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/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
"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"
)
type viewKind int
const (
viewHome viewKind = iota
viewContainers
)
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
type containersMsg struct {
items []docker.Container
err error
}
// infoMsg — ringkasan daemon docker.
type infoMsg struct {
info docker.Info
err error
}
type Model struct {
width, height int
sound *audio.Engine
toast components.Toast
tickID int64
animating bool
phase float64
frames int
fps int
lastFPSAt time.Time
view viewKind
hostname string
sys sysinfo.Stats
cl *docker.Client
engine docker.Info
engineErr error
containers []docker.Container
cursor int
loadErr error
loading bool
}
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)
cl, err := docker.New()
host, _ := os.Hostname()
m := Model{
sound: audio.New(audio.Options{
Enabled: cfg.UI.SoundEnabled,
Volume: cfg.UI.SoundVolume,
MuteOnSSH: cfg.UI.SoundMuteOnSSH,
}),
tickID: animation.NewID(),
animating: cfg.UI.AnimationEnabled,
toast: components.NewToast(),
view: viewHome,
hostname: host,
sys: sysinfo.Read(),
cl: cl,
loading: true,
}
if cfgErr != nil {
m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error())
}
if err != nil {
m.loadErr = err
m.engineErr = err
m.loading = false
}
return m
}
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
if m.animating {
cmds = append(cmds, animation.Next(m.tickID))
}
if m.cl != nil {
cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl))
}
return tea.Batch(cmds...)
}
func loadContainers(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
items, err := cl.ListContainers(ctx)
return containersMsg{items: items, err: err}
}
}
func loadInfo(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := cl.Info(ctx)
return infoMsg{info: info, err: err}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case animation.TickMsg:
if msg.ID != m.tickID || !m.animating {
return m, nil // tick basi / reduced-motion
}
m.phase += 0.9
m.toast.Update(animation.FrameInterval)
m.frames++
if m.lastFPSAt.IsZero() {
m.lastFPSAt = msg.Time
}
if d := msg.Time.Sub(m.lastFPSAt); d >= time.Second {
m.fps = int(float64(m.frames) / d.Seconds())
m.frames = 0
m.lastFPSAt = msg.Time
m.sys = sysinfo.Read() // metrik host: refresh 1×/detik
}
return m, animation.Next(m.tickID)
case containersMsg:
m.loading = false
if msg.err != nil {
m.loadErr = msg.err
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), msg.err.Error())
m.sound.Play(audio.NotifyError)
} else {
m.loadErr = nil
m.containers = msg.items
if m.cursor >= len(m.containers) {
m.cursor = max(0, len(m.containers)-1)
}
return m, loadInfo(m.cl) // sinkronkan hitungan di dashboard
}
return m, nil
case infoMsg:
if msg.err != nil {
m.engineErr = msg.err
} else {
m.engineErr = nil
m.engine = msg.info
}
return m, nil
case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
theme.SetDetectedDark(msg.IsDark())
return m, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
m.sound.Play(audio.ModalClose)
return m, tea.Quit
case "esc":
if m.view == viewHome {
m.sound.Play(audio.ModalClose)
return m, tea.Quit
}
m.view = viewHome
m.sound.Play(audio.Click)
case "0":
m.view = viewHome
m.sound.Play(audio.Click)
case "1", "enter":
m.view = viewContainers
m.sound.Play(audio.Click)
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return m, animation.Next(m.tickID)
}
case "r": // muat ulang data docker
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
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)
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)
case "j", "down":
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
m.cursor++
}
m.sound.Play(audio.Navigate)
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
m.sound.Play(audio.Navigate)
}
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
m.sound.Play(audio.Click)
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// hit-test sidebar (hanya di view kontainer):
// baris item pertama = headerRows + 1 (border atas panel)
if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 {
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < len(m.containers) {
m.cursor = idx
}
}
}
}
return m, nil
}
// --- layout helpers ---
func (m Model) toastRows() int {
if m.toast.Active() {
return 5 // 4 baris toast + 1 baris jarak
}
return 0
}
func (m Model) headerRows() int { return 1 + m.toastRows() }
func (m Model) bodyHeight() int {
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
if h < 5 {
h = 5
}
return h
}
func (m Model) leftWidth() int {
w := int(float64(m.width) * 0.33)
if w < 24 {
w = 24
}
if w > 46 {
w = 46
}
if w > m.width-24 {
w = max(16, m.width-24)
}
return w
}
// scrollStart — offset window daftar agar kursor selalu terlihat.
func (m Model) scrollStart() int {
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
if rows <= 0 || m.cursor < rows {
return 0
}
return m.cursor - rows + 1
}
func stateStyle(state string) (glyph string, c theme.Color) {
th := theme.Current()
switch state {
case "running":
return "●", th.Success
case "paused":
return "⏸", th.Warning
case "restarting", "created":
return "◌", th.Info
case "exited", "dead":
return "○", th.Danger
default:
return "○", th.Overlay
}
}
// --- styling helpers (semua via token tema — F-asi §5.4) ---
func dim(s string) string {
return theme.Style(theme.Current().Subtext).Render(s)
}
func text(s string) string {
return theme.Style(theme.Current().Text).Render(s)
}
func red(s string) string {
return theme.Style(theme.Current().Danger).Render(s)
}
func styled(c theme.Color, s string) string {
return theme.Style(c).Render(s)
}
func truncate(s string, w int) string {
r := []rune(s)
if w <= 1 || len(r) <= w {
return s
}
return string(r[:w-1]) + "…"
}
// field — label kolom (padding tetap agar nilai sejajar).
func field(label string) string { return fieldW(label, 7) }
func fieldW(label string, w int) string {
return text(fmt.Sprintf("%-*s", w, label))
}
// panel — kotak gaya mirador: judul di border atas (kiri), meta (kanan),
// border redup; konten otomatis dipangkas/padded selebar panel.
func panel(title, meta string, lines []string, w, h int, focused bool) string {
if w < 6 || h < 2 {
return ""
}
th := theme.Current()
bc := th.Overlay
if focused {
bc = th.Accent
}
border := lipgloss.NewStyle().Foreground(lipgloss.Color(string(bc)))
titleS := text(title)
left := border.Render("╭─ ") + titleS
right := border.Render("─╮")
if meta != "" {
right = " " + dim(meta) + border.Render(" ─╮")
}
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
var b strings.Builder
b.WriteString(left)
b.WriteString(border.Render(strings.Repeat("─", gap)))
b.WriteString(right)
content := lipgloss.NewStyle().Width(w - 4)
for i := 0; i < h-2; i++ {
l := ""
if i < len(lines) {
l = lines[i]
}
b.WriteString("\n")
b.WriteString(border.Render("│"))
b.WriteString(" ")
b.WriteString(content.Render(ansiTruncate(l, w-4)))
b.WriteString(" ")
b.WriteString(border.Render("│"))
}
b.WriteString("\n")
b.WriteString(border.Render("╰" + strings.Repeat("─", w-2) + "╯"))
return b.String()
}
// --- views ---
func (m Model) View() tea.View {
header := theme.Style(theme.Current().Accent).Bold(true).
Render("tia — Terminal Is A My Way")
var body, footer string
switch m.view {
case viewHome:
header += dim(" · " + i18n.T("nav.home"))
body = m.homeBody()
footer = m.footerLine(i18n.T("hint.footerHome"))
default:
header += dim(" · " + i18n.T("nav.containers"))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
footer = m.footerLine(i18n.T("hint.footerContainers"))
}
parts := []string{}
if t := m.toast.View(max(m.width, 20)); t != "" {
parts = append(parts, t)
}
parts = append(parts, header, body, footer)
v := tea.NewView(strings.Join(parts, "\n"))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) footerLine(hints string) string {
anim := "—"
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width))
}
func (m Model) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
title, meta := i18n.T("section.containers"), fmt.Sprintf("(%d)", len(m.containers))
if m.loading {
meta = i18n.T("state.loading")
}
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
case m.loading:
lines = append(lines, dim(i18n.T("state.loading")))
case len(m.containers) == 0:
lines = append(lines, dim(i18n.T("state.noContainers")))
default:
rows := h - 2
start := m.scrollStart()
innerW := w - 4
for i := start; i < len(m.containers) && i-start < rows; i++ {
c := m.containers[i]
glyph, color := stateStyle(c.State)
mark := " "
if i == m.cursor {
mark = styled(theme.Current().Accent, "▶ ")
}
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
}
}
return panel(title, meta, lines, w, h, false)
}
func (m Model) contentView() string {
w, h := m.width-m.leftWidth(), m.bodyHeight()
title, meta := i18n.T("section.detail"), ""
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines, red(i18n.T("error.loadContainers")), "", dim(truncate(m.loadErr.Error(), w-6)))
case len(m.containers) == 0:
lines = append(lines, dim(i18n.T("hint.noContainers")))
default:
c := m.containers[min(m.cursor, len(m.containers)-1)]
glyph, color := stateStyle(c.State)
title, meta = c.Name, c.State
lines = append(lines,
field(i18n.T("field.id"))+dim(c.ID),
field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)),
field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)),
field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)),
"",
dim(i18n.T("hint.tabsComing")),
)
}
return panel(title, meta, lines, w, h, false)
}