ui: window the host table by lines so the filter and cursor stay on screen
Signed-off-by: dawn <90008@klbr.net>
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384package app
import ( "fmt" "strings" "sync" "testing" "time"
"charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/creack/pty" xterm "github.com/gitpod-io/xterm-go" "github.com/samber/lo")
// with more hosts than the terminal has rows, typing and moving must still// show on screen: an overlong view loses its top to the renderer, so the// keys land in the model while only the spinners visibly changefunc TestLongTablesKeepTheFilterAndCursorOnScreen(t *testing.T) { const hosts, rows, cols = 40, 24, 80 f := textinput.New() f.Prompt = "› " f.Focus() f.SetWidth(cols - 8) m := pickerModel{width: cols, filter: f, th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot))} m.rows = []hostRow{{name: "selfish", local: true}} for i := range hosts { m.rows = append(m.rows, hostRow{name: fmt.Sprintf("host%03d", i), probing: true, started: time.Now()}) } m.probes, m.pending = make(chan probeResult, hosts), hosts m.move(0)
master, tty, err := pty.Open() if err != nil { t.Fatal(err) } defer master.Close() defer tty.Close() if err := pty.Setsize(tty, &pty.Winsize{Rows: rows, Cols: cols}); err != nil { t.Fatal(err) } vt := xterm.New(xterm.WithCols(cols), xterm.WithRows(rows)) vt.OnData(func(s string) { _, _ = master.Write([]byte(s)) }) // answer the program's queries var mu sync.Mutex go func() { buf := make([]byte, 64<<10) for { n, err := master.Read(buf) mu.Lock() _, _ = vt.Write(buf[:n]) mu.Unlock() if err != nil { return } } }() screen := func() string { mu.Lock(); defer mu.Unlock(); return vt.String() } shows := func(want []string) bool { s := screen() return lo.EveryBy(want, func(w string) bool { return strings.Contains(s, w) }) } waitFor := func(want ...string) { t.Helper() deadline := time.Now().Add(3 * time.Second) for !shows(want) { if time.Now().After(deadline) { t.Fatalf("the screen never showed %q:\n%s", want, screen()) } time.Sleep(20 * time.Millisecond) } }
p := tea.NewProgram(m, tea.WithInput(tty), tea.WithOutput(tty)) done := make(chan error, 1) go func() { _, err := p.Run(); done <- err }() defer func() { p.Quit(); <-done }()
waitFor("│ ›", "› selfish") _, _ = master.Write([]byte("hos\x1b[B\x1b[B\x1b[B")) // selfish drops out, so the cursor lands on the fourth host waitFor("│ › hos", "› host003")
}6 unmodified lines789101112111314121617131415192021222324251617181920272122292324313233252627373828293031323334354748363738394041534243444546474849506 unmodified lines
"tobi/internal/session")
// the draw window keeps the cursor's row on screen within the line budgetfunc TestTableWindowHoldsTheCursor(t *testing.T) {func TestScrollWindowHoldsTheCursor(t *testing.T) { // rows: 3 lines, 1 line, 2 lines, 1 line, 4 lines lines := []int{3, 1, 2, 1, 4} for _, tc := range []struct { cursorRow, avail int from, to int n, at, avail int from, to int }{ {0, 3, 0, 1}, // the cursor row fills the budget exactly {0, 4, 0, 2}, // grows down into the 1-line row {1, 3, 1, 3}, // grows down first, then up {2, 3, 2, 4}, // the 2-line row plus the 1-line row below it {4, 4, 4, 5}, // a row larger than the budget draws alone {2, 0, 0, 5}, // no known height: draw everything {2, 99, 0, 5}, // everything fits {10, 0, 4, 0, 4}, {10, 5, 4, 3, 7}, {10, 9, 4, 6, 10}, {3, 2, 4, 0, 3}, } { from, to := tableWindow(lines, tc.cursorRow, tc.avail) from, to := scrollWindow(tc.n, tc.at, tc.avail) if from != tc.from || to != tc.to { t.Errorf("tableWindow(cursor=%d, avail=%d) = [%d,%d), want [%d,%d)", tc.cursorRow, tc.avail, from, to, tc.from, tc.to) t.Errorf("scrollWindow(%d, %d, %d) = [%d,%d), want [%d,%d)", tc.n, tc.at, tc.avail, from, to, tc.from, tc.to) } if tc.avail > 0 && !(from <= tc.cursorRow && tc.cursorRow < to) { t.Errorf("window [%d,%d) loses cursor row %d", from, to, tc.cursorRow) } }}
// with more hosts than the terminal is tall, the drawn table fits the budget// and still shows the cursor's row: the selection must never scroll outfunc TestViewWindowsLongTablesToTheCursor(t *testing.T) { m := pickerModel{width: 80, height: 10, th: newTheme(true)} for i := range 30 { m.rows = append(m.rows, hostRow{name: "host", probing: true, started: time.Now().Add(-time.Duration(i) * time.Minute)}) } m.rows = append(m.rows, hostRow{name: "zzz", sessions: []session.Session{{ID: "q", Command: "nu", Cwd: "/tmp", StartedAt: time.Now()}}}) m.cursor = 25 out := m.view() if table := strings.Count(out, "searching"); table != 5 { // 10 - the 5 fixed lines around the table t.Errorf("drew %d table lines into a 5-line budget", table) if lines := strings.Count(out, "\n") + 1; lines != m.height { t.Errorf("drew %d lines into a %d-line terminal", lines, m.height) } if !strings.Contains(out, " › host") { t.Error("the cursor row is not drawn") } if !strings.Contains(out, "31 host") { // the header counts stay honest if !strings.Contains(out, "31 host") { t.Error("the header host count is wrong") }
m.cursor = 31 // the session line under the last host if !strings.Contains(m.view(), " › q") { t.Error("the cursor's session line is not drawn") }}380 unmodified linesExpand all3813823833843853863873883853863873883893903913923963973933983994003943953963975 unmodified lines4034044054064144154164174184194204214224234244254264274284294304314324334344354364074084094384394404414424104114124134144154444454464474484494504514524534544164174184194204214224584594604614624634644234244664674254264694704274284294304314324334344354364374384804394404414424434444454844464474484494504514524534544554564576 unmodified lines46446546646750246846947047147277 unmodified lines380 unmodified linesExpand all glyph, sess, ping string glyphSt, sessSt, nameSt, pingSt lipgloss.Style}
// only the cursor's row animates: every probing row repainting a live// spinner and tenths-of-seconds timer each frame is what stalls a slow// terminal behind the renderer, taking input with itfunc (t theme) hostLine(r hostRow, live bool, spin string) hostLine {func (t theme) hostLine(r hostRow, spin string) hostLine { l := hostLine{ glyph: "○", sess: "no sessions", ping: fmt.Sprintf("%.1fs", r.duration.Seconds()), glyphSt: t.dim, sessSt: t.dim, nameSt: t.text, pingSt: t.dim, } switch { case r.probing: l.sess = "searching" if live { l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds()) l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds()) } else { l.ping = fmt.Sprintf("%.0fs", probeClock(time.Since(r.started)).Seconds()) } case r.unreachable: l.glyph, l.sess = "!", "unreachable" l.glyphSt, l.sessSt, l.nameSt, l.pingSt = t.salmon, t.salmon.Faint(true), t.faint, t.salmon.Faint(true) case r.peerOffline:5 unmodified lines } return l}
// tableRows measures each row's line count and finds the row holding the// cursor, for the draw windowfunc tableRows(rows []hostRow, cursor int) (lines []int, cursorRow int) { lines = make([]int, len(rows)) e, cursorRow := 0, max(0, len(rows)-1) at := func(i, entry int) { if entry == cursor { cursorRow = i } } for i, r := range rows { lines[i] = 1 + len(r.sessions) if selectableHost(r) { at(i, e) e++ } for range r.sessions { at(i, e) e++ } } return lines, cursorRow}// the header, the filter box, the divider and the hints drawn around the tableconst chromeLines = 5
// tableWindow picks contiguous rows totalling at most avail lines that keep// the cursor's row on screen: it grows down from the cursor first, then upfunc tableWindow(lines []int, cursorRow, avail int) (from, to int) { if avail <= 0 || len(lines) == 0 { return 0, len(lines)// scrollWindow picks the avail of n lines to draw, centred on the cursor's// line and clamped to the endsfunc scrollWindow(n, at, avail int) (from, to int) { if n <= avail { return 0, n } from, to, used := cursorRow, min(cursorRow+1, len(lines)), 0 if from < len(lines) { used = lines[from] } for to < len(lines) && used+lines[to] <= avail { used, to = used+lines[to], to+1 } for from > 0 && used+lines[from-1] <= avail { used, from = used+lines[from-1], from-1 } return from, to from = max(0, min(n-avail, at-avail/2)) return from, from + avail}
// the table only draws the lines that fit: a taller view loses its top to// the renderer, taking the filter box and the cursor with itfunc (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) { // header, the filter box, the divider and the hints are fixed lines lines, cursorRow := tableRows(rows, m.cursor) from, to := tableWindow(lines, cursorRow, max(0, m.height-5)) skip := 0 // the entries index of the first drawn row, so the selection still lines up for _, r := range rows[:from] { skip += len(r.sessions) + lo.Ternary(selectableHost(r), 1, 0) } var t strings.Builder nameW := min(22, lo.Max(lo.Map(rows, func(r hostRow, _ int) int { return utf8.RuneCountInString(r.name) }))) rows = rows[from:to] cursor := skip // the entries() index of the line being drawn cursor, at := 0, 0 // the entries() index being drawn, and the table line holding the cursor for _, r := range rows { sel := selectableHost(r) && cursor == m.cursor l := m.th.hostLine(r, sel, m.spinner.View()) l := m.th.hostLine(r, m.spinner.View()) reserve := 5 + lo.Ternary(r.local, 8, 0) // the local pill and its gap sessW := min(12, max(0, w-3-2-nameW-3-reserve)) sel := selectableHost(r) && cursor == m.cursor if selectableHost(r) { cursor++ } pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, m.th.pill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt if sel { pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), m.th.plain, m.th.plain at = strings.Count(t.String(), "\n") } m.th.writeLine(b, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel) m.th.writeLine(&t, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel)
for _, s := range r.sessions { sel := cursor == m.cursor if sel { at = strings.Count(t.String(), "\n") } m.sessionRow(b, w, s, sel, m.sessionStatus(s, sel)) m.sessionRow(&t, w, s, sel, m.sessionStatus(s, sel)) cursor++ } } lines := strings.SplitAfter(t.String(), "\n") lines = lines[:len(lines)-1] // the empty tail after the last newline from, to := scrollWindow(len(lines), at, lo.Ternary(m.height > 0, max(1, m.height-chromeLines), len(lines))) b.WriteString(strings.Join(lines[from:to], ""))}
func runPicker(local []session.Session, initial string) error { peersCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)6 unmodified lines filter.Prompt = "› " filter.Placeholder = "search sessions…" filter.Focus() filter.SetWidth(startWidth - 8) th := newTheme(true) // dark until the terminal answers the background query th := newTheme(true) st := textinput.DefaultStyles(true) st.Focused.Placeholder, st.Blurred.Placeholder = th.faint, th.faint filter.SetStyles(st)
77 unmodified lines