From db891398fb566ac164ddaa79c369aa82d7ea5ee6 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:17:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20seleksi=20baris=20pakai=20highlight?= =?UTF-8?q?=20(token=20Selection)=20gantikan=20panah=20=E2=80=94=20sesuai?= =?UTF-8?q?=20masukan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PRD.md | 2 +- e2e/e2e_test.go | 43 ++++++++++++++++++++---- e2e/screen_test.go | 80 ++++++++++++++++++++++++++++++++++++--------- internal/app/app.go | 45 ++++++++++++++++++++----- 4 files changed, 138 insertions(+), 32 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index d8362b5..0f7b060 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -627,7 +627,7 @@ Layar pembuka interaktif yang menampilkan identitas keren `tia` ("Terminal Is A ├──────────────────────┴───────────────────────────────────────────────────────┤ │ [?]bantuan [c]konversi [n]baru [P]bersihkan [m]pilih-teks [/]cari [q]keluar │ └──────────────────────────────────────────────────────────────────────────────┘ - ▶ = terpilih ● berjalan ○ berhenti ▾/▸ = buka/tutup bagian + baris ter-highlight = terpilih ● berjalan ○ berhenti ▾/▸ = buka/tutup bagian Mouse: klik item/tab/tombol · klik kanan = menu · scroll · seret batas = ubah lebar Semua elemen [..] di toolbar & status bar bisa diklik. ``` diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c66a00a..7297963 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -149,6 +149,23 @@ func (u *tui) waitContains(substr string, d time.Duration) { u.t.Fatalf("layar tidak memuat %q dalam %v.\n--- snapshot layar ---\n%s", substr, d, u.screen()) } +// waitHighlighted — menunggu baris yang memuat substr ber-latar (SGR bg), +// yaitu penanda baris terpilih di daftar. +func (u *tui) waitHighlighted(substr string, d time.Duration) { + u.t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + u.mu.Lock() + ok := u.scr.Highlighted(substr) + u.mu.Unlock() + if ok { + return + } + time.Sleep(50 * time.Millisecond) + } + u.t.Fatalf("baris %q tidak ter-highlight dalam %v.\n--- snapshot layar ---\n%s", substr, d, u.screen()) +} + func (u *tui) waitExit(d time.Duration) int { u.t.Helper() done := make(chan error, 1) @@ -347,8 +364,9 @@ func TestMouseAndNavigateKeys(t *testing.T) { } } -// TestClickSelectsContainer — lokal/CI-with-docker saja: klik baris sidebar -// harus memindahkan penanda pilihan (▶) ke kontainer. +// TestClickSelectsContainer — lokal/CI-with-docker saja: baris terpilih +// ber-highlight (token Selection), klik berpindah seleksi, dan panel detail +// mengikuti pilihan. func TestClickSelectsContainer(t *testing.T) { if _, err := os.Stat("/var/run/docker.sock"); err != nil { t.Skip("docker socket tidak ada") @@ -361,16 +379,27 @@ func TestClickSelectsContainer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() items, err := cl.ListContainers(ctx) - if err != nil || len(items) == 0 { - t.Skipf("tidak ada kontainer (err=%v, n=%d)", err, len(items)) + if err != nil || len(items) < 2 { + t.Skipf("butuh >= 2 kontainer (err=%v, n=%d)", err, len(items)) + } + short := func(s string) string { + if len(s) > 12 { + return s[:12] + } + return s } u := startTUI(t) 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.waitContains("▶", 3*time.Second) + u.waitContains(short(items[0].Name), 5*time.Second) + u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) + + // klik baris item ke-2: xterm y=4 (1-based) → app row 3 → indeks 1 + u.send("\x1b[<0;10;4M\x1b[<0;10;4m") + u.waitHighlighted(short(items[1].Name), 3*time.Second) + u.waitContains(items[1].ID, 3*time.Second) // panel detail ikut berpindah + u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) diff --git a/e2e/screen_test.go b/e2e/screen_test.go index 9f0e224..69802d3 100644 --- a/e2e/screen_test.go +++ b/e2e/screen_test.go @@ -13,11 +13,19 @@ import ( // CUP (H/f), relative moves (A-D), G/d, erase line (K), erase display (J), // erase chars (X), save/restore cursor (ESC 7/8). SGR (m) & mode diam-diam // diabaikan. +// cell — satu sel layar; bg menandai sel ditulis saat SGR latar aktif +// (dipakai tes untuk memverifikasi baris ter-highlight). +type cell struct { + r rune + bg bool +} + type screen struct { rows, cols int - grid [][]rune + grid [][]cell curR, curC int savR, savC int + bg bool // status SGR latar saat ini st int // 0 normal · 1 esc · 2 esc-intern · 3 csi · 4 osc · 5 osc-esc seq []byte @@ -35,11 +43,11 @@ const ( ) func newScreen(rows, cols int) *screen { - g := make([][]rune, rows) + g := make([][]cell, rows) for i := range g { - g[i] = make([]rune, cols) + g[i] = make([]cell, cols) for j := range g[i] { - g[i][j] = ' ' + g[i][j] = cell{r: ' '} } } return &screen{rows: rows, cols: cols, grid: g} @@ -181,21 +189,21 @@ func (s *screen) applyCSI(final rune, params string) { switch at(nums, 0, 0) { case 0: // dari kursor sampai akhir layar for c := s.curC; c < s.cols; c++ { - s.grid[s.curR][c] = ' ' + s.grid[s.curR][c] = cell{r: ' '} } for r := s.curR + 1; r < s.rows; r++ { for c := range s.grid[r] { - s.grid[r][c] = ' ' + s.grid[r][c] = cell{r: ' '} } } case 1: // dari awal layar sampai kursor for r := 0; r < s.curR; r++ { for c := range s.grid[r] { - s.grid[r][c] = ' ' + s.grid[r][c] = cell{r: ' '} } } for c := 0; c <= s.curC && c < s.cols; c++ { - s.grid[s.curR][c] = ' ' + s.grid[s.curR][c] = cell{r: ' '} } default: // 2 (layar penuh) & 3 (scrollback) → bersihkan s.clearAll() @@ -205,18 +213,32 @@ func (s *screen) applyCSI(final rune, params string) { case 'X': // erase n karakter (kursor tidak pindah) n := at(nums, 0, 1) for i := 0; i < n && s.curC+i < s.cols; i++ { - s.grid[s.curR][s.curC+i] = ' ' + s.grid[s.curR][s.curC+i] = cell{r: ' '} } case 'h': if priv == '?' && at(nums, 0, 0) == 1049 { // masuk alt-screen s.clearAll() } + case 'm': + // Lacak SGR latar: 48 (set bg) · 0/49 (reset). Dipakai sel & Highlighted(). + if len(nums) == 0 { + s.bg = false + return + } + for _, n := range nums { + switch n { + case 0, 49: + s.bg = false + case 48: + s.bg = true + } + } } } func (s *screen) put(r rune) { s.clampCursor() - s.grid[s.curR][s.curC] = r + s.grid[s.curR][s.curC] = cell{r: r, bg: s.bg} s.curC++ } @@ -238,14 +260,14 @@ func (s *screen) eraseLine(mode int) { switch mode { case 0: // dari kursor ke akhir if c >= s.curC { - s.grid[s.curR][c] = ' ' + s.grid[s.curR][c] = cell{r: ' '} } case 1: // dari awal ke kursor if c <= s.curC { - s.grid[s.curR][c] = ' ' + s.grid[s.curR][c] = cell{r: ' '} } case 2: // seluruh baris - s.grid[s.curR][c] = ' ' + s.grid[s.curR][c] = cell{r: ' '} } } } @@ -253,7 +275,7 @@ func (s *screen) eraseLine(mode int) { func (s *screen) clearAll() { for i := range s.grid { for j := range s.grid[i] { - s.grid[i][j] = ' ' + s.grid[i][j] = cell{r: ' '} } } } @@ -262,11 +284,39 @@ func (s *screen) clearAll() { func (s *screen) String() string { lines := make([]string, s.rows) for i, row := range s.grid { - lines[i] = strings.TrimRight(string(row), " ") + lines[i] = strings.TrimRight(rowString(row), " ") } return strings.Join(lines, "\n") } +// Highlighted — true bila ada baris yang memuat substr dan posisi awal +// substr itu berlatar (SGR bg aktif saat sel ditulis). +func (s *screen) Highlighted(substr string) bool { + if substr == "" { + return false + } + for _, row := range s.grid { + text := rowString(row) + i := strings.Index(text, substr) + if i < 0 { + continue + } + pos := len([]rune(text[:i])) + if pos < len(row) && row[pos].bg { + return true + } + } + return false +} + +func rowString(row []cell) string { + var b strings.Builder + for _, c := range row { + b.WriteRune(c.r) + } + return b.String() +} + func parseNums(s string) []int { if s == "" { return nil diff --git a/internal/app/app.go b/internal/app/app.go index 1fcfb21..796eda0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -616,11 +616,10 @@ func (m Model) sidebarView() string { } func (m Model) sectionLine(i, innerW int) string { - mark := " " - if i == m.cur() { - mark = styled(theme.Current().Accent, "▶ ") - } + sel := i == m.cur() th := theme.Current() + + var line string switch m.section { case sectionImages: im := m.images[i] @@ -628,19 +627,47 @@ func (m Model) sectionLine(i, innerW int) string { if m.imageInUse(im.ID) { glyph, color = "●", th.Success } - return mark + styled(color, glyph) + " " + - text(truncate(imageDisplayName(im), innerW-10)) + dim(" "+humanBytes(im.Size)) + line = paintRow(sel, color, glyph) + + paintRow(sel, th.Text, " "+truncate(imageDisplayName(im), innerW-10)) + + paintRow(sel, th.Subtext, " "+humanBytes(im.Size)) case sectionVolumes: v := m.volumes[i] - return mark + text(truncate(v.Name, innerW-6)) + dim(" "+v.Driver) + line = paintRow(sel, th.Text, truncate(v.Name, innerW-6)) + + paintRow(sel, th.Subtext, " "+v.Driver) case sectionNetworks: n := m.networks[i] - return mark + text(truncate(n.Name, innerW-6)) + dim(" "+n.Driver) + line = paintRow(sel, th.Text, truncate(n.Name, innerW-6)) + + paintRow(sel, th.Subtext, " "+n.Driver) default: c := m.containers[i] glyph, color := stateStyle(c.State) - return mark + styled(color, glyph) + " " + text(truncate(c.Name, innerW-4)) + line = paintRow(sel, color, glyph) + + paintRow(sel, th.Text, " "+truncate(c.Name, innerW-4)) } + if sel { + line = selPad(line, innerW) + } + return line +} + +// paintRow — satu segmen baris daftar; baris terpilih memakai latar +// token Selection sehingga posisi aktif jelas tanpa perlu panah. +func paintRow(sel bool, fg theme.Color, s string) string { + st := theme.Style(fg) + if sel { + st = st.Background(lipgloss.Color(string(theme.Current().Selection))) + } + return st.Render(s) +} + +// selPad — isi sisa lebar baris terpilih dengan latar Selection penuh. +func selPad(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap <= 0 { + return s + } + bg := lipgloss.NewStyle().Background(lipgloss.Color(string(theme.Current().Selection))) + return s + bg.Render(strings.Repeat(" ", gap)) } func (m Model) contentView() string {