diff --git a/docs/keybindings/Keybindings_de.md b/docs/keybindings/Keybindings_de.md index dc9dd08e..f79fe76c 100644 --- a/docs/keybindings/Keybindings_de.md +++ b/docs/keybindings/Keybindings_de.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: zurück
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index ae21c4dc..e8382ca8 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: return
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_es.md b/docs/keybindings/Keybindings_es.md index 6b4eb921..19e49d44 100644 --- a/docs/keybindings/Keybindings_es.md +++ b/docs/keybindings/Keybindings_es.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: regresar
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_fr.md b/docs/keybindings/Keybindings_fr.md index 109078b2..921e11c4 100644 --- a/docs/keybindings/Keybindings_fr.md +++ b/docs/keybindings/Keybindings_fr.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: retour
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 24477006..ff51ee8c 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: terug
+  g: scroll to top
+  G: scroll to bottom
 
## Globaal diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 2f4cb93f..da178f16 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: powrót
+  g: scroll to top
+  G: scroll to bottom
 
## Globalne diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 32a557b5..990a23ca 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: retornar
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_tr.md b/docs/keybindings/Keybindings_tr.md index 3192a7c2..0fd14dbd 100644 --- a/docs/keybindings/Keybindings_tr.md +++ b/docs/keybindings/Keybindings_tr.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: dönüş
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_zh.md b/docs/keybindings/Keybindings_zh.md index 0c75fbcb..8b7ed22a 100644 --- a/docs/keybindings/Keybindings_zh.md +++ b/docs/keybindings/Keybindings_zh.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: 返回
+  g: scroll to top
+  G: scroll to bottom
 
## 全局 diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 6ea2ee6c..266378e9 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -87,6 +87,10 @@ type guiState struct { // Maintains the state of manual filtering i.e. typing in a substring // to filter on in the current panel. Filter filterState + + // Timestamp of the last 'g' key press in the main panel, used to detect + // the vim-style "gg" double press that jumps to the top. + lastGPressedAt time.Time } type filterState struct { @@ -167,6 +171,16 @@ func (gui *Gui) renderGlobalOptions() error { }) } +func (gui *Gui) renderMainOptions() error { + return gui.renderOptionsMap(map[string]string{ + "PgUp/PgDn": gui.Tr.Scroll, + "← → ↑ ↓": gui.Tr.Navigate, + "gg": gui.Tr.GotoTop, + "G": gui.Tr.GotoBottom, + "esc": gui.Tr.Return, + }) +} + func (gui *Gui) goEvery(interval time.Duration, function func() error) { _ = function() // time.Tick doesn't run immediately so we'll do that here // TODO: maybe change go func() { diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df706024..1342e4ff 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -461,6 +461,20 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.scrollRightMain, }, + { + ViewName: "main", + Key: 'g', + Modifier: gocui.ModNone, + Handler: gui.gotoTopMain, + Description: gui.Tr.GotoTop, + }, + { + ViewName: "main", + Key: 'G', + Modifier: gocui.ModNone, + Handler: gui.gotoBottomMain, + Description: gui.Tr.GotoBottom, + }, { ViewName: "filter", Key: gocui.KeyEnter, diff --git a/pkg/gui/main_panel.go b/pkg/gui/main_panel.go index 004147be..ebd628b0 100644 --- a/pkg/gui/main_panel.go +++ b/pkg/gui/main_panel.go @@ -2,10 +2,33 @@ package gui import ( "math" + "time" "github.com/jesseduffield/gocui" ) +// gPressTimeout is the window within which two consecutive 'g' presses are +// treated as the vim-style "gg" command to jump to the top of the main panel. +const gPressTimeout = 250 * time.Millisecond + +// isDoublePress reports whether `now` follows `previous` closely enough to be +// considered the second half of a double key press. +func isDoublePress(previous, now time.Time, timeout time.Duration) bool { + return !previous.IsZero() && now.Sub(previous) <= timeout +} + +// gotoBottomOriginY returns the vertical origin that scrolls the main panel to +// its final line. When scrollPastBottom is false the last page of content is +// kept on screen rather than scrolling it off the top. +func gotoBottomOriginY(totalLines, viewHeight int, scrollPastBottom bool) int { + reservedLines := 0 + if !scrollPastBottom { + reservedLines = viewHeight + } + + return int(math.Max(0, float64(totalLines-reservedLines))) +} + func (gui *Gui) scrollUpMain() error { mainView := gui.Views.Main mainView.Autoscroll = false @@ -73,6 +96,33 @@ func (gui *Gui) jumpToTopMain(g *gocui.Gui, v *gocui.View) error { return nil } +// gotoTopMain implements the vim-style "gg" command: the first 'g' arms the +// sequence and the second 'g' pressed within gPressTimeout jumps to the top of +// the main panel. +func (gui *Gui) gotoTopMain(g *gocui.Gui, v *gocui.View) error { + now := time.Now() + if !isDoublePress(gui.State.lastGPressedAt, now, gPressTimeout) { + gui.State.lastGPressedAt = now + return nil + } + + gui.State.lastGPressedAt = time.Time{} + return gui.jumpToTopMain(g, v) +} + +// gotoBottomMain implements the vim-style "G" command, jumping to the bottom of +// the main panel. +func (gui *Gui) gotoBottomMain(g *gocui.Gui, v *gocui.View) error { + mainView := gui.Views.Main + mainView.Autoscroll = false + + _, viewHeight := mainView.Size() + ox, _ := mainView.Origin() + newOy := gotoBottomOriginY(mainView.ViewLinesHeight(), viewHeight, gui.Config.UserConfig.Gui.ScrollPastBottom) + + return mainView.SetOrigin(ox, newOy) +} + func (gui *Gui) onMainTabClick(tabIndex int) error { gui.Log.Warn(tabIndex) diff --git a/pkg/gui/main_panel_test.go b/pkg/gui/main_panel_test.go new file mode 100644 index 00000000..0600779e --- /dev/null +++ b/pkg/gui/main_panel_test.go @@ -0,0 +1,100 @@ +package gui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGotoBottomOriginY(t *testing.T) { + type scenario struct { + name string + totalLines int + viewHeight int + scrollPastBottom bool + expected int + } + + scenarios := []scenario{ + { + name: "keeps the last page on screen when not scrolling past bottom", + totalLines: 100, + viewHeight: 20, + scrollPastBottom: false, + expected: 80, + }, + { + name: "scrolls all content off the top when scrolling past bottom", + totalLines: 100, + viewHeight: 20, + scrollPastBottom: true, + expected: 100, + }, + { + name: "never returns a negative origin when content fits the view", + totalLines: 5, + viewHeight: 20, + scrollPastBottom: false, + expected: 0, + }, + { + name: "handles empty content", + totalLines: 0, + viewHeight: 20, + scrollPastBottom: true, + expected: 0, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, gotoBottomOriginY(s.totalLines, s.viewHeight, s.scrollPastBottom)) + }) + } +} + +func TestIsDoublePress(t *testing.T) { + now := time.Unix(1000, 0) + timeout := 250 * time.Millisecond + + type scenario struct { + name string + previous time.Time + now time.Time + expected bool + } + + scenarios := []scenario{ + { + name: "no previous press is not a double press", + previous: time.Time{}, + now: now, + expected: false, + }, + { + name: "second press within the timeout is a double press", + previous: now.Add(-100 * time.Millisecond), + now: now, + expected: true, + }, + { + name: "second press exactly on the timeout is a double press", + previous: now.Add(-timeout), + now: now, + expected: true, + }, + { + name: "second press after the timeout is not a double press", + previous: now.Add(-timeout - time.Millisecond), + now: now, + expected: false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, isDoublePress(s.previous, s.now, timeout)) + }) + } +} diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 6acbb18c..5bb354e7 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -230,6 +230,8 @@ func (gui *Gui) renderPanelOptions() error { return gui.renderMenuOptions() case "confirmation": return gui.renderConfirmationOptions() + case "main": + return gui.renderMainOptions() } return gui.renderGlobalOptions() } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d86b0c84..4d119227 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -63,6 +63,8 @@ type TranslationSet struct { Recreate string PreviousContext string NextContext string + GotoTop string + GotoBottom string Attach string ViewLogs string UpProject string @@ -194,6 +196,8 @@ func englishSet() TranslationSet { Recreate: "recreate", PreviousContext: "previous tab", NextContext: "next tab", + GotoTop: "scroll to top", + GotoBottom: "scroll to bottom", Attach: "attach", ViewLogs: "view logs", UpProject: "up project",