From 82083b2f49f9ca123a9cd6ae5e8359db2ebaf287 Mon Sep 17 00:00:00 2001 From: zbinorama Date: Wed, 9 Sep 2026 18:32:06 +0300 Subject: [PATCH] feat(top): add vertical pagination to top Add PgUp and PgDn handlers for paging through rows in the main statistics view while keeping the table header visible. Preserve the current snapshot during redraws, clamp the vertical offset after filtering, refresh, and terminal resize, and retain the horizontal column position. Add tests for page offsets, filtered rows, redraw behavior, header rendering, horizontal offset preservation, and the PgUp/PgDn handlers. --- top/config.go | 27 +++--- top/config_view.go | 52 +++++++++++ top/dialog.go | 1 + top/help.go | 1 + top/keybindings.go | 2 + top/scroll_test.go | 216 +++++++++++++++++++++++++++++++++++++++++++++ top/stat.go | 98 +++++++++++++++----- top/ui.go | 8 ++ 8 files changed, 370 insertions(+), 35 deletions(-) create mode 100644 top/scroll_test.go diff --git a/top/config.go b/top/config.go index fe45b3d..0dce5ec 100644 --- a/top/config.go +++ b/top/config.go @@ -15,16 +15,18 @@ const defaultRefresh = time.Second // config defines 'top' program runtime configuration. type config struct { - view view.View // Current active view. - views view.Views // List of all available views. - queryOptions query.Options // Queries' settings that might depend on Postgres version. - viewCh chan view.View // Channel used for passing view settings to stats goroutine. - logtail stat.Logfile // Logfile used for working with Postgres log file. - dialog dialogType // Remember current user-started dialog, used for selecting needed dialog handler. - menu menuStyle // When working with menus, keep properties of the menu. - procMask int // Process mask used for selecting group of process. - scrollOffset int // Horizontal scroll position: index into scrollable columns (1..Ncols-1); 0 means no scroll. Ephemeral, reset on view switch. - verbose bool // Verbose display mode for the top summary panels. Persistent: unlike scrollOffset, it is NOT reset on view switch (mirrored into every views entry). + view view.View // Current active view. + views view.Views // List of all available views. + queryOptions query.Options // Queries' settings that might depend on Postgres version. + viewCh chan view.View // Channel used for passing view settings to stats goroutine. + redrawCh chan struct{} // Channel used for requesting a redraw without changing collector settings. + logtail stat.Logfile // Logfile used for working with Postgres log file. + dialog dialogType // Remember current user-started dialog, used for selecting needed dialog handler. + menu menuStyle // When working with menus, keep properties of the menu. + procMask int // Process mask used for selecting group of process. + scrollOffset int // Horizontal scroll position: index into scrollable columns (1..Ncols-1); 0 means no scroll. Ephemeral, reset on view switch. + verticalOffset int // Vertical scroll position in dbstat rows. Ephemeral, reset on view switch. + verbose bool // Verbose display mode for the top summary panels. Persistent: unlike scrollOffset, it is NOT reset on view switch (mirrored into every views entry). // autoScrollToOrderKey is a one-shot request to bring the sort column into the visible window. // It is set by the sort handlers (orderKeyLeft/orderKeyRight) and consumed — and cleared — by // renderDbstat on the next frame, so manual [ / ] scrolling afterwards is never undone by the @@ -45,7 +47,8 @@ func newConfig() *config { views := view.New() return &config{ - views: views, - viewCh: make(chan view.View), + views: views, + viewCh: make(chan view.View), + redrawCh: make(chan struct{}), } } diff --git a/top/config_view.go b/top/config_view.go index 90361a4..0deb1fd 100644 --- a/top/config_view.go +++ b/top/config_view.go @@ -30,6 +30,7 @@ func orderKeyLeft(config *config) func(_ *gocui.Gui, _ *gocui.View) error { // itself is not computed here: column widths are known only after alignment against real // data, which happens on the render path. config.autoScrollToOrderKey = true + config.verticalOffset = 0 config.viewCh <- config.view return nil @@ -46,6 +47,7 @@ func orderKeyRight(config *config) func(_ *gocui.Gui, _ *gocui.View) error { // See orderKeyLeft: the scroll is deferred to the next render, which knows the widths. config.autoScrollToOrderKey = true + config.verticalOffset = 0 config.viewCh <- config.view return nil @@ -83,6 +85,52 @@ func scrollRight(config *config) func(_ *gocui.Gui, _ *gocui.View) error { } } +// scrollPage moves the vertical origin of the main statistics view by one visible page. +// The upper bound is applied during rendering, when the filtered row count is available. +func scrollPage(config *config, direction int) func(g *gocui.Gui, _ *gocui.View) error { + return func(g *gocui.Gui, _ *gocui.View) error { + v, err := g.View("dbstat") + if err != nil { + return err + } + _, height := v.Size() + config.verticalOffset = pageOffset(config.verticalOffset, direction, scrollPageSize(height)) + requestRedraw(config) + return nil + } +} + +func requestRedraw(config *config) { + config.redrawCh <- struct{}{} +} + +// scrollPageSize reserves one row for the fixed header and keeps the scroll step positive. +func scrollPageSize(height int) int { + if height <= 1 { + return 1 + } + return height - 1 +} + +func pageOffset(current, direction, pageSize int) int { + if pageSize < 1 { + pageSize = 1 + } + next := current + direction*pageSize + if next < 0 { + return 0 + } + return next +} + +func scrollPageUp(config *config) func(*gocui.Gui, *gocui.View) error { + return scrollPage(config, -1) +} + +func scrollPageDown(config *config) func(*gocui.Gui, *gocui.View) error { + return scrollPage(config, 1) +} + // increaseWidth increases visible width of current column. func increaseWidth(config *config) func(_ *gocui.Gui, _ *gocui.View) error { return func(_ *gocui.Gui, _ *gocui.View) error { @@ -113,6 +161,7 @@ func decreaseWidth(config *config) func(_ *gocui.Gui, _ *gocui.View) error { func switchSortOrder(config *config) func(g *gocui.Gui, _ *gocui.View) error { return func(g *gocui.Gui, _ *gocui.View) error { config.view.OrderDesc = !config.view.OrderDesc + config.verticalOffset = 0 printCmdline(g, "Switch sort order") config.viewCh <- config.view @@ -197,6 +246,7 @@ func clearFilters(config *config) func(g *gocui.Gui, _ *gocui.View) error { // Notify the stats goroutine only when something has been removed. viewCh is // unbuffered and nobody is expected to read an update that changes nothing. if n > 0 { + config.verticalOffset = 0 config.viewCh <- config.view } @@ -316,6 +366,7 @@ func viewSwitchHandler(config *config, c string) { config.views[config.view.Name] = config.view config.view = config.views[c] config.scrollOffset = 0 // horizontal scroll is ephemeral; reset on view switch + config.verticalOffset = 0 // vertical scroll is ephemeral; reset on view switch config.autoScrollToOrderKey = false // a pending auto-scroll must not fire on the new screen config.viewCh <- config.view } @@ -331,6 +382,7 @@ func switchViewToProcPidStat(app *app) func(g *gocui.Gui, _ *gocui.View) error { // Horizontal scroll is ephemeral; reset it when entering the per-process // screen. This path bypasses viewSwitchHandler, so the reset is done here. app.config.scrollOffset = 0 + app.config.verticalOffset = 0 // Same for a pending auto-scroll request: it belongs to the outgoing screen's sort // column. Reset before the local-mode guard below, so the switch cannot leave it armed. diff --git a/top/dialog.go b/top/dialog.go index 2303370..9ba8363 100644 --- a/top/dialog.go +++ b/top/dialog.go @@ -215,6 +215,7 @@ func dialogFinish(app *app) func(g *gocui.Gui, v *gocui.View) error { message = doReload(answer, app.db) case dialogFilter: message = setFilter(answer, app.config.view) + app.config.verticalOffset = 0 case dialogCancelQuery: message = killSingle(app.db, "cancel", answer) case dialogTerminateBackend: diff --git a/top/help.go b/top/help.go index 5bb585f..db372af 100644 --- a/top/help.go +++ b/top/help.go @@ -22,6 +22,7 @@ general actions: \ '\' clear all filters of the current screen. Up,Down 'Up' increase column width, 'Down' decrease column width. [,] '[' scroll columns left, ']' scroll columns right. + PgUp,PgDn previous/next page of statistics. C,E,R config: 'C' show config, 'E' edit configs, 'R' reload config. ~ start psql session. l open log file with pager. diff --git a/top/keybindings.go b/top/keybindings.go index 75c1c2e..86c627b 100644 --- a/top/keybindings.go +++ b/top/keybindings.go @@ -23,6 +23,8 @@ func keybindings(app *app) error { {"sysstat", gocui.KeyArrowRight, orderKeyRight(app.config)}, {"sysstat", gocui.KeyArrowUp, increaseWidth(app.config)}, {"sysstat", gocui.KeyArrowDown, decreaseWidth(app.config)}, + {"sysstat", gocui.KeyPgup, scrollPageUp(app.config)}, + {"sysstat", gocui.KeyPgdn, scrollPageDown(app.config)}, {"sysstat", '[', scrollLeft(app.config)}, {"sysstat", ']', scrollRight(app.config)}, {"sysstat", '<', switchSortOrder(app.config)}, diff --git a/top/scroll_test.go b/top/scroll_test.go new file mode 100644 index 0000000..95bba00 --- /dev/null +++ b/top/scroll_test.go @@ -0,0 +1,216 @@ +package top + +import ( + "bytes" + "database/sql" + "fmt" + "regexp" + "testing" + + "github.com/jroimartin/gocui" + "github.com/lesovsky/pgcenter/internal/stat" + "github.com/lesovsky/pgcenter/internal/view" +) + +func TestPageOffset(t *testing.T) { + tests := []struct { + name, direction string + current, size int + want int + }{ + {name: "down", direction: "down", current: 0, size: 10, want: 10}, + {name: "multiple down", direction: "down", current: 10, size: 10, want: 20}, + {name: "up", direction: "up", current: 20, size: 10, want: 10}, + {name: "up clamps at top", direction: "up", current: 3, size: 10, want: 0}, + {name: "zero page size", direction: "down", current: 0, size: 0, want: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + direction := 1 + if tt.direction == "up" { + direction = -1 + } + if got := pageOffset(tt.current, direction, tt.size); got != tt.want { + t.Fatalf("pageOffset(%d, %d, %d) = %d, want %d", tt.current, direction, tt.size, got, tt.want) + } + }) + } +} + +func TestRenderedDbstatRowsHonorsFilters(t *testing.T) { + s := stat.Stat{Pgstat: stat.Pgstat{Result: stat.PGresult{ + Nrows: 3, + Ncols: 2, + Values: [][]sql.NullString{ + {{String: "keep", Valid: true}, {String: "one", Valid: true}}, + {{String: "drop", Valid: true}, {String: "two", Valid: true}}, + {{String: "keep", Valid: true}, {String: "three", Valid: true}}, + }, + }}} + + filters := map[int]*regexp.Regexp{0: regexp.MustCompile("keep")} + if got := renderedDbstatRows(s, filters); got != 2 { + t.Fatalf("renderedDbstatRows(filtered) = %d, want 2", got) + } + if got := renderedDbstatRows(s, nil); got != 3 { + t.Fatalf("renderedDbstatRows(unfiltered) = %d, want 3", got) + } +} + +func TestRenderedDbstatRowsIgnoresFiltersOutsideResult(t *testing.T) { + s := makeRenderResult(2, 20) + filters := map[int]*regexp.Regexp{7: regexp.MustCompile("keep")} + + if got := renderedDbstatRows(s, filters); got != s.Result.Nrows { + t.Fatalf("renderedDbstatRows(stale filter) = %d, want %d", got, s.Result.Nrows) + } + + cfg := makeRenderConfig(2, 10) + cfg.view.Filters = filters + var buf bytes.Buffer + win := visibleColumns(s.Result.Ncols, cfg.view.ColsWidth, 80, 0) + if err := printStatDataRange(&buf, s, cfg, true, win, 0, -1); err != nil { + t.Fatal(err) + } + if got := bytes.Count(buf.Bytes(), []byte("\n")); got != s.Result.Nrows { + t.Fatalf("renderer printed %d rows with stale filter, want %d", got, s.Result.Nrows) + } +} + +func TestRenderDbstatWindowPreservesSnapshotForRedraw(t *testing.T) { + cfg := makeRenderConfig(2, 10) + cfg.view.Filters[0] = regexp.MustCompile("suffix$") + s := makeRenderResult(2, 1) + s.Result.Values[0][0].String = "long-relation-suffix" + + for redraw := 1; redraw <= 2; redraw++ { + var buf bytes.Buffer + if err := renderDbstatWindow(&buf, cfg, s, 80, 0, 1); err != nil { + t.Fatal(err) + } + if got := renderedDbstatRows(s, cfg.view.Filters); got != 1 { + t.Fatalf("redraw %d changed filtered row count to %d, want 1", redraw, got) + } + if got := s.Result.Values[0][0].String; got != "long-relation-suffix" { + t.Fatalf("redraw %d changed snapshot value to %q", redraw, got) + } + } +} + +func TestRenderDbstatWindowKeepsHeaderWhenPaged(t *testing.T) { + cfg := makeRenderConfig(2, 10) + s := makeRenderResult(2, 20) + var buf bytes.Buffer + + if err := renderDbstatWindow(&buf, cfg, s, 80, 10, 3); err != nil { + t.Fatal(err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("col0")) { + t.Fatal("paged render must keep the header") + } + if !bytes.Contains([]byte(out), []byte(fmt.Sprintf("r%d-c0", 10))) { + t.Fatal("paged render must start at the requested data row") + } + if bytes.Contains([]byte(out), []byte("r0-c0")) { + t.Fatal("paged render must omit rows before the requested offset") + } +} + +func TestRenderDbstatWindowKeepsHorizontalOffset(t *testing.T) { + cfg := makeRenderConfig(7, 10) + cfg.scrollOffset = 1 + s := makeRenderResult(7, 20) + var buf bytes.Buffer + + wantOffset := visibleColumns(s.Result.Ncols, cfg.view.ColsWidth, 40, cfg.scrollOffset).clamped + if err := renderDbstatWindow(&buf, cfg, s, 40, 10, 3); err != nil { + t.Fatal(err) + } + if cfg.scrollOffset != wantOffset { + t.Fatalf("vertical paging changed horizontal offset to %d, want %d", cfg.scrollOffset, wantOffset) + } + if !bytes.Contains(buf.Bytes(), []byte("col2")) { + t.Fatal("paged render must keep the horizontally selected columns") + } + if bytes.Contains(buf.Bytes(), []byte("col1")) { + t.Fatal("paged render must not reset the horizontal window") + } +} + +func TestClampVerticalOffset(t *testing.T) { + tests := []struct { + name string + offset int + dataRows int + visibleRows int + want int + }{ + {name: "empty", offset: 4, dataRows: 0, visibleRows: 10, want: 0}, + {name: "fits", offset: 2, dataRows: 5, visibleRows: 10, want: 0}, + {name: "exact page", offset: 1, dataRows: 10, visibleRows: 10, want: 1}, + {name: "clamp bottom", offset: 99, dataRows: 20, visibleRows: 10, want: 11}, + {name: "clamp top", offset: -1, dataRows: 20, visibleRows: 10, want: 0}, + {name: "zero height", offset: 3, dataRows: 2, visibleRows: 0, want: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clampVerticalOffset(tt.offset, tt.dataRows, tt.visibleRows); got != tt.want { + t.Fatalf("clampVerticalOffset(%d, %d, %d) = %d, want %d", tt.offset, tt.dataRows, tt.visibleRows, got, tt.want) + } + }) + } +} + +func TestPageSizeLeavesRoomForHeader(t *testing.T) { + for _, tt := range []struct { + height, want int + }{{20, 19}, {2, 1}, {1, 1}, {0, 1}, {-1, 1}} { + pageSize := scrollPageSize(tt.height) + if pageSize != tt.want { + t.Fatalf("page size for height %d = %d, want %d", tt.height, pageSize, tt.want) + } + } +} + +func TestScrollPageHandlersRequestRedrawWithoutChangingView(t *testing.T) { + g := &gocui.Gui{} + if _, err := g.SetView("dbstat", 0, 0, 80, 21); err != gocui.ErrUnknownView { + t.Fatal(err) + } + + cfg := newConfig() + cfg.redrawCh = make(chan struct{}, 2) + cfg.viewCh = make(chan view.View, 2) + + if err := scrollPageDown(cfg)(g, nil); err != nil { + t.Fatal(err) + } + if cfg.verticalOffset != 19 { + t.Fatalf("verticalOffset = %d, want 19", cfg.verticalOffset) + } + select { + case <-cfg.redrawCh: + default: + t.Fatal("page down did not request redraw") + } + + if err := scrollPageUp(cfg)(g, nil); err != nil { + t.Fatal(err) + } + if cfg.verticalOffset != 0 { + t.Fatalf("verticalOffset after page up = %d, want 0", cfg.verticalOffset) + } + select { + case <-cfg.redrawCh: + default: + t.Fatal("page up did not request redraw") + } + + select { + case <-cfg.viewCh: + t.Fatal("page scroll must not send a view update to the collector") + default: + } +} diff --git a/top/stat.go b/top/stat.go index 0fef409..29c6eac 100644 --- a/top/stat.go +++ b/top/stat.go @@ -694,9 +694,57 @@ func printDbstat(v *gocui.View, config *config, s stat.Stat) error { // Terminal width drives the visible-column window. dbstat is created with // Frame=false, so Size() returns the true drawing width. - termWidth, _ := v.Size() + termWidth, termHeight := v.Size() + config.verticalOffset = clampVerticalOffset(config.verticalOffset, renderedDbstatRows(s, config.view.Filters), termHeight) + ox, _ := v.Origin() + if err := v.SetOrigin(ox, 0); err != nil { + return fmt.Errorf("set dbstat vertical origin failed: %w", err) + } + + return renderDbstatWindow(v, config, s, termWidth, config.verticalOffset, termHeight-1) +} + +// renderedDbstatRows returns the number of data rows that printStatData will emit after +// filtering. The header is accounted for by clampVerticalOffset separately. +func renderedDbstatRows(s stat.Stat, filters map[int]*regexp.Regexp) int { + rows := 0 + for rownum := 0; rownum < s.Result.Nrows; rownum++ { + if dbstatRowMatchesFilters(s, rownum, filters) { + rows++ + } + } + return rows +} + +// dbstatRowMatchesFilters reports whether a row passes the active filters. Filter indexes +// that do not exist in the current result are ignored, as they are during rendering of a +// transition frame after a view switch. +func dbstatRowMatchesFilters(s stat.Stat, rownum int, filters map[int]*regexp.Regexp) bool { + active := false + for i, re := range filters { + if re == nil || i < 0 || i >= s.Result.Ncols { + continue + } + active = true + if re.MatchString(s.Result.Values[rownum][i].String) { + return true + } + } + return !active +} - return renderDbstat(v, config, s, termWidth) +func clampVerticalOffset(offset, dataRows, visibleRows int) int { + if offset < 0 { + offset = 0 + } + maxOffset := dataRows + 1 - visibleRows // one header row precedes the data + if maxOffset < 0 { + maxOffset = 0 + } + if offset > maxOffset { + return maxOffset + } + return offset } // renderDbstat is the writer-based core of printDbstat: it clamps the scroll offset, @@ -704,6 +752,12 @@ func printDbstat(v *gocui.View, config *config, s stat.Stat) error { // resolves the terminal width from the gocui view) so the render can be unit-tested // without a live terminal. func renderDbstat(w io.Writer, config *config, s stat.Stat, termWidth int) error { + return renderDbstatWindow(w, config, s, termWidth, 0, -1) +} + +// renderDbstatWindow renders the header and a window of filtered data rows. startRow and +// rowLimit apply only to data rows, so the header remains visible while paging. +func renderDbstatWindow(w io.Writer, config *config, s stat.Stat, termWidth, startRow, rowLimit int) error { // One-shot auto-scroll: a sort-column change asked for that column to be brought into the // window. It is consumed here rather than in the key handler because column widths are known // only after alignViewToResult has run against real data (printDbstat). The flag is cleared @@ -740,7 +794,7 @@ func renderDbstat(w io.Writer, config *config, s stat.Stat, termWidth int) error } // Print data. - return printStatData(w, s, config, isFilterRequired(config.view.Filters), win) + return printStatDataRange(w, s, config, isFilterRequired(config.view.Filters), win, startRow, rowLimit) } // formatError returns formatted error string depending on its type. @@ -1029,6 +1083,10 @@ func printHeaderCell(w io.Writer, s stat.Stat, config *config, i int) error { // colnum counter is removed) so windowed rendering keeps each value aligned with its // column. func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win columnWindow) error { + return printStatDataRange(w, s, config, filter, win, 0, -1) +} + +func printStatDataRange(w io.Writer, s stat.Stat, config *config, filter bool, win columnWindow, startRow, rowLimit int) error { // Blank fillers mirroring the header's edge markers: the header prints a marker rune on // each hidden side, so each data row prints markerWidth spaces in the same place. This is // the alignment invariant — the visible width of the header row equals that of every data @@ -1042,27 +1100,19 @@ func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win co rightMarker = strings.Repeat(" ", markerWidth) } - var doPrint bool + renderedRows := 0 for rownum := 0; rownum < s.Result.Nrows; rownum++ { - // be optimistic, we want to print the row. - doPrint = true - - // apply filters using regexp - if filter { - for i := 0; i < s.Result.Ncols; i++ { - if config.view.Filters[i] != nil { - if config.view.Filters[i].MatchString(s.Result.Values[rownum][i].String) { - doPrint = true - break - } - doPrint = false - } - } + if filter && !dbstatRowMatchesFilters(s, rownum, config.view.Filters) { + continue } - - if !doPrint { + if renderedRows < startRow { + renderedRows++ continue } + if rowLimit >= 0 && renderedRows >= startRow+rowLimit { + break + } + renderedRows++ // print frozen column 0 value first, then the windowed columns. if err := printDataCell(w, s, config, rownum, 0); err != nil { @@ -1101,8 +1151,10 @@ func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win co // than the column width (replacing the last character with '~') and padding to the column // width plus the +2 gap. Returns an error for a zero or negative column width. func printDataCell(w io.Writer, s stat.Stat, config *config, rownum, i int) error { + value := s.Result.Values[rownum][i].String + // truncate values that are longer than column width - valuelen := len(s.Result.Values[rownum][i].String) + valuelen := len(value) if valuelen > config.view.ColsWidth[i] { width := config.view.ColsWidth[i] if width <= 0 { @@ -1110,11 +1162,11 @@ func printDataCell(w io.Writer, s stat.Stat, config *config, rownum, i int) erro } // truncate value up to column width and replace last character with '~' symbol - s.Result.Values[rownum][i].String = s.Result.Values[rownum][i].String[:width-1] + "~" + value = value[:width-1] + "~" } // print value - _, err := fmt.Fprintf(w, "%-*s", config.view.ColsWidth[i]+2, s.Result.Values[rownum][i].String) + _, err := fmt.Fprintf(w, "%-*s", config.view.ColsWidth[i]+2, value) return err } diff --git a/top/ui.go b/top/ui.go index 6c026b3..95446c3 100644 --- a/top/ui.go +++ b/top/ui.go @@ -106,6 +106,8 @@ func mainLoop(ctx context.Context, app *app) error { func doWork(ctx context.Context, app *app) { var wg sync.WaitGroup statCh := make(chan stat.Stat) + var latest stat.Stat + haveLatest := false wg.Add(1) go func() { @@ -126,7 +128,13 @@ func doWork(ctx context.Context, app *app) { // used for exit from UI (not the program) in case when need to open $PAGER or $EDITOR programs. return case s := <-statCh: + latest = s + haveLatest = true printStat(app, s, app.postgresProps) + case <-app.config.redrawCh: + if haveLatest { + printStat(app, latest, app.postgresProps) + } case <-ctx.Done(): wg.Wait() return