feat(ui): seleksi baris pakai highlight (token Selection) gantikan panah — sesuai masukan
CI / test (push) Failing after 14s
CI / test (push) Failing after 14s
This commit is contained in:
1 parent
a28b83513d
commit
db891398fb
4 files changed
+138
-32
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user