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, "\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) { 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) 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)+"╯")) ctaTop = max((h-ctaH)/2, 0) lines = make([]string, 0, h) for i := 0; i < h; i++ { idx := i - ctaTop switch { case idx >= 0 && idx < ctaH: lines = append(lines, box[idx]) default: lines = append(lines, strings.Repeat(" ", w)) } } return } // 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) _, ctaTop, ctaH := 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+ctaTop && y < top+ctaTop+ctaH && x >= k1 && x < k1+cw: m.jumpSection(sectionStacks) m.sound.Play(audio.Click) return m.restartData() } 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)) }