feat(ui): seleksi baris pakai highlight (token Selection) gantikan panah — sesuai masukan
CI / test (push) Failing after 14s

This commit is contained in:
oxpoe committed 2026-10-15 14:17:27 +07:00
1 parent a28b83513d
commit db891398fb
4 files changed
+138 -32

No files matched your search

+1 -1
View File
@@ -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.
```
+36 -7
View File
@@ -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)
+65 -15
View File
@@ -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
+36 -9
View File
@@ -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 {