1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-07-15 01:34:26 +02:00

Section headers in keybindings menu (#2911)

This commit is contained in:
Stefan Haller
2023-08-29 08:14:30 +02:00
committed by GitHub
29 changed files with 712 additions and 203 deletions

View File

@ -24,7 +24,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetBranchListDisplayStrings( return presentation.GetBranchListDisplayStrings(
viewModel.GetItems(), viewModel.GetItems(),
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
@ -45,9 +45,11 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }

View File

@ -28,7 +28,7 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
c.UserConfig.Gui.ShowFileTree, c.UserConfig.Gui.ShowFileTree,
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
if viewModel.Len() == 0 { if viewModel.Len() == 0 {
return [][]string{{style.FgRed.Sprint("(none)")}} return [][]string{{style.FgRed.Sprint("(none)")}}
} }
@ -54,9 +54,11 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
Transient: true, Transient: true,
}), }),
), ),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }

View File

@ -4,17 +4,13 @@ import (
"fmt" "fmt"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
) )
type ListContextTrait struct { type ListContextTrait struct {
types.Context types.Context
ListRenderer
c *ContextCommon c *ContextCommon
list types.IList
getDisplayStrings func(startIdx int, length int) [][]string
// Alignment for each column. If nil, the default is left alignment
getColumnAlignments func() []utils.Alignment
// Some contexts, like the commit context, will highlight the path from the selected commit // Some contexts, like the commit context, will highlight the path from the selected commit
// to its parents, because it's ambiguous otherwise. For these, we need to refresh the viewport // to its parents, because it's ambiguous otherwise. For these, we need to refresh the viewport
// so that we show the highlighted path. // so that we show the highlighted path.
@ -26,10 +22,6 @@ type ListContextTrait struct {
func (self *ListContextTrait) IsListContext() {} func (self *ListContextTrait) IsListContext() {}
func (self *ListContextTrait) GetList() types.IList {
return self.list
}
func (self *ListContextTrait) FocusLine() { func (self *ListContextTrait) FocusLine() {
// Doing this at the end of the layout function because we need the view to be // Doing this at the end of the layout function because we need the view to be
// resized before we focus the line, otherwise if we're in accordion mode // resized before we focus the line, otherwise if we're in accordion mode
@ -37,7 +29,8 @@ func (self *ListContextTrait) FocusLine() {
self.c.AfterLayout(func() error { self.c.AfterLayout(func() error {
oldOrigin, _ := self.GetViewTrait().ViewPortYBounds() oldOrigin, _ := self.GetViewTrait().ViewPortYBounds()
self.GetViewTrait().FocusPoint(self.list.GetSelectedLineIdx()) self.GetViewTrait().FocusPoint(
self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()))
// If FocusPoint() caused the view to scroll (because the selected line // If FocusPoint() caused the view to scroll (because the selected line
// was out of view before), we need to rerender the view port again. // was out of view before), we need to rerender the view port again.
@ -59,8 +52,7 @@ func (self *ListContextTrait) FocusLine() {
func (self *ListContextTrait) refreshViewport() { func (self *ListContextTrait) refreshViewport() {
startIdx, length := self.GetViewTrait().ViewPortYBounds() startIdx, length := self.GetViewTrait().ViewPortYBounds()
displayStrings := self.getDisplayStrings(startIdx, length) content := self.renderLines(startIdx, startIdx+length)
content := utils.RenderDisplayStrings(displayStrings, nil)
self.GetViewTrait().SetViewPortContent(content) self.GetViewTrait().SetViewPortContent(content)
} }
@ -93,14 +85,7 @@ func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) error
// OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view // OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view
func (self *ListContextTrait) HandleRender() error { func (self *ListContextTrait) HandleRender() error {
self.list.RefreshSelectedIdx() self.list.RefreshSelectedIdx()
var columnAlignments []utils.Alignment content := self.renderLines(-1, -1)
if self.getColumnAlignments != nil {
columnAlignments = self.getColumnAlignments()
}
content := utils.RenderDisplayStrings(
self.getDisplayStrings(0, self.list.Len()),
columnAlignments,
)
self.GetViewTrait().SetContent(content) self.GetViewTrait().SetContent(content)
self.c.Render() self.c.Render()
self.setFooter() self.setFooter()

View File

@ -0,0 +1,124 @@
package context
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"golang.org/x/exp/slices"
)
type NonModelItem struct {
// Where in the model this should be inserted
Index int
// Content to render
Content string
// The column from which to render the item
Column int
}
type ListRenderer struct {
list types.IList
// Function to get the display strings for each model item in the given
// range. startIdx and endIdx are model indices. For each model item, return
// an array of strings, one for each column; the list renderer will take
// care of aligning the columns appropriately.
getDisplayStrings func(startIdx int, endIdx int) [][]string
// Alignment for each column. If nil, the default is left alignment
getColumnAlignments func() []utils.Alignment
// Function to insert non-model items (e.g. section headers). If nil, no
// such items are inserted
getNonModelItems func() []*NonModelItem
// The remaining fields are private and shouldn't be initialized by clients
numNonModelItems int
viewIndicesByModelIndex []int
modelIndicesByViewIndex []int
}
func (self *ListRenderer) GetList() types.IList {
return self.list
}
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
modelIndex = lo.Clamp(modelIndex, 0, self.list.Len())
if self.viewIndicesByModelIndex != nil {
return self.viewIndicesByModelIndex[modelIndex]
}
return modelIndex
}
func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int {
viewIndex = utils.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems)
if self.modelIndicesByViewIndex != nil {
return self.modelIndicesByViewIndex[viewIndex]
}
return viewIndex
}
// startIdx and endIdx are view indices, not model indices. If you want to
// render the whole list, pass -1 for both.
func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
var columnAlignments []utils.Alignment
if self.getColumnAlignments != nil {
columnAlignments = self.getColumnAlignments()
}
nonModelItems := []*NonModelItem{}
self.numNonModelItems = 0
if self.getNonModelItems != nil {
nonModelItems = self.getNonModelItems()
self.prepareConversionArrays(nonModelItems)
}
startModelIdx := 0
if startIdx == -1 {
startIdx = 0
} else {
startModelIdx = self.ViewIndexToModelIndex(startIdx)
}
endModelIdx := self.list.Len()
if endIdx == -1 {
endIdx = endModelIdx + len(nonModelItems)
} else {
endModelIdx = self.ViewIndexToModelIndex(endIdx)
}
lines, columnPositions := utils.RenderDisplayStrings(
self.getDisplayStrings(startModelIdx, endModelIdx),
columnAlignments)
lines = self.insertNonModelItems(nonModelItems, endIdx, startIdx, lines, columnPositions)
return strings.Join(lines, "\n")
}
func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) {
self.numNonModelItems = len(nonModelItems)
self.viewIndicesByModelIndex = lo.Range(self.list.Len() + 1)
self.modelIndicesByViewIndex = lo.Range(self.list.Len() + 1)
offset := 0
for _, item := range nonModelItems {
for i := item.Index; i <= self.list.Len(); i++ {
self.viewIndicesByModelIndex[i]++
}
self.modelIndicesByViewIndex = slices.Insert(
self.modelIndicesByViewIndex, item.Index+offset, self.modelIndicesByViewIndex[item.Index+offset])
offset++
}
}
func (self *ListRenderer) insertNonModelItems(
nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int,
) []string {
offset := 0
for _, item := range nonModelItems {
if item.Index+offset >= endIdx {
break
}
if item.Index+offset >= startIdx {
padding := strings.Repeat(" ", columnPositions[item.Column])
lines = slices.Insert(lines, item.Index+offset-startIdx, padding+item.Content)
}
offset++
}
return lines
}

View File

@ -0,0 +1,256 @@
package context
import (
"fmt"
"strings"
"testing"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestListRenderer_renderLines(t *testing.T) {
scenarios := []struct {
name string
modelStrings []string
nonModelIndices []int
startIdx int
endIdx int
expectedOutput string
}{
{
name: "Render whole list",
modelStrings: []string{"a", "b", "c"},
startIdx: 0,
endIdx: 3,
expectedOutput: `
a
b
c`,
},
{
name: "Partial list, beginning",
modelStrings: []string{"a", "b", "c"},
startIdx: 0,
endIdx: 2,
expectedOutput: `
a
b`,
},
{
name: "Partial list, end",
modelStrings: []string{"a", "b", "c"},
startIdx: 1,
endIdx: 3,
expectedOutput: `
b
c`,
},
{
name: "Pass an endIdx greater than the model length",
modelStrings: []string{"a", "b", "c"},
startIdx: 2,
endIdx: 5,
expectedOutput: `
c`,
},
{
name: "Whole list with section headers",
modelStrings: []string{"a", "b", "c"},
nonModelIndices: []int{1, 3},
startIdx: 0,
endIdx: 5,
expectedOutput: `
a
--- 1 (0) ---
b
c
--- 3 (1) ---`,
},
{
name: "Multiple consecutive headers",
modelStrings: []string{"a", "b", "c"},
nonModelIndices: []int{0, 0, 2, 2, 2},
startIdx: 0,
endIdx: 8,
expectedOutput: `
--- 0 (0) ---
--- 0 (1) ---
a
b
--- 2 (2) ---
--- 2 (3) ---
--- 2 (4) ---
c`,
},
{
name: "Partial list with headers, beginning",
modelStrings: []string{"a", "b", "c"},
nonModelIndices: []int{1, 3},
startIdx: 0,
endIdx: 3,
expectedOutput: `
a
--- 1 (0) ---
b`,
},
{
name: "Partial list with headers, end (beyond end index)",
modelStrings: []string{"a", "b", "c"},
nonModelIndices: []int{1, 3},
startIdx: 2,
endIdx: 7,
expectedOutput: `
b
c
--- 3 (1) ---`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
viewModel := NewListViewModel[string](func() []string { return s.modelStrings })
var getNonModelItems func() []*NonModelItem
if s.nonModelIndices != nil {
getNonModelItems = func() []*NonModelItem {
return lo.Map(s.nonModelIndices, func(modelIndex int, nonModelIndex int) *NonModelItem {
return &NonModelItem{
Index: modelIndex,
Content: fmt.Sprintf("--- %d (%d) ---", modelIndex, nonModelIndex),
}
})
}
}
self := &ListRenderer{
list: viewModel,
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
return lo.Map(s.modelStrings[startIdx:endIdx],
func(s string, _ int) []string { return []string{s} })
},
getNonModelItems: getNonModelItems,
}
expectedOutput := strings.Join(lo.Map(
strings.Split(strings.TrimPrefix(s.expectedOutput, "\n"), "\n"),
func(line string, _ int) string { return strings.TrimSpace(line) }), "\n")
assert.Equal(t, expectedOutput, self.renderLines(s.startIdx, s.endIdx))
})
}
}
func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
scenarios := []struct {
name string
numModelItems int
nonModelIndices []int
modelIndices []int
expectedViewIndices []int
viewIndices []int
expectedModelIndices []int
}{
{
name: "no headers (no getNonModelItems provided)",
numModelItems: 3,
nonModelIndices: nil, // no get
modelIndices: []int{-1, 0, 1, 2, 3, 4},
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
viewIndices: []int{-1, 0, 1, 2, 3, 4},
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
},
{
name: "no headers (getNonModelItems returns zero items)",
numModelItems: 3,
nonModelIndices: []int{},
modelIndices: []int{-1, 0, 1, 2, 3, 4},
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
viewIndices: []int{-1, 0, 1, 2, 3, 4},
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
},
{
name: "basic",
numModelItems: 3,
nonModelIndices: []int{1, 2},
/*
0: model 0
1: --- header 0 ---
2: model 1
3: --- header 1 ---
4: model 2
*/
modelIndices: []int{-1, 0, 1, 2, 3, 4},
expectedViewIndices: []int{0, 0, 2, 4, 5, 5},
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6},
expectedModelIndices: []int{0, 0, 1, 1, 2, 2, 3, 3},
},
{
name: "consecutive section headers",
numModelItems: 3,
nonModelIndices: []int{0, 0, 2, 2, 2, 3, 3},
/*
0: --- header 0 ---
1: --- header 1 ---
2: model 0
3: model 1
4: --- header 2 ---
5: --- header 3 ---
6: --- header 4 ---
7: model 2
8: --- header 5 ---
9: --- header 6 ---
*/
modelIndices: []int{-1, 0, 1, 2, 3, 4},
expectedViewIndices: []int{2, 2, 3, 7, 10, 10},
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
expectedModelIndices: []int{0, 0, 0, 0, 1, 2, 2, 2, 2, 3, 3, 3, 3},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
// Expect lists of equal length for each test:
assert.Equal(t, len(s.modelIndices), len(s.expectedViewIndices))
assert.Equal(t, len(s.viewIndices), len(s.expectedModelIndices))
modelInts := lo.Range(s.numModelItems)
viewModel := NewListViewModel[int](func() []int { return modelInts })
var getNonModelItems func() []*NonModelItem
if s.nonModelIndices != nil {
getNonModelItems = func() []*NonModelItem {
return lo.Map(s.nonModelIndices, func(modelIndex int, _ int) *NonModelItem {
return &NonModelItem{Index: modelIndex, Content: ""}
})
}
}
self := &ListRenderer{
list: viewModel,
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
return lo.Map(modelInts[startIdx:endIdx],
func(i int, _ int) []string { return []string{fmt.Sprint(i)} })
},
getNonModelItems: getNonModelItems,
}
// Need to render first so that it knows the non-model items
self.renderLines(-1, -1)
for i := 0; i < len(s.modelIndices); i++ {
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
}
for i := 0; i < len(s.viewIndices); i++ {
assert.Equal(t, s.expectedModelIndices[i], self.ViewIndexToModelIndex(s.viewIndices[i]))
}
})
}
}

View File

@ -27,7 +27,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
c, c,
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(startIdx int, endIdx int) [][]string {
selectedCommitSha := "" selectedCommitSha := ""
if c.CurrentContext().GetKey() == LOCAL_COMMITS_CONTEXT_KEY { if c.CurrentContext().GetKey() == LOCAL_COMMITS_CONTEXT_KEY {
@ -56,7 +56,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
c.UserConfig.Git.ParseEmoji, c.UserConfig.Git.ParseEmoji,
selectedCommitSha, selectedCommitSha,
startIdx, startIdx,
length, endIdx,
shouldShowGraph(c), shouldShowGraph(c),
c.Model().BisectInfo, c.Model().BisectInfo,
showYouAreHereLabel, showYouAreHereLabel,
@ -74,8 +74,10 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
getDisplayStrings: getDisplayStrings,
},
c: c, c: c,
refreshViewportOnChange: true, refreshViewportOnChange: true,
}, },

View File

@ -34,10 +34,13 @@ func NewMenuContext(
Focusable: true, Focusable: true,
HasUncontrolledBounds: true, HasUncontrolledBounds: true,
})), })),
getDisplayStrings: viewModel.GetDisplayStrings, ListRenderer: ListRenderer{
list: viewModel, list: viewModel,
c: c, getDisplayStrings: viewModel.GetDisplayStrings,
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment }, getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
getNonModelItems: viewModel.GetNonModelItems,
},
c: c,
}, },
} }
} }
@ -79,7 +82,7 @@ func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem, columnAlignment
} }
// TODO: move into presentation package // TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]string { func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
menuItems := self.FilteredListViewModel.GetItems() menuItems := self.FilteredListViewModel.GetItems()
showKeys := lo.SomeBy(menuItems, func(item *types.MenuItem) bool { showKeys := lo.SomeBy(menuItems, func(item *types.MenuItem) bool {
return item.Key != nil return item.Key != nil
@ -111,6 +114,40 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str
}) })
} }
func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
// Don't display section headers when we are filtering. The reason is that
// filtering changes the order of the items (they are sorted by best match),
// so all the sections would be messed up.
if self.FilteredListViewModel.IsFiltering() {
return []*NonModelItem{}
}
result := []*NonModelItem{}
menuItems := self.FilteredListViewModel.GetItems()
var prevSection *types.MenuSection = nil
for i, menuItem := range menuItems {
menuItem := menuItem
if menuItem.Section != nil && menuItem.Section != prevSection {
if prevSection != nil {
result = append(result, &NonModelItem{
Index: i,
Column: 1,
Content: "",
})
}
result = append(result, &NonModelItem{
Index: i,
Column: 1,
Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title),
})
prevSection = menuItem.Section
}
}
return result
}
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
basicBindings := self.ListContextTrait.GetKeybindings(opts) basicBindings := self.ListContextTrait.GetKeybindings(opts)
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool { menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {

View File

@ -26,7 +26,7 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetReflogCommitListDisplayStrings( return presentation.GetReflogCommitListDisplayStrings(
viewModel.GetItems(), viewModel.GetItems(),
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
@ -49,9 +49,11 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -27,7 +27,7 @@ func NewRemoteBranchesContext(
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
} }
@ -43,9 +43,11 @@ func NewRemoteBranchesContext(
Focusable: true, Focusable: true,
Transient: true, Transient: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -24,7 +24,7 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetRemoteListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) return presentation.GetRemoteListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
} }
@ -38,9 +38,11 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -26,7 +26,7 @@ func NewStashContext(
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
} }
@ -40,9 +40,11 @@ func NewStashContext(
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -36,7 +36,7 @@ func NewSubCommitsContext(
limitCommits: true, limitCommits: true,
} }
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(startIdx int, endIdx int) [][]string {
// This can happen if a sub-commits view is asked to be rerendered while // This can happen if a sub-commits view is asked to be rerendered while
// it is invisble; for example when switching screen modes, which // it is invisble; for example when switching screen modes, which
// rerenders all views. // rerenders all views.
@ -72,7 +72,7 @@ func NewSubCommitsContext(
c.UserConfig.Git.ParseEmoji, c.UserConfig.Git.ParseEmoji,
selectedCommitSha, selectedCommitSha,
startIdx, startIdx,
length, endIdx,
shouldShowGraph(c), shouldShowGraph(c),
git_commands.NewNullBisectInfo(), git_commands.NewNullBisectInfo(),
false, false,
@ -93,8 +93,10 @@ func NewSubCommitsContext(
Focusable: true, Focusable: true,
Transient: true, Transient: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
getDisplayStrings: getDisplayStrings,
},
c: c, c: c,
refreshViewportOnChange: true, refreshViewportOnChange: true,
}, },

View File

@ -21,7 +21,7 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems()) return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems())
} }
@ -35,9 +35,11 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -36,7 +36,7 @@ func NewSuggestionsContext(
return state.Suggestions return state.Suggestions
} }
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetSuggestionListDisplayStrings(state.Suggestions) return presentation.GetSuggestionListDisplayStrings(state.Suggestions)
} }
@ -54,9 +54,11 @@ func NewSuggestionsContext(
Focusable: true, Focusable: true,
HasUncontrolledBounds: true, HasUncontrolledBounds: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -26,7 +26,7 @@ func NewTagsContext(
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetTagListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) return presentation.GetTagListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
} }
@ -40,9 +40,11 @@ func NewTagsContext(
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -23,7 +23,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
c.UserConfig.Gui.ShowFileTree, c.UserConfig.Gui.ShowFileTree,
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
lines := presentation.RenderFileTree(viewModel, c.Modes().Diffing.Ref, c.Model().Submodules) lines := presentation.RenderFileTree(viewModel, c.Modes().Diffing.Ref, c.Model().Submodules)
return lo.Map(lines, func(line string, _ int) []string { return lo.Map(lines, func(line string, _ int) []string {
return []string{line} return []string{line}
@ -41,9 +41,11 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }

View File

@ -21,7 +21,7 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
}, },
) )
getDisplayStrings := func(startIdx int, length int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
return presentation.GetWorktreeDisplayStrings( return presentation.GetWorktreeDisplayStrings(
c.Tr, c.Tr,
viewModel.GetFilteredList(), viewModel.GetFilteredList(),
@ -38,9 +38,11 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
Kind: types.SIDE_CONTEXT, Kind: types.SIDE_CONTEXT,
Focusable: true, Focusable: true,
})), })),
list: viewModel, ListRenderer: ListRenderer{
getDisplayStrings: getDisplayStrings, list: viewModel,
c: c, getDisplayStrings: getDisplayStrings,
},
c: c,
}, },
} }
} }

View File

@ -83,9 +83,11 @@ func (self *ListController) handleLineChange(change int) error {
// we're not constantly re-rendering the main view. // we're not constantly re-rendering the main view.
if before != after { if before != after {
if change == -1 { if change == -1 {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig, before, after) checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig,
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
} else if change == 1 { } else if change == 1 {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig, before, after) checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig,
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
} }
return self.context.HandleFocus(types.OnFocusOpts{}) return self.context.HandleFocus(types.OnFocusOpts{})
@ -112,7 +114,7 @@ func (self *ListController) HandleGotoBottom() error {
func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx() prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx()
newSelectedLineIdx := opts.Y newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y)
alreadyFocused := self.isFocused() alreadyFocused := self.isFocused()
if err := self.pushContextIfNotFocused(); err != nil { if err := self.pushContextIfNotFocused(); err != nil {

View File

@ -18,23 +18,33 @@ func (self *OptionsMenuAction) Call() error {
return nil return nil
} }
bindings := self.getBindings(ctx) local, global, navigation := self.getBindings(ctx)
menuItems := lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem { menuItems := []*types.MenuItem{}
return &types.MenuItem{
OpensMenu: binding.OpensMenu, appendBindings := func(bindings []*types.Binding, section *types.MenuSection) {
Label: binding.Description, menuItems = append(menuItems,
OnPress: func() error { lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem {
if binding.Handler == nil { return &types.MenuItem{
return nil OpensMenu: binding.OpensMenu,
Label: binding.Description,
OnPress: func() error {
if binding.Handler == nil {
return nil
}
return binding.Handler()
},
Key: binding.Key,
Tooltip: binding.Tooltip,
Section: section,
} }
})...)
}
return binding.Handler() appendBindings(local, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionLocal, Column: 1})
}, appendBindings(global, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionGlobal, Column: 1})
Key: binding.Key, appendBindings(navigation, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionNavigation, Column: 1})
Tooltip: binding.Tooltip,
}
})
return self.c.Menu(types.CreateMenuOptions{ return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Keybindings, Title: self.c.Tr.Keybindings,
@ -44,7 +54,8 @@ func (self *OptionsMenuAction) Call() error {
}) })
} }
func (self *OptionsMenuAction) getBindings(context types.Context) []*types.Binding { // Returns three slices of bindings: local, global, and navigation
func (self *OptionsMenuAction) getBindings(context types.Context) ([]*types.Binding, []*types.Binding, []*types.Binding) {
var bindingsGlobal, bindingsPanel, bindingsNavigation []*types.Binding var bindingsGlobal, bindingsPanel, bindingsNavigation []*types.Binding
bindings, _ := self.c.GetInitialKeybindingsWithCustomCommands() bindings, _ := self.c.GetInitialKeybindingsWithCustomCommands()
@ -61,14 +72,7 @@ func (self *OptionsMenuAction) getBindings(context types.Context) []*types.Bindi
} }
} }
resultBindings := []*types.Binding{} return uniqueBindings(bindingsPanel), uniqueBindings(bindingsGlobal), uniqueBindings(bindingsNavigation)
resultBindings = append(resultBindings, uniqueBindings(bindingsPanel)...)
// adding a separator between the panel-specific bindings and the other bindings
resultBindings = append(resultBindings, &types.Binding{})
resultBindings = append(resultBindings, uniqueBindings(bindingsGlobal)...)
resultBindings = append(resultBindings, uniqueBindings(bindingsNavigation)...)
return resultBindings
} }
// We shouldn't really need to do this. We should define alternative keys for the same // We shouldn't really need to do this. We should define alternative keys for the same

View File

@ -1,7 +1,8 @@
package gui package gui
import ( import (
"github.com/jesseduffield/lazygit/pkg/gui/presentation" "fmt"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
@ -27,7 +28,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
} }
if item.OpensMenu { if item.OpensMenu {
item.LabelColumns[0] = presentation.OpensMenuStyle(item.LabelColumns[0]) item.LabelColumns[0] = fmt.Sprintf("%s...", item.LabelColumns[0])
} }
maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns)) maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns))

View File

@ -52,7 +52,7 @@ func GetCommitListDisplayStrings(
parseEmoji bool, parseEmoji bool,
selectedCommitSha string, selectedCommitSha string,
startIdx int, startIdx int,
length int, endIdx int,
showGraph bool, showGraph bool,
bisectInfo *git_commands.BisectInfo, bisectInfo *git_commands.BisectInfo,
showYouAreHereLabel bool, showYouAreHereLabel bool,
@ -68,11 +68,10 @@ func GetCommitListDisplayStrings(
return nil return nil
} }
end := utils.Min(startIdx+length, len(commits))
// this is where my non-TODO commits begin // this is where my non-TODO commits begin
rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), end) rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), endIdx)
filteredCommits := commits[startIdx:end] filteredCommits := commits[startIdx:endIdx]
bisectBounds := getbisectBounds(commits, bisectInfo) bisectBounds := getbisectBounds(commits, bisectInfo)
@ -85,8 +84,8 @@ func GetCommitListDisplayStrings(
pipeSets := loadPipesets(commits[rebaseOffset:]) pipeSets := loadPipesets(commits[rebaseOffset:])
pipeSetOffset := utils.Max(startIdx-rebaseOffset, 0) pipeSetOffset := utils.Max(startIdx-rebaseOffset, 0)
graphPipeSets := pipeSets[pipeSetOffset:utils.Max(end-rebaseOffset, 0)] graphPipeSets := pipeSets[pipeSetOffset:utils.Max(endIdx-rebaseOffset, 0)]
graphCommits := commits[graphOffset:end] graphCommits := commits[graphOffset:endIdx]
graphLines := graph.RenderAux( graphLines := graph.RenderAux(
graphPipeSets, graphPipeSets,
graphCommits, graphCommits,

View File

@ -41,7 +41,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
parseEmoji bool parseEmoji bool
selectedCommitSha string selectedCommitSha string
startIdx int startIdx int
length int endIdx int
showGraph bool showGraph bool
bisectInfo *git_commands.BisectInfo bisectInfo *git_commands.BisectInfo
showYouAreHereLabel bool showYouAreHereLabel bool
@ -52,7 +52,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
testName: "no commits", testName: "no commits",
commits: []*models.Commit{}, commits: []*models.Commit{},
startIdx: 0, startIdx: 0,
length: 1, endIdx: 1,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -66,7 +66,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit2", Sha: "sha2"}, {Name: "commit2", Sha: "sha2"},
}, },
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -83,7 +83,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit2", Sha: "sha2"}, {Name: "commit2", Sha: "sha2"},
}, },
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -110,7 +110,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
currentBranchName: "current-branch", currentBranchName: "current-branch",
hasUpdateRefConfig: true, hasUpdateRefConfig: true,
startIdx: 0, startIdx: 0,
length: 4, endIdx: 4,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -135,7 +135,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
currentBranchName: "current-branch", currentBranchName: "current-branch",
hasUpdateRefConfig: true, hasUpdateRefConfig: true,
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -158,7 +158,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
currentBranchName: "current-branch", currentBranchName: "current-branch",
hasUpdateRefConfig: false, hasUpdateRefConfig: false,
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -179,7 +179,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "some-branch", CommitHash: "sha2"}, {Name: "some-branch", CommitHash: "sha2"},
}, },
startIdx: 0, startIdx: 0,
length: 3, endIdx: 3,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -200,7 +200,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 0, startIdx: 0,
length: 5, endIdx: 5,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -223,7 +223,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 0, startIdx: 0,
length: 5, endIdx: 5,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -247,7 +247,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 1, startIdx: 1,
length: 10, endIdx: 5,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -270,7 +270,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 3, startIdx: 3,
length: 2, endIdx: 5,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -291,7 +291,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -312,7 +312,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 4, startIdx: 4,
length: 2, endIdx: 5,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -332,7 +332,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}}, {Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
}, },
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -351,7 +351,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}}, {Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
}, },
startIdx: 0, startIdx: 0,
length: 5, endIdx: 3,
showGraph: true, showGraph: true,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -373,7 +373,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
timeFormat: "2006-01-02", timeFormat: "2006-01-02",
shortTimeFormat: "3:04PM", shortTimeFormat: "3:04PM",
startIdx: 0, startIdx: 0,
length: 2, endIdx: 2,
showGraph: false, showGraph: false,
bisectInfo: git_commands.NewNullBisectInfo(), bisectInfo: git_commands.NewNullBisectInfo(),
cherryPickedCommitShaSet: set.New[string](), cherryPickedCommitShaSet: set.New[string](),
@ -416,13 +416,14 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
s.parseEmoji, s.parseEmoji,
s.selectedCommitSha, s.selectedCommitSha,
s.startIdx, s.startIdx,
s.length, s.endIdx,
s.showGraph, s.showGraph,
s.bisectInfo, s.bisectInfo,
s.showYouAreHereLabel, s.showYouAreHereLabel,
) )
renderedResult := utils.RenderDisplayStrings(result, nil) renderedLines, _ := utils.RenderDisplayStrings(result, nil)
renderedResult := strings.Join(renderedLines, "\n")
t.Logf("\n%s", renderedResult) t.Logf("\n%s", renderedResult)
assert.EqualValues(t, s.expected, renderedResult) assert.EqualValues(t, s.expected, renderedResult)

View File

@ -1,7 +0,0 @@
package presentation
import "github.com/jesseduffield/lazygit/pkg/gui/style"
func OpensMenuStyle(str string) string {
return style.FgMagenta.Sprintf("%s...", str)
}

View File

@ -177,6 +177,11 @@ type PromptOpts struct {
Mask bool Mask bool
} }
type MenuSection struct {
Title string
Column int // The column that this section title should be aligned with
}
type MenuItem struct { type MenuItem struct {
Label string Label string
@ -194,6 +199,14 @@ type MenuItem struct {
// The tooltip will be displayed upon highlighting the menu item // The tooltip will be displayed upon highlighting the menu item
Tooltip string Tooltip string
// Can be used to group menu items into sections with headers. MenuItems
// with the same Section should be contiguous, and will automatically get a
// section header. If nil, the item is not part of a section.
// Note that pointer comparison is used to determine whether two menu items
// belong to the same section, so make sure all your items in a given
// section point to the same MenuSection instance.
Section *MenuSection
} }
type Model struct { type Model struct {

View File

@ -124,6 +124,8 @@ type IListContext interface {
GetSelectedItemId() string GetSelectedItemId() string
GetList() IList GetList() IList
ViewIndexToModelIndex(int) int
ModelIndexToViewIndex(int) int
FocusLine() FocusLine()
IsListContext() // used for type switch IsListContext() // used for type switch

View File

@ -388,6 +388,9 @@ type TranslationSet struct {
Panel string Panel string
Keybindings string Keybindings string
KeybindingsLegend string KeybindingsLegend string
KeybindingsMenuSectionLocal string
KeybindingsMenuSectionGlobal string
KeybindingsMenuSectionNavigation string
RenameBranch string RenameBranch string
SetUnsetUpstream string SetUnsetUpstream string
NewGitFlowBranchPrompt string NewGitFlowBranchPrompt string
@ -986,6 +989,9 @@ func EnglishTranslationSet() TranslationSet {
ConflictsResolved: "All merge conflicts resolved. Continue?", ConflictsResolved: "All merge conflicts resolved. Continue?",
Continue: "Continue", Continue: "Continue",
Keybindings: "Keybindings", Keybindings: "Keybindings",
KeybindingsMenuSectionLocal: "Local",
KeybindingsMenuSectionGlobal: "Global",
KeybindingsMenuSectionNavigation: "Navigation",
RebasingTitle: "Rebase '{{.checkedOutBranch}}' onto '{{.ref}}'", RebasingTitle: "Rebase '{{.checkedOutBranch}}' onto '{{.ref}}'",
RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base onto '{{.ref}}'", RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base onto '{{.ref}}'",
SimpleRebase: "Simple rebase", SimpleRebase: "Simple rebase",

View File

@ -456,13 +456,13 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
self.IsFocused() self.IsFocused()
view := self.getView() view := self.getView()
lines := view.BufferLines()
var matchIndex int var matchIndex int
self.t.assertWithRetries(func() (bool, string) { self.t.assertWithRetries(func() (bool, string) {
matchIndex = -1 matchIndex = -1
var matches []string var matches []string
lines := view.BufferLines()
// first we look for a duplicate on the current screen. We won't bother looking beyond that though. // first we look for a duplicate on the current screen. We won't bother looking beyond that though.
for i, line := range lines { for i, line := range lines {
ok, _ := matcher.test(line) ok, _ := matcher.test(line)
@ -486,19 +486,38 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
return self return self
} }
if selectedLineIdx == matchIndex { if selectedLineIdx == matchIndex {
self.SelectedLine(matcher) return self.SelectedLine(matcher)
} else if selectedLineIdx < matchIndex {
for i := selectedLineIdx; i < matchIndex; i++ {
self.SelectNextItem()
}
self.SelectedLine(matcher)
} else {
for i := selectedLineIdx; i > matchIndex; i-- {
self.SelectPreviousItem()
}
self.SelectedLine(matcher)
} }
// At this point we can't just take the difference of selected and matched
// index and press up or down arrow this many times. The reason is that
// there might be section headers between those lines, and these will be
// skipped when pressing up or down arrow. So we must keep pressing the
// arrow key in a loop, and check after each one whether we now reached the
// target line.
var maxNumKeyPresses int
var keyPress func()
if selectedLineIdx < matchIndex {
maxNumKeyPresses = matchIndex - selectedLineIdx
keyPress = func() { self.SelectNextItem() }
} else {
maxNumKeyPresses = selectedLineIdx - matchIndex
keyPress = func() { self.SelectPreviousItem() }
}
for i := 0; i < maxNumKeyPresses; i++ {
keyPress()
idx, err := self.getSelectedLineIdx()
if err != nil {
self.t.fail(err.Error())
return self
}
if ok, _ := matcher.test(lines[idx]); ok {
return self
}
}
self.t.fail(fmt.Sprintf("Could not navigate to item matching: %s. Lines:\n%s", matcher.name(), strings.Join(lines, "\n")))
return self return self
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"github.com/samber/lo" "github.com/samber/lo"
"golang.org/x/exp/slices"
) )
type Alignment int type Alignment int
@ -36,10 +37,14 @@ func WithPadding(str string, padding int, alignment Alignment) string {
// defaults to left-aligning each column. If you want to set the alignment of // defaults to left-aligning each column. If you want to set the alignment of
// each column, pass in a slice of Alignment values. // each column, pass in a slice of Alignment values.
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) string { // returns a list of strings that should be joined with "\n", and an array of
displayStringsArr = excludeBlankColumns(displayStringsArr) // the column positions
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) ([]string, []int) {
displayStringsArr, columnAlignments, removedColumns := excludeBlankColumns(displayStringsArr, columnAlignments)
padWidths := getPadWidths(displayStringsArr) padWidths := getPadWidths(displayStringsArr)
columnConfigs := make([]ColumnConfig, len(padWidths)) columnConfigs := make([]ColumnConfig, len(padWidths))
columnPositions := make([]int, len(padWidths)+1)
columnPositions[0] = 0
for i, padWidth := range padWidths { for i, padWidth := range padWidths {
// gracefully handle when columnAlignments is shorter than padWidths // gracefully handle when columnAlignments is shorter than padWidths
alignment := AlignLeft alignment := AlignLeft
@ -51,16 +56,23 @@ func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Align
Width: padWidth, Width: padWidth,
Alignment: alignment, Alignment: alignment,
} }
columnPositions[i+1] = columnPositions[i] + padWidth + 1
} }
output := getPaddedDisplayStrings(displayStringsArr, columnConfigs) // Add the removed columns back into columnPositions (a removed column gets
// the same position as the following column); clients should be able to rely
return output // on them all to be there
for _, removedColumn := range removedColumns {
if removedColumn < len(columnPositions) {
columnPositions = slices.Insert(columnPositions, removedColumn, columnPositions[removedColumn])
}
}
return getPaddedDisplayStrings(displayStringsArr, columnConfigs), columnPositions
} }
// NOTE: this mutates the input slice for the sake of performance // NOTE: this mutates the input slice for the sake of performance
func excludeBlankColumns(displayStringsArr [][]string) [][]string { func excludeBlankColumns(displayStringsArr [][]string, columnAlignments []Alignment) ([][]string, []Alignment, []int) {
if len(displayStringsArr) == 0 { if len(displayStringsArr) == 0 {
return displayStringsArr return displayStringsArr, columnAlignments, []int{}
} }
// if all rows share a blank column, we want to remove that column // if all rows share a blank column, we want to remove that column
@ -76,26 +88,33 @@ outer:
} }
if len(toRemove) == 0 { if len(toRemove) == 0 {
return displayStringsArr return displayStringsArr, columnAlignments, []int{}
} }
// remove the columns // remove the columns
for i, strings := range displayStringsArr { for i, strings := range displayStringsArr {
for j := len(toRemove) - 1; j >= 0; j-- { for j := len(toRemove) - 1; j >= 0; j-- {
strings = append(strings[:toRemove[j]], strings[toRemove[j]+1:]...) strings = slices.Delete(strings, toRemove[j], toRemove[j]+1)
} }
displayStringsArr[i] = strings displayStringsArr[i] = strings
} }
return displayStringsArr for j := len(toRemove) - 1; j >= 0; j-- {
if columnAlignments != nil && toRemove[j] < len(columnAlignments) {
columnAlignments = slices.Delete(columnAlignments, toRemove[j], toRemove[j]+1)
}
}
return displayStringsArr, columnAlignments, toRemove
} }
func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) string { func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) []string {
builder := strings.Builder{} result := make([]string, 0, len(stringArrays))
for i, stringArray := range stringArrays { for _, stringArray := range stringArrays {
if len(stringArray) == 0 { if len(stringArray) == 0 {
continue continue
} }
builder := strings.Builder{}
for j, columnConfig := range columnConfigs { for j, columnConfig := range columnConfigs {
if len(stringArray)-1 < j { if len(stringArray)-1 < j {
continue continue
@ -107,12 +126,9 @@ func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConf
continue continue
} }
builder.WriteString(stringArray[len(columnConfigs)]) builder.WriteString(stringArray[len(columnConfigs)])
result = append(result, builder.String())
if i < len(stringArrays)-1 {
builder.WriteString("\n")
}
} }
return builder.String() return result
} }
func getPadWidths(stringArrays [][]string) []int { func getPadWidths(stringArrays [][]string) []int {

View File

@ -1,6 +1,7 @@
package utils package utils
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -157,66 +158,90 @@ func TestTruncateWithEllipsis(t *testing.T) {
func TestRenderDisplayStrings(t *testing.T) { func TestRenderDisplayStrings(t *testing.T) {
type scenario struct { type scenario struct {
input [][]string input [][]string
columnAlignments []Alignment columnAlignments []Alignment
expected string expectedOutput string
expectedColumnPositions []int
} }
tests := []scenario{ tests := []scenario{
{ {
input: [][]string{{""}, {""}}, input: [][]string{{""}, {""}},
columnAlignments: nil, columnAlignments: nil,
expected: "", expectedOutput: "",
expectedColumnPositions: []int{0, 0},
}, },
{ {
input: [][]string{{"a"}, {""}}, input: [][]string{{"a"}, {""}},
columnAlignments: nil, columnAlignments: nil,
expected: "a\n", expectedOutput: "a\n",
expectedColumnPositions: []int{0},
}, },
{ {
input: [][]string{{"a"}, {"b"}}, input: [][]string{{"a"}, {"b"}},
columnAlignments: nil, columnAlignments: nil,
expected: "a\nb", expectedOutput: "a\nb",
expectedColumnPositions: []int{0},
}, },
{ {
input: [][]string{{"a", "b"}, {"c", "d"}}, input: [][]string{{"a", "b"}, {"c", "d"}},
columnAlignments: nil, columnAlignments: nil,
expected: "a b\nc d", expectedOutput: "a b\nc d",
expectedColumnPositions: []int{0, 2},
}, },
{ {
input: [][]string{{"a", "", "c"}, {"d", "", "f"}}, input: [][]string{{"a", "", "c"}, {"d", "", "f"}},
columnAlignments: nil, columnAlignments: nil,
expected: "a c\nd f", expectedOutput: "a c\nd f",
expectedColumnPositions: []int{0, 2, 2},
}, },
{ {
input: [][]string{{"a", "", "c", ""}, {"d", "", "f", ""}}, input: [][]string{{"a", "", "c", ""}, {"d", "", "f", ""}},
columnAlignments: nil, columnAlignments: nil,
expected: "a c\nd f", expectedOutput: "a c\nd f",
expectedColumnPositions: []int{0, 2, 2},
}, },
{ {
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
columnAlignments: nil, columnAlignments: nil,
expected: "abc d\ne f", expectedOutput: "abc d\ne f",
expectedColumnPositions: []int{0, 4, 4},
}, },
{ {
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, input: [][]string{{"", "abc", "", "", "d", "e"}, {"", "f", "", "", "g", "h"}},
columnAlignments: []Alignment{AlignLeft, AlignLeft}, // same as nil (default) columnAlignments: nil,
expected: "abc d\ne f", expectedOutput: "abc d e\nf g h",
expectedColumnPositions: []int{0, 0, 4, 4, 4, 6},
}, },
{ {
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
columnAlignments: []Alignment{AlignRight, AlignLeft}, columnAlignments: []Alignment{AlignLeft, AlignLeft}, // same as nil (default)
expected: "abc d\n e f", expectedOutput: "abc d\ne f",
expectedColumnPositions: []int{0, 4, 4},
}, },
{ {
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
columnAlignments: []Alignment{AlignRight}, // gracefully defaults unspecified columns to left-align columnAlignments: []Alignment{AlignRight, AlignLeft},
expected: "abc d\n e f", expectedOutput: "abc d\n e f",
expectedColumnPositions: []int{0, 4, 4},
},
{
input: [][]string{{"a", "", "bcd", "efg", "h"}, {"i", "", "j", "k", "l"}},
columnAlignments: []Alignment{AlignLeft, AlignLeft, AlignRight, AlignLeft},
expectedOutput: "a bcd efg h\ni j k l",
expectedColumnPositions: []int{0, 2, 2, 6, 10},
},
{
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
columnAlignments: []Alignment{AlignRight}, // gracefully defaults unspecified columns to left-align
expectedOutput: "abc d\n e f",
expectedColumnPositions: []int{0, 4, 4},
}, },
} }
for _, test := range tests { for _, test := range tests {
output := RenderDisplayStrings(test.input, test.columnAlignments) output, columnPositions := RenderDisplayStrings(test.input, test.columnAlignments)
assert.EqualValues(t, test.expected, output) assert.EqualValues(t, test.expectedOutput, strings.Join(output, "\n"))
assert.EqualValues(t, test.expectedColumnPositions, columnPositions)
} }
} }