Skip to content

Commit

Permalink
Add multi-select mode for lists
Browse files Browse the repository at this point in the history
This adds a multi-select mode for lists in which the current selection
changes to be a cursor and pressing enter marks the item under the
cursor as actually selected.

DropDown is based on list so this should unblock adding a multi-select
mode to DropDown's as well.

Updates #330

Signed-off-by: Sam Whited <[email protected]>
  • Loading branch information
SamWhited committed Nov 23, 2024
1 parent c76f787 commit 339debc
Showing 1 changed file with 131 additions and 23 deletions.
154 changes: 131 additions & 23 deletions list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tview

import (
"fmt"
"slices"
"strings"

"github.com/gdamore/tcell/v2"
Expand Down Expand Up @@ -62,6 +63,9 @@ type List struct {
// The style for selected items.
selectedStyle tcell.Style

// The style for the item under the cursor.
cursorStyle tcell.Style

// If true, the selection is only shown when the list has focus.
selectedFocusOnly bool

Expand Down Expand Up @@ -95,6 +99,12 @@ type List struct {

// An optional function which is called when the user presses the Escape key.
done func()

// True if multiple items may be selected.
multiSelect bool

// The indices of the currently selected items in multi-select mode.
selectedItems []int
}

// NewList returns a new list.
Expand All @@ -107,17 +117,27 @@ func NewList() *List {
secondaryTextStyle: tcell.StyleDefault.Foreground(Styles.TertiaryTextColor).Background(Styles.PrimitiveBackgroundColor),
shortcutStyle: tcell.StyleDefault.Foreground(Styles.SecondaryTextColor).Background(Styles.PrimitiveBackgroundColor),
selectedStyle: tcell.StyleDefault.Foreground(Styles.PrimitiveBackgroundColor).Background(Styles.PrimaryTextColor),
cursorStyle: tcell.StyleDefault.Foreground(Styles.ContrastBackgroundColor).Background(Styles.PrimaryTextColor),
mainStyleTags: true,
secondaryStyleTags: true,
}
}

// MultiSelect enables or disables the selection of multiple list items.
func (l *List) MultiSelect(enable bool) *List {
l.multiSelect = enable
return l
}

// SetCurrentItem sets the currently selected item by its index, starting at 0
// for the first item. If a negative index is provided, items are referred to
// from the back (-1 = last item, -2 = second-to-last item, and so on). Out of
// range indices are clamped to the beginning/end.
//
// Calling this function triggers a "changed" event if the selection changes.
// If multi-select mode is enabled the changed event is never triggered as this
// changes the locatin of the cursor but does not select or deselect the list
// option.
func (l *List) SetCurrentItem(index int) *List {
if index < 0 {
index = len(l.items) + index
Expand All @@ -143,6 +163,8 @@ func (l *List) SetCurrentItem(index int) *List {

// GetCurrentItem returns the index of the currently selected list item,
// starting at 0 for the first item.
// If multi-select mode is enabled this returns the location of the cursor
// whether the item is selected or not.
func (l *List) GetCurrentItem() int {
return l.currentItem
}
Expand Down Expand Up @@ -194,22 +216,42 @@ func (l *List) RemoveItem(index int) *List {

// Remove item.
l.items = append(l.items[:index], l.items[index+1:]...)
idx := slices.Index(l.selectedItems, index)
if idx > -1 {
l.selectedItems = append(l.selectedItems[:idx], l.selectedItems[idx+1:]...)
}

// If there is nothing left, we're done.
if len(l.items) == 0 {
return l
}

// Shift current item.
// Shift cursor.
previousCurrentItem := l.currentItem
if l.currentItem > index || l.currentItem == len(l.items) {
l.currentItem--
}

// Fire "changed" event for removed items.
if previousCurrentItem == index && l.changed != nil {
item := l.items[l.currentItem]
l.changed(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
if l.multiSelect {
// Shift selected items.
for i := 0; i < len(l.selectedItems); i++ {
previousSelectedItem := l.selectedItems[i]
if l.selectedItems[i] > index || l.selectedItems[i] == len(l.items) {
l.selectedItems[i]--
}

// Fire "changed" event for removed items.
if previousSelectedItem == index && l.changed != nil {
item := l.items[l.selectedItems[i]]
l.changed(l.selectedItems[i], item.MainText, item.SecondaryText, item.Shortcut)
}
}
} else {
// Fire "changed" event for removed items in single-select mode.
if previousCurrentItem == index && l.changed != nil {
item := l.items[l.currentItem]
l.changed(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
}
}

return l
Expand Down Expand Up @@ -279,6 +321,14 @@ func (l *List) SetSelectedStyle(style tcell.Style) *List {
return l
}

// SetCursorStyle sets the style of the item under the cursor in multi-select
// mode. Note that the color of main text characters that are different from the
// main text color (e.g. color tags) is maintained.
func (l *List) SetCursorStyle(style tcell.Style) *List {
l.cursorStyle = style
return l
}

// SetUseStyleTags sets a flag which determines whether style tags are used in
// the main and secondary texts. The default is true.
func (l *List) SetUseStyleTags(mainStyleTags, secondaryStyleTags bool) *List {
Expand Down Expand Up @@ -399,10 +449,16 @@ func (l *List) InsertItem(index int, mainText, secondaryText string, shortcut ru
index = len(l.items)
}

// Shift current item.
// Shift cursor.
if l.currentItem < len(l.items) && l.currentItem >= index {
l.currentItem++
}
// Shift selected items.
for i := 0; i < len(l.selectedItems); i++ {
if l.selectedItems[i] < len(l.items) && l.selectedItems[i] >= index {
l.selectedItems[i]++
}
}

// Insert item (make space for the new item, then shift and insert).
l.items = append(l.items, nil)
Expand Down Expand Up @@ -491,6 +547,7 @@ func (l *List) FindItems(mainSearch, secondarySearch string, mustContainBoth, ig
func (l *List) Clear() *List {
l.items = nil
l.currentItem = 0
l.selectedItems = nil
return l
}

Expand Down Expand Up @@ -538,11 +595,23 @@ func (l *List) Draw(screen tcell.Screen) {
}

// Main text.
selected := index == l.currentItem && (!l.selectedFocusOnly || l.HasFocus())
var (
selected bool
underCursor bool
)
if l.multiSelect {
selected = slices.Contains(l.selectedItems, index) && (!l.selectedFocusOnly || l.HasFocus())
underCursor = index == l.currentItem && (!l.selectedFocusOnly || l.HasFocus())
} else {
selected = index == l.currentItem && (!l.selectedFocusOnly || l.HasFocus())
}
style := l.mainTextStyle
if selected {
style = l.selectedStyle
}
if underCursor {
style = l.cursorStyle
}
mainText := item.MainText
if !l.mainStyleTags {
mainText = Escape(mainText)
Expand Down Expand Up @@ -648,16 +717,32 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
}
case tcell.KeyEnter:
if l.currentItem >= 0 && l.currentItem < len(l.items) {
item := l.items[l.currentItem]
if item.Selected != nil {
item.Selected()
idx := -1
if l.multiSelect {
idx = slices.Index(l.selectedItems, l.currentItem)
}
if l.selected != nil {
l.selected(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
if idx == -1 {
// Toggle the item to be selected.
if l.multiSelect {
// Only add the item to the selectedItems list if we're in
// multi-select mode.
l.selectedItems = append(l.selectedItems, l.currentItem)
}
item := l.items[l.currentItem]
if item.Selected != nil {
item.Selected()
}
if l.selected != nil {
l.selected(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
}
} else {
// Remove the item from the selection if we're in multi-select mode.
l.selectedItems = append(l.selectedItems[:idx], l.selectedItems[idx+1:]...)
}
}
case tcell.KeyRune:
ch := event.Rune()
var removed bool
if ch != ' ' {
// It's not a space bar. Is it a shortcut?
var found bool
Expand All @@ -666,19 +751,30 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
// We have a shortcut.
found = true
l.currentItem = index
if l.multiSelect {
idx := slices.Index(l.selectedItems, l.currentItem)
if idx == -1 {
l.selectedItems = append(l.selectedItems, l.currentItem)
} else {
l.selectedItems = append(l.selectedItems[:idx], l.selectedItems[idx+1:]...)
removed = true
}
}
break
}
}
if !found {
break
}
}
item := l.items[l.currentItem]
if item.Selected != nil {
item.Selected()
}
if l.selected != nil {
l.selected(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
if !removed {
item := l.items[l.currentItem]
if item.Selected != nil {
item.Selected()
}
if l.selected != nil {
l.selected(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
}
}
}

Expand Down Expand Up @@ -738,13 +834,25 @@ func (l *List) MouseHandler() func(action MouseAction, event *tcell.EventMouse,
case MouseLeftClick:
setFocus(l)
index := l.indexAtPoint(event.Position())
var removed bool
if index != -1 {
item := l.items[index]
if item.Selected != nil {
item.Selected()
if l.multiSelect {
idx := slices.Index(l.selectedItems, index)
if idx == -1 {
l.selectedItems = append(l.selectedItems, index)
} else {
removed = true
l.selectedItems = append(l.selectedItems[:idx], l.selectedItems[idx+1:]...)
}
}
if l.selected != nil {
l.selected(index, item.MainText, item.SecondaryText, item.Shortcut)
item := l.items[index]
if !removed {
if item.Selected != nil {
item.Selected()
}
if l.selected != nil {
l.selected(index, item.MainText, item.SecondaryText, item.Shortcut)
}
}
if index != l.currentItem {
if l.changed != nil {
Expand Down

0 comments on commit 339debc

Please sign in to comment.