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

349 lines
8.5 KiB
Go

package app
import (
"context"
"fmt"
"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/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
)
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
type containersMsg struct {
items []docker.Container
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
cl *docker.Client
containers []docker.Container
cursor int
loadErr error
loading bool
}
func New() Model {
cl, err := docker.New()
m := Model{
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
tickID: animation.NewID(),
animating: true,
toast: components.NewToast(),
cl: cl,
loading: true,
}
if err != nil {
m.loadErr = err
m.loading = false
}
return m
}
func (m Model) Init() tea.Cmd {
if m.cl == nil {
return animation.Next(m.tickID)
}
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl))
}
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 (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
}
return m, animation.Next(m.tickID)
case containersMsg:
m.loading = false
if msg.err != nil {
m.loadErr = msg.err
m.toast.Show("✖ [ GAGAL ] Docker", 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, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
m.sound.Play(audio.ModalClose)
return m, tea.Quit
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 daftar kontainer
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
return m, loadContainers(m.cl)
case "t": // demo toast (sampai modal asli datang)
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
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 sisi sidebar: baris item pertama = bodyTop + 2
// (border atas + baris judul panel)
if msg.X < m.leftWidth() && len(m.containers) > 0 {
idx := msg.Y - (m.headerRows() + 2) + 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() - 3 // tinggi dalam panel - border - baris judul
if rows <= 0 || m.cursor < rows {
return 0
}
return m.cursor - rows + 1
}
func stateStyle(state string) (glyph, color string) {
switch state {
case "running":
return "●", "#a6e3a1"
case "paused":
return "⏸", "#f9e2af"
case "restarting", "created":
return "◌", "#89b4fa"
case "exited", "dead":
return "○", "#f38ba8"
default:
return "○", "#6c7086"
}
}
func truncate(s string, w int) string {
r := []rune(s)
if w <= 1 || len(r) <= w {
return s
}
return string(r[:w-1]) + "…"
}
// box — panel ber-border; w/h = ukuran total panel (termasuk border+padding).
func box(lines []string, w, h int) string {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#6c7086")).
Width(w-4).
Height(h-2).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
}
func dim(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
}
func text(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
}
// --- views ---
func (m Model) View() tea.View {
header := lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
Render("tia — Terminal Is A My Way") +
dim(" · kontainer")
body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
anim := "—"
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
footerPlain := fmt.Sprintf(
"[j/k] pilih · [r] muat ulang · [t] toast · [a] reduced-motion · [q] keluar animasi %s fps · audio: %s",
anim, m.sound.Status())
footer := dim(truncate(footerPlain, m.width))
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) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
innerW := w - 4
head := fmt.Sprintf("Kontainer (%d)", len(m.containers))
if m.loading {
head = "Kontainer · memuat…"
}
lines := []string{lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(head)}
switch {
case m.loadErr != nil:
lines = append(lines,
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("GAGAL memuat"),
dim("tekan r untuk coba lagi"),
)
case m.loading:
lines = append(lines, dim("memuat…"))
case len(m.containers) == 0:
lines = append(lines, dim("belum ada kontainer"))
default:
rows := h - 3
start := m.scrollStart()
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 = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")).Render("▶ ")
}
line := mark +
lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph) + " " +
lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(truncate(c.Name, innerW-4))
lines = append(lines, line)
}
}
return box(lines, w, h)
}
func (m Model) contentView() string {
w, h := m.width-m.leftWidth(), m.bodyHeight()
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines,
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("Gagal memuat daftar kontainer"),
"",
dim(truncate(m.loadErr.Error(), w-6)),
)
case len(m.containers) == 0:
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
default:
c := m.containers[min(m.cursor, len(m.containers)-1)]
glyph, color := stateStyle(c.State)
lines = append(lines,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(c.Name),
"",
text("ID ")+dim(c.ID),
text("Image ")+dim(truncate(c.Image, w-20)),
text("State ")+lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph+" "+c.State),
text("Status ")+dim(truncate(c.Status, w-20)),
"",
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
)
}
return box(lines, w, h)
}