feat(ui): home dashboard ala mirador — jam blok, panel judul-di-border, metrik docker+host, navigasi 0/1
CI / test (push) Successful in 10s
CI / test (push) Successful in 10s
This commit is contained in:
1 parent
fe781591e3
commit
76cdb0b5bb
10 files changed
+641
-66
No files matched your search
@@ -11,6 +11,7 @@ mengimplementasikan ulang dalam Go. Tidak ada kode yang disalin. Tabel pemetaan
|
|||||||
| composerize | https://github.com/composerize/composerize | `3e39eb7` | MIT |
|
| composerize | https://github.com/composerize/composerize | `3e39eb7` | MIT |
|
||||||
| Dockge | https://github.com/louislam/dockge | `f809ae1` | MIT |
|
| Dockge | https://github.com/louislam/dockge | `f809ae1` | MIT |
|
||||||
| lazydocker | https://github.com/jesseduffield/lazydocker | `7e7aadc` | MIT |
|
| lazydocker | https://github.com/jesseduffield/lazydocker | `7e7aadc` | MIT |
|
||||||
|
| Mirador (inspirasi tata letak dashboard — tanpa kode) | https://github.com/jchultarsky/mirador | — | MIT |
|
||||||
|
|
||||||
Repo referensi hanya ada di `reference/` (git-ignored).
|
Repo referensi hanya ada di `reference/` (git-ignored).
|
||||||
|
|
||||||
|
|||||||
@@ -1478,6 +1478,7 @@ flowchart LR
|
|||||||
| Charm (bubbles) | https://github.com/charmbracelet/bubbles | UI components |
|
| Charm (bubbles) | https://github.com/charmbracelet/bubbles | UI components |
|
||||||
| Charm (lipgloss) | https://github.com/charmbracelet/lipgloss | Styling |
|
| Charm (lipgloss) | https://github.com/charmbracelet/lipgloss | Styling |
|
||||||
| Charm (huh) | https://github.com/charmbracelet/huh | Forms |
|
| Charm (huh) | https://github.com/charmbracelet/huh | Forms |
|
||||||
|
| Mirador | https://github.com/jchultarsky/mirador | Inspirasi tata letak dashboard TUI (panel grid dengan judul di border, meta kanan, jam blok, satu panel fokus terang) |
|
||||||
|
|
||||||
## Appendix B: Charm v2 Import Paths
|
## Appendix B: Charm v2 Import Paths
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -211,12 +211,29 @@ func TestDoctorCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardNavigation(t *testing.T) {
|
||||||
|
u := startTUI(t)
|
||||||
|
u.waitContains("beranda", 5*time.Second)
|
||||||
|
u.waitContains("Jam", 5*time.Second) // panel jam blok
|
||||||
|
u.waitContains("Docker", 5*time.Second)
|
||||||
|
u.send("1")
|
||||||
|
u.waitContains("Kontainer", 3*time.Second)
|
||||||
|
u.send("0")
|
||||||
|
u.waitContains("beranda", 3*time.Second)
|
||||||
|
u.send("q")
|
||||||
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||||
|
t.Fatalf("exit code %d, mau 0", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMouseAndNavigateKeys(t *testing.T) {
|
func TestMouseAndNavigateKeys(t *testing.T) {
|
||||||
u := startTUI(t)
|
u := startTUI(t)
|
||||||
u.waitContains("Kontainer", 5*time.Second)
|
u.waitContains("beranda", 5*time.Second)
|
||||||
|
u.send("1") // masuk view kontainer
|
||||||
|
u.waitContains("Kontainer", 3*time.Second)
|
||||||
u.send("j")
|
u.send("j")
|
||||||
u.send("k")
|
u.send("k")
|
||||||
u.send("\x1b[<0;10;3M\x1b[<0;10;3m") // klik kiri di area sidebar
|
u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik kiri di area sidebar
|
||||||
u.send("r") // muat ulang
|
u.send("r") // muat ulang
|
||||||
u.send("q")
|
u.send("q")
|
||||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||||
@@ -243,7 +260,9 @@ func TestClickSelectsContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u := startTUI(t)
|
u := startTUI(t)
|
||||||
u.waitContains("Kontainer (", 5*time.Second)
|
u.waitContains("beranda", 5*time.Second)
|
||||||
|
u.send("1") // masuk view kontainer
|
||||||
|
u.waitContains("Kontainer", 3*time.Second)
|
||||||
u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik baris item pertama sidebar
|
u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik baris item pertama sidebar
|
||||||
u.waitContains("▶", 3*time.Second)
|
u.waitContains("▶", 3*time.Second)
|
||||||
u.send("q")
|
u.send("q")
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ansiTruncate — potong string ber-ANSI ke lebar tampilan w (sel), aman
|
||||||
|
// terhadap escape sequence; bila terpotong ditutup reset + "…".
|
||||||
|
// Dipakai panel() agar konten tidak pernah wrap (tinggi panel tetap).
|
||||||
|
func ansiTruncate(s string, w int) string {
|
||||||
|
if w <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if lipgloss.Width(s) <= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
const (
|
||||||
|
stNormal = iota
|
||||||
|
stEsc
|
||||||
|
stCSI
|
||||||
|
stOSC
|
||||||
|
stOSCEsc
|
||||||
|
)
|
||||||
|
state := stNormal
|
||||||
|
width := 0
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch state {
|
||||||
|
case stNormal:
|
||||||
|
if r == 0x1b {
|
||||||
|
state = stEsc
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if width >= w-1 { // sisakan satu sel untuk "…"
|
||||||
|
b.WriteString("\x1b[0m…")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
width++
|
||||||
|
case stEsc:
|
||||||
|
b.WriteRune(r)
|
||||||
|
switch {
|
||||||
|
case r == '[':
|
||||||
|
state = stCSI
|
||||||
|
case r == ']':
|
||||||
|
state = stOSC
|
||||||
|
case r >= 0x20 && r <= 0x2f:
|
||||||
|
// intermediate — tunggu final byte
|
||||||
|
default:
|
||||||
|
state = stNormal
|
||||||
|
}
|
||||||
|
case stCSI:
|
||||||
|
b.WriteRune(r)
|
||||||
|
if r >= 0x40 && r <= 0x7e {
|
||||||
|
state = stNormal
|
||||||
|
}
|
||||||
|
case stOSC:
|
||||||
|
b.WriteRune(r)
|
||||||
|
if r == 0x07 {
|
||||||
|
state = stNormal
|
||||||
|
} else if r == 0x1b {
|
||||||
|
state = stOSCEsc
|
||||||
|
}
|
||||||
|
case stOSCEsc:
|
||||||
|
b.WriteRune(r)
|
||||||
|
if r == '\\' {
|
||||||
|
state = stNormal
|
||||||
|
} else {
|
||||||
|
state = stOSC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
+165
-63
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,16 +12,30 @@ import (
|
|||||||
|
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
"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/docker"
|
||||||
|
"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/animation"
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type viewKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
viewHome viewKind = iota
|
||||||
|
viewContainers
|
||||||
|
)
|
||||||
|
|
||||||
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
|
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
|
||||||
type containersMsg struct {
|
type containersMsg struct {
|
||||||
items []docker.Container
|
items []docker.Container
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// infoMsg — ringkasan daemon docker.
|
||||||
|
type infoMsg struct {
|
||||||
|
info docker.Info
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
width, height int
|
width, height int
|
||||||
sound *audio.Engine
|
sound *audio.Engine
|
||||||
@@ -33,7 +48,14 @@ type Model struct {
|
|||||||
fps int
|
fps int
|
||||||
lastFPSAt time.Time
|
lastFPSAt time.Time
|
||||||
|
|
||||||
|
view viewKind
|
||||||
|
|
||||||
|
hostname string
|
||||||
|
sys sysinfo.Stats
|
||||||
|
|
||||||
cl *docker.Client
|
cl *docker.Client
|
||||||
|
engine docker.Info
|
||||||
|
engineErr error
|
||||||
containers []docker.Container
|
containers []docker.Container
|
||||||
cursor int
|
cursor int
|
||||||
loadErr error
|
loadErr error
|
||||||
@@ -42,16 +64,21 @@ type Model struct {
|
|||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
cl, err := docker.New()
|
cl, err := docker.New()
|
||||||
|
host, _ := os.Hostname()
|
||||||
m := Model{
|
m := Model{
|
||||||
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
||||||
tickID: animation.NewID(),
|
tickID: animation.NewID(),
|
||||||
animating: true,
|
animating: true,
|
||||||
toast: components.NewToast(),
|
toast: components.NewToast(),
|
||||||
|
view: viewHome,
|
||||||
|
hostname: host,
|
||||||
|
sys: sysinfo.Read(),
|
||||||
cl: cl,
|
cl: cl,
|
||||||
loading: true,
|
loading: true,
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.loadErr = err
|
m.loadErr = err
|
||||||
|
m.engineErr = err
|
||||||
m.loading = false
|
m.loading = false
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -61,7 +88,7 @@ func (m Model) Init() tea.Cmd {
|
|||||||
if m.cl == nil {
|
if m.cl == nil {
|
||||||
return animation.Next(m.tickID)
|
return animation.Next(m.tickID)
|
||||||
}
|
}
|
||||||
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl))
|
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl), loadInfo(m.cl))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadContainers(cl *docker.Client) tea.Cmd {
|
func loadContainers(cl *docker.Client) tea.Cmd {
|
||||||
@@ -73,6 +100,15 @@ func loadContainers(cl *docker.Client) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case animation.TickMsg:
|
case animation.TickMsg:
|
||||||
@@ -89,6 +125,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.fps = int(float64(m.frames) / d.Seconds())
|
m.fps = int(float64(m.frames) / d.Seconds())
|
||||||
m.frames = 0
|
m.frames = 0
|
||||||
m.lastFPSAt = msg.Time
|
m.lastFPSAt = msg.Time
|
||||||
|
m.sys = sysinfo.Read() // metrik host: refresh 1×/detik
|
||||||
}
|
}
|
||||||
return m, animation.Next(m.tickID)
|
return m, animation.Next(m.tickID)
|
||||||
|
|
||||||
@@ -104,6 +141,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.cursor >= len(m.containers) {
|
if m.cursor >= len(m.containers) {
|
||||||
m.cursor = max(0, len(m.containers)-1)
|
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
|
return m, nil
|
||||||
|
|
||||||
@@ -112,22 +159,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c", "esc":
|
case "q", "ctrl+c":
|
||||||
m.sound.Play(audio.ModalClose)
|
m.sound.Play(audio.ModalClose)
|
||||||
return m, tea.Quit
|
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
|
case "a": // reduced motion
|
||||||
m.animating = !m.animating
|
m.animating = !m.animating
|
||||||
if m.animating {
|
if m.animating {
|
||||||
m.tickID = animation.NewID()
|
m.tickID = animation.NewID()
|
||||||
return m, animation.Next(m.tickID)
|
return m, animation.Next(m.tickID)
|
||||||
}
|
}
|
||||||
case "r": // muat ulang daftar kontainer
|
case "r": // muat ulang data docker
|
||||||
if m.cl == nil {
|
if m.cl == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.sound.Play(audio.Click)
|
m.sound.Play(audio.Click)
|
||||||
return m, loadContainers(m.cl)
|
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
|
||||||
case "t": // demo toast (sampai modal asli datang)
|
case "t": // demo toast (sampai modal asli datang)
|
||||||
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
|
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
|
||||||
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
|
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
|
||||||
@@ -150,10 +210,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.toast.Active() {
|
if m.toast.Active() {
|
||||||
m.toast = components.NewToast() // klik = tutup toast
|
m.toast = components.NewToast() // klik = tutup toast
|
||||||
}
|
}
|
||||||
// hit-test sisi sidebar: baris item pertama = bodyTop + 2
|
// hit-test sidebar (hanya di view kontainer):
|
||||||
// (border atas + baris judul panel)
|
// baris item pertama = headerRows + 1 (border atas panel)
|
||||||
if msg.X < m.leftWidth() && len(m.containers) > 0 {
|
if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 {
|
||||||
idx := msg.Y - (m.headerRows() + 2) + m.scrollStart()
|
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
|
||||||
if idx >= 0 && idx < len(m.containers) {
|
if idx >= 0 && idx < len(m.containers) {
|
||||||
m.cursor = idx
|
m.cursor = idx
|
||||||
}
|
}
|
||||||
@@ -198,7 +258,7 @@ func (m Model) leftWidth() int {
|
|||||||
|
|
||||||
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
||||||
func (m Model) scrollStart() int {
|
func (m Model) scrollStart() int {
|
||||||
rows := m.bodyHeight() - 3 // tinggi dalam panel - border - baris judul
|
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
|
||||||
if rows <= 0 || m.cursor < rows {
|
if rows <= 0 || m.cursor < rows {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -220,24 +280,7 @@ func stateStyle(state string) (glyph, color string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, w int) string {
|
// --- styling helpers ---
|
||||||
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 {
|
func dim(s string) string {
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
|
||||||
@@ -247,24 +290,86 @@ func text(s string) string {
|
|||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func red(s string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func styled(color, s string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).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]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
|
bc := "#6c7086"
|
||||||
|
if focused {
|
||||||
|
bc = "#cba6f7"
|
||||||
|
}
|
||||||
|
border := lipgloss.NewStyle().Foreground(lipgloss.Color(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 ---
|
// --- views ---
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
header := lipgloss.NewStyle().Bold(true).
|
header := lipgloss.NewStyle().Bold(true).
|
||||||
Foreground(lipgloss.Color("#cba6f7")).
|
Foreground(lipgloss.Color("#cba6f7")).
|
||||||
Render("tia — Terminal Is A My Way") +
|
Render("tia — Terminal Is A My Way")
|
||||||
dim(" · kontainer")
|
|
||||||
|
|
||||||
body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
var body, footer string
|
||||||
|
switch m.view {
|
||||||
anim := "—"
|
case viewHome:
|
||||||
if m.animating {
|
header += dim(" · beranda")
|
||||||
anim = fmt.Sprintf("%d", m.fps)
|
body = m.homeBody()
|
||||||
|
footer = m.footerLine("[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar")
|
||||||
|
default:
|
||||||
|
header += dim(" · kontainer")
|
||||||
|
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
||||||
|
footer = m.footerLine("[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar")
|
||||||
}
|
}
|
||||||
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{}
|
parts := []string{}
|
||||||
if t := m.toast.View(max(m.width, 20)); t != "" {
|
if t := m.toast.View(max(m.width, 20)); t != "" {
|
||||||
@@ -278,71 +383,68 @@ func (m Model) View() tea.View {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) footerLine(hints string) string {
|
||||||
|
anim := "—"
|
||||||
|
if m.animating {
|
||||||
|
anim = fmt.Sprintf("%d", m.fps)
|
||||||
|
}
|
||||||
|
return dim(truncate(fmt.Sprintf("%s animasi %s fps · audio: %s", hints, anim, m.sound.Status()), m.width))
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) sidebarView() string {
|
func (m Model) sidebarView() string {
|
||||||
w, h := m.leftWidth(), m.bodyHeight()
|
w, h := m.leftWidth(), m.bodyHeight()
|
||||||
innerW := w - 4
|
title, meta := "Kontainer", fmt.Sprintf("(%d)", len(m.containers))
|
||||||
|
|
||||||
head := fmt.Sprintf("Kontainer (%d)", len(m.containers))
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
head = "Kontainer · memuat…"
|
meta = "memuat…"
|
||||||
}
|
}
|
||||||
lines := []string{lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(head)}
|
|
||||||
|
|
||||||
|
var lines []string
|
||||||
switch {
|
switch {
|
||||||
case m.loadErr != nil:
|
case m.loadErr != nil:
|
||||||
lines = append(lines,
|
lines = append(lines, red("GAGAL memuat"), dim("tekan r untuk coba lagi"))
|
||||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("GAGAL memuat"),
|
|
||||||
dim("tekan r untuk coba lagi"),
|
|
||||||
)
|
|
||||||
case m.loading:
|
case m.loading:
|
||||||
lines = append(lines, dim("memuat…"))
|
lines = append(lines, dim("memuat…"))
|
||||||
case len(m.containers) == 0:
|
case len(m.containers) == 0:
|
||||||
lines = append(lines, dim("belum ada kontainer"))
|
lines = append(lines, dim("belum ada kontainer"))
|
||||||
default:
|
default:
|
||||||
rows := h - 3
|
rows := h - 2
|
||||||
start := m.scrollStart()
|
start := m.scrollStart()
|
||||||
|
innerW := w - 4
|
||||||
for i := start; i < len(m.containers) && i-start < rows; i++ {
|
for i := start; i < len(m.containers) && i-start < rows; i++ {
|
||||||
c := m.containers[i]
|
c := m.containers[i]
|
||||||
glyph, color := stateStyle(c.State)
|
glyph, color := stateStyle(c.State)
|
||||||
mark := " "
|
mark := " "
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
mark = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")).Render("▶ ")
|
mark = styled("#cba6f7", "▶ ")
|
||||||
}
|
}
|
||||||
line := mark +
|
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
|
||||||
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)
|
return panel(title, meta, lines, w, h, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) contentView() string {
|
func (m Model) contentView() string {
|
||||||
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
||||||
|
title, meta := "Detail", ""
|
||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
switch {
|
switch {
|
||||||
case m.loadErr != nil:
|
case m.loadErr != nil:
|
||||||
lines = append(lines,
|
lines = append(lines, red("Gagal memuat daftar kontainer"), "", dim(truncate(m.loadErr.Error(), w-6)))
|
||||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("Gagal memuat daftar kontainer"),
|
|
||||||
"",
|
|
||||||
dim(truncate(m.loadErr.Error(), w-6)),
|
|
||||||
)
|
|
||||||
case len(m.containers) == 0:
|
case len(m.containers) == 0:
|
||||||
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
|
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
|
||||||
default:
|
default:
|
||||||
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
||||||
glyph, color := stateStyle(c.State)
|
glyph, color := stateStyle(c.State)
|
||||||
|
title, meta = c.Name, c.State
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(c.Name),
|
|
||||||
"",
|
|
||||||
text("ID ")+dim(c.ID),
|
text("ID ")+dim(c.ID),
|
||||||
text("Image ")+dim(truncate(c.Image, w-20)),
|
text("Image ")+dim(truncate(c.Image, w-20)),
|
||||||
text("State ")+lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph+" "+c.State),
|
text("State ")+styled(color, glyph+" "+c.State),
|
||||||
text("Status ")+dim(truncate(c.Status, w-20)),
|
text("Status ")+dim(truncate(c.Status, w-20)),
|
||||||
"",
|
"",
|
||||||
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
|
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return box(lines, w, h)
|
return panel(title, meta, lines, w, h, false)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
|
||||||
|
// grid panel dengan judul di border; baris atas Jam · Docker · Host,
|
||||||
|
// baris bawah Kontainer · Aktivitas.
|
||||||
|
func (m Model) homeBody() string {
|
||||||
|
W := max(m.width, 60)
|
||||||
|
bodyH := m.bodyHeight()
|
||||||
|
|
||||||
|
row1H := 9
|
||||||
|
row2H := bodyH - row1H
|
||||||
|
if row2H < 5 {
|
||||||
|
row1H = 6
|
||||||
|
row2H = max(bodyH-row1H, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
k1 := int(float64(W) * 0.55)
|
||||||
|
k2 := W - k1
|
||||||
|
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
|
panel("Kontainer", fmt.Sprintf("%d total", len(m.containers)),
|
||||||
|
m.miniContainerLines(row2H-2), k1, row2H, false),
|
||||||
|
panel("Aktivitas", "Phase 2", activityLines(), k2, row2H, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
return row1 + "\n" + row2
|
||||||
|
}
|
||||||
|
|
||||||
|
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] = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cba6f7")).Render(r)
|
||||||
|
}
|
||||||
|
lines := append(rows, "", dim(" "+hariID(now)+", "+tanggalID(now)+" · "+now.Format("MST")))
|
||||||
|
if len(lines) > h-2 {
|
||||||
|
lines = lines[:h-2]
|
||||||
|
}
|
||||||
|
return panel("Jam", "", lines, w, h, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) dockerPanel(w, h int) string {
|
||||||
|
var lines []string
|
||||||
|
switch {
|
||||||
|
case m.engineErr != nil:
|
||||||
|
lines = append(lines, red("GAGAL"), "", dim(truncate(m.engineErr.Error(), w-6)))
|
||||||
|
case m.engine.ServerVersion == "":
|
||||||
|
lines = append(lines, dim("memuat…"))
|
||||||
|
default:
|
||||||
|
lines = append(lines,
|
||||||
|
text("Engine ")+dim(m.engine.ServerVersion),
|
||||||
|
text("API ")+dim(m.engine.APIVersion),
|
||||||
|
text("Kontainer ")+dim(fmt.Sprintf("%d jalan / %d total", m.engine.Running, m.engine.Containers)),
|
||||||
|
text("Image ")+dim(fmt.Sprintf("%d", m.engine.Images)),
|
||||||
|
"",
|
||||||
|
styled("#a6e3a1", "● terhubung"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return panel("Docker", "lokal", 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{
|
||||||
|
text("Host ") + dim(m.hostname),
|
||||||
|
text("CPU ") + dim(fmt.Sprintf("%d core · load %.2f", cpus, s.Load1)),
|
||||||
|
text("RAM ") + dim(fmt.Sprintf("%3d%% ", pct)) + memBar(pct, 10) +
|
||||||
|
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
|
||||||
|
text("Uptime ") + dim(humanDuration(s.Uptime)),
|
||||||
|
}
|
||||||
|
return panel("Host", "", lines, w, h, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) miniContainerLines(maxLines int) []string {
|
||||||
|
if m.loading && len(m.containers) == 0 {
|
||||||
|
return []string{dim("memuat…")}
|
||||||
|
}
|
||||||
|
if len(m.containers) == 0 {
|
||||||
|
return []string{dim("belum ada kontainer — tekan 1")}
|
||||||
|
}
|
||||||
|
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(fmt.Sprintf("… %d lainnya (tekan 1)", extra)))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityLines() []string {
|
||||||
|
return []string{
|
||||||
|
dim("Listener Docker events menyusul (Phase 2)."),
|
||||||
|
"",
|
||||||
|
dim("Panel ini akan menampilkan 5 aktivitas terbaru:"),
|
||||||
|
dim("kontainer start/stop, image di-pull, error terakhir."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small formatting helpers ---
|
||||||
|
|
||||||
|
var hari = [...]string{"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"}
|
||||||
|
var bulan = [...]string{"Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||||
|
|
||||||
|
func hariID(t time.Time) string { return hari[int(t.Weekday())] }
|
||||||
|
|
||||||
|
func tanggalID(t time.Time) string {
|
||||||
|
return fmt.Sprintf("%d %s %d", t.Day(), bulan[int(t.Month())-1], t.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fmt.Sprintf("%d hari %d jam", days, hours)
|
||||||
|
case hours > 0:
|
||||||
|
return fmt.Sprintf("%d jam %d mnt", hours, mins)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d mnt", mins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memBar — bar RAM; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%).
|
||||||
|
func memBar(pct, w int) string {
|
||||||
|
fill := pct * w / 100
|
||||||
|
if fill > w {
|
||||||
|
fill = w
|
||||||
|
}
|
||||||
|
color := "#a6e3a1"
|
||||||
|
switch {
|
||||||
|
case pct >= 85:
|
||||||
|
color = "#f38ba8"
|
||||||
|
case pct >= 60:
|
||||||
|
color = "#f9e2af"
|
||||||
|
}
|
||||||
|
return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill))
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package sysinfo — metrik host ringan untuk dashboard (F-027).
|
||||||
|
// Linux: baca /proc. OS lain: nilai nol (degrade anggun, tidak pernah panic).
|
||||||
|
package sysinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
MemTotal uint64 // KB
|
||||||
|
MemAvail uint64 // KB
|
||||||
|
Load1 float64 // load average 1 menit
|
||||||
|
Uptime float64 // detik
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read() Stats {
|
||||||
|
var s Stats
|
||||||
|
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
parseMeminfo(string(b), &s)
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
parseLoadavg(string(b), &s)
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile("/proc/uptime"); err == nil {
|
||||||
|
parseUptime(string(b), &s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeminfo(s string, st *Stats) {
|
||||||
|
for _, ln := range strings.Split(s, "\n") {
|
||||||
|
f := strings.Fields(ln)
|
||||||
|
if len(f) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(f[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch f[0] {
|
||||||
|
case "MemTotal:":
|
||||||
|
st.MemTotal = v
|
||||||
|
case "MemAvailable:":
|
||||||
|
st.MemAvail = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLoadavg(s string, st *Stats) {
|
||||||
|
if f := strings.Fields(s); len(f) > 0 {
|
||||||
|
st.Load1, _ = strconv.ParseFloat(f[0], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUptime(s string, st *Stats) {
|
||||||
|
if f := strings.Fields(s); len(f) > 0 {
|
||||||
|
st.Uptime, _ = strconv.ParseFloat(f[0], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package sysinfo
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
s := Stats{}
|
||||||
|
parseMeminfo("MemTotal: 32740916 kB\nMemFree: 9410408 kB\nMemAvailable: 25719724 kB\n", &s)
|
||||||
|
if s.MemTotal != 32740916 || s.MemAvail != 25719724 {
|
||||||
|
t.Fatalf("meminfo salah: %+v", s)
|
||||||
|
}
|
||||||
|
parseLoadavg("0.42 0.61 0.55 3/1812 123456\n", &s)
|
||||||
|
if s.Load1 != 0.42 {
|
||||||
|
t.Fatalf("loadavg salah: %v", s.Load1)
|
||||||
|
}
|
||||||
|
parseUptime("123456.78 9876.5\n", &s)
|
||||||
|
if s.Uptime != 123456.78 {
|
||||||
|
t.Fatalf("uptime salah: %v", s.Uptime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// input rusak tidak boleh panic & tidak mengubah nilai valid
|
||||||
|
parseMeminfo("sampah\nMemTotal:\n", &s)
|
||||||
|
parseLoadavg("", &s)
|
||||||
|
if s.MemTotal != 32740916 {
|
||||||
|
t.Fatalf("nilai valid berubah setelah input rusak: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLive(t *testing.T) {
|
||||||
|
s := Read()
|
||||||
|
if s.MemTotal == 0 {
|
||||||
|
t.Skip("bukan Linux / /proc tidak tersedia — dilewati")
|
||||||
|
}
|
||||||
|
t.Logf("total %d MB · avail %d MB · load %.2f · uptime %.0f dtk",
|
||||||
|
s.MemTotal/1024, s.MemAvail/1024, s.Load1, s.Uptime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderClock — jam digital blok, 5 baris, format HH:MM:SS.
|
||||||
|
// Teks polos (tanpa ANSI); pemanggil yang mewarnai.
|
||||||
|
func RenderClock(t time.Time) []string {
|
||||||
|
s := t.Format("15:04:05")
|
||||||
|
rows := make([]string, 5)
|
||||||
|
for _, r := range s {
|
||||||
|
glyph, ok := blockDigits[r]
|
||||||
|
if !ok {
|
||||||
|
glyph = blockDigits[' ']
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i] != "" {
|
||||||
|
rows[i] += " "
|
||||||
|
}
|
||||||
|
rows[i] += glyph[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockDigits = map[rune][]string{
|
||||||
|
'0': {"███", "█ █", "█ █", "█ █", "███"},
|
||||||
|
'1': {" █ ", " █ ", " █ ", " █ ", " █ "},
|
||||||
|
'2': {"███", " █", "███", "█ ", "███"},
|
||||||
|
'3': {"███", " █", "███", " █", "███"},
|
||||||
|
'4': {"█ █", "█ █", "███", " █", " █"},
|
||||||
|
'5': {"███", "█ ", "███", " █", "███"},
|
||||||
|
'6': {"███", "█ ", "███", "█ █", "███"},
|
||||||
|
'7': {"███", " █", " █", " █", " █"},
|
||||||
|
'8': {"███", "█ █", "███", "█ █", "███"},
|
||||||
|
'9': {"███", "█ █", "███", " █", "███"},
|
||||||
|
':': {" ", "█", " ", "█", " "},
|
||||||
|
' ': {" ", " ", " ", " ", " "},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CenterBlock — pusatkan tiap baris blok dalam lebar w (helper tampilan).
|
||||||
|
func CenterBlock(rows []string, w int) []string {
|
||||||
|
out := make([]string, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
pad := (w - len([]rune(r))) / 2
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
out[i] = strings.Repeat(" ", pad) + r
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderClock(t *testing.T) {
|
||||||
|
rows := RenderClock(time.Date(2026, 10, 15, 18, 28, 41, 0, time.UTC))
|
||||||
|
if len(rows) != 5 {
|
||||||
|
t.Fatalf("baris jam: %d, mau 5", len(rows))
|
||||||
|
}
|
||||||
|
w := len([]rune(rows[0]))
|
||||||
|
for i, r := range rows {
|
||||||
|
if len([]rune(r)) != w {
|
||||||
|
t.Fatalf("lebar baris %d tidak konsisten: %d vs %d", i, len([]rune(r)), w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "18:28:41" = 8 glyph × 3 + 7 spasi = 31
|
||||||
|
if w != 27 {
|
||||||
|
t.Fatalf("lebar jam: %d, mau 27", w)
|
||||||
|
}
|
||||||
|
if rows[0] == "" || rows[4] == "" {
|
||||||
|
t.Fatal("baris kosong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCenterBlock(t *testing.T) {
|
||||||
|
rows := CenterBlock([]string{"ab", "abcd"}, 8)
|
||||||
|
if rows[0] != " ab" || rows[1] != " abcd" {
|
||||||
|
t.Fatalf("center salah: %q %q", rows[0], rows[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user