Repository: Evertras/bubble-table
Pull request #98 (2022-06-11 01:59:24)

Add user event for row change
Adds a new `UserEvent` type which can be checked after calling `Update` to more easily look for triggers such as the user changing rows.  Currently the only event is a row change event.

Evertras: Helps #60 

Patch:
diff --git a/Makefile b/Makefile
index 7800b15..eac3a38 100644
--- a/Makefile
+++ b/Makefile
@@ -10,6 +10,10 @@ example-metadata:
 example-dimensions:
 	@go run ./examples/dimensions/main.go
 
+.PHONY: example-events
+example-events:
+	@go run ./examples/events/main.go
+
 .PHONY: example-features
 example-features:
 	@go run ./examples/features/main.go
diff --git a/README.md b/README.md
index a545e33..1f4c265 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,8 @@ keys can be customized with a KeyMap.
 
 Can make rows selectable, and fetch the current selections.
 
+Events can be checked for user interactions.
+
 Pagination can be set with a given page size, which automatically generates a
 simple footer to show the current page and total pages.
 
diff --git a/examples/events/README.md b/examples/events/README.md
new file mode 100644
index 0000000..643a67f
--- /dev/null
+++ b/examples/events/README.md
@@ -0,0 +1,7 @@
+# Events example
+
+This example shows how to use events to handle triggers for data retrieval or
+other desired behavior.  This example in particular shows how to use events to
+trigger data retrieval.
+
+<img width="290" alt="image" src="https://user-images.githubusercontent.com/5923958/173168499-836bd03b-debb-455e-9f73-f2bfd028f2b2.png">
diff --git a/examples/events/main.go b/examples/events/main.go
new file mode 100644
index 0000000..5ce0cd0
--- /dev/null
+++ b/examples/events/main.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/evertras/bubble-table/table"
+)
+
+type Element string
+
+const (
+	columnKeyName    = "name"
+	columnKeyElement = "element"
+
+	// This is not a visible column, but is used to attach useful reference data
+	// to the row itself for easier retrieval
+	columnKeyPokemonData = "pokedata"
+
+	elementNormal   Element = "Normal"
+	elementFire     Element = "Fire"
+	elementElectric Element = "Electric"
+	elementWater    Element = "Water"
+	elementPlant    Element = "Plant"
+)
+
+var (
+	styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888"))
+
+	styleBase = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("#a7a")).
+			BorderForeground(lipgloss.Color("#a38")).
+			Align(lipgloss.Right)
+
+	elementColors = map[Element]string{
+		elementNormal:   "#fa0",
+		elementFire:     "#f64",
+		elementElectric: "#ff0",
+		elementWater:    "#44f",
+		elementPlant:    "#8b8",
+	}
+
+	customBorder = table.Border{
+		Top:    "─",
+		Left:   "│",
+		Right:  "│",
+		Bottom: "─",
+
+		TopRight:    "╮",
+		TopLeft:     "╭",
+		BottomRight: "╯",
+		BottomLeft:  "╰",
+
+		TopJunction:    "┬",
+		LeftJunction:   "├",
+		RightJunction:  "┤",
+		BottomJunction: "┴",
+		InnerJunction:  "┼",
+
+		InnerDivider: "│",
+	}
+)
+
+type Pokemon struct {
+	Name                     string
+	Element                  Element
+	ConversationCount        int
+	PositiveSentimentPercent float32
+	NegativeSentimentPercent float32
+}
+
+func NewPokemon(name string, element Element, conversationCount int, positiveSentimentPercent float32, negativeSentimentPercent float32) Pokemon {
+	return Pokemon{
+		Name:                     name,
+		Element:                  element,
+		ConversationCount:        conversationCount,
+		PositiveSentimentPercent: positiveSentimentPercent,
+		NegativeSentimentPercent: negativeSentimentPercent,
+	}
+}
+
+func (p Pokemon) ToRow() table.Row {
+	color, exists := elementColors[p.Element]
+
+	if !exists {
+		color = elementColors[elementNormal]
+	}
+
+	return table.NewRow(table.RowData{
+		columnKeyName:    p.Name,
+		columnKeyElement: table.NewStyledCell(p.Element, lipgloss.NewStyle().Foreground(lipgloss.Color(color))),
+
+		// This isn't a visible column, but we can add the data here anyway for later retrieval
+		columnKeyPokemonData: p,
+	})
+}
+
+type Model struct {
+	pokeTable table.Model
+
+	currentPokemonData Pokemon
+}
+
+func NewModel() Model {
+	pokemon := []Pokemon{
+		NewPokemon("Pikachu", elementElectric, 2300648, 21.9, 8.54),
+		NewPokemon("Eevee", elementNormal, 636373, 26.4, 7.37),
+		NewPokemon("Bulbasaur", elementPlant, 352190, 25.7, 9.02),
+		NewPokemon("Squirtle", elementWater, 241259, 25.6, 5.96),
+		NewPokemon("Blastoise", elementWater, 162794, 19.5, 6.04),
+		NewPokemon("Charmander", elementFire, 265760, 31.2, 5.25),
+		NewPokemon("Charizard", elementFire, 567763, 25.6, 7.56),
+	}
+
+	rows := []table.Row{}
+
+	for _, p := range pokemon {
+		rows = append(rows, p.ToRow())
+	}
+
+	return Model{
+		pokeTable: table.New([]table.Column{
+			table.NewColumn(columnKeyName, "Name", 13),
+			table.NewColumn(columnKeyElement, "Element", 10),
+		}).WithRows(rows).
+			Border(customBorder).
+			WithBaseStyle(styleBase).
+			WithPageSize(4).
+			Focused(true),
+		currentPokemonData: pokemon[0],
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return nil
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmd  tea.Cmd
+		cmds []tea.Cmd
+	)
+
+	m.pokeTable, cmd = m.pokeTable.Update(msg)
+	cmds = append(cmds, cmd)
+
+	for _, e := range m.pokeTable.GetLastUpdateUserEvents() {
+		switch e.(type) {
+		case table.UserEventHighlightedIndexChanged:
+			// We can pretend this is an async data retrieval, but really we already
+			// have the data, so just return it after some fake delay.  Also note
+			// that the event has some data attached to it, but we're ignoring
+			// that for this example as we just want the current highlighted row.
+			selectedPokemon := m.pokeTable.HighlightedRow().Data[columnKeyPokemonData].(Pokemon)
+
+			cmds = append(cmds, func() tea.Msg {
+				time.Sleep(time.Millisecond * 200)
+
+				return selectedPokemon
+			})
+		}
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc", "q":
+			cmds = append(cmds, tea.Quit)
+		}
+
+	case Pokemon:
+		m.currentPokemonData = msg
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m Model) View() string {
+	view := lipgloss.JoinVertical(
+		lipgloss.Left,
+		styleSubtle.Render("Press q or ctrl+c to quit"),
+		fmt.Sprintf("%s (%s)", m.currentPokemonData.Name, m.currentPokemonData.Element),
+		lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8")).
+			Render(":D %"+fmt.Sprintf("%.1f", m.currentPokemonData.PositiveSentimentPercent)),
+		lipgloss.NewStyle().Foreground(lipgloss.Color("#c88")).
+			Render(":( %"+fmt.Sprintf("%.1f", m.currentPokemonData.NegativeSentimentPercent)),
+		m.pokeTable.View(),
+	) + "\n"
+
+	return lipgloss.NewStyle().MarginLeft(1).Render(view)
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/table/events.go b/table/events.go
new file mode 100644
index 0000000..3eaefdb
--- /dev/null
+++ b/table/events.go
@@ -0,0 +1,42 @@
+package table
+
+// UserEvent is some state change that has occurred due to user input.  These will
+// ONLY be generated when a user has interacted directly with the table.  These
+// will NOT be generated when code programmatically changes values in the table.
+type UserEvent interface{}
+
+func (m *Model) appendUserEvent(e UserEvent) {
+	m.lastUpdateUserEvents = append(m.lastUpdateUserEvents, e)
+}
+
+func (m *Model) clearUserEvents() {
+	m.lastUpdateUserEvents = nil
+}
+
+// GetLastUpdateUserEvents returns a list of events that happened due to user
+// input in the last Update call.  This is useful to look for triggers such as
+// whether the user moved to a new highlighted row.
+func (m *Model) GetLastUpdateUserEvents() []UserEvent {
+	// Most common case
+	if len(m.lastUpdateUserEvents) == 0 {
+		return nil
+	}
+
+	returned := make([]UserEvent, len(m.lastUpdateUserEvents))
+
+	// Slightly wasteful but helps guarantee immutability, and this should only
+	// have data very rarely so this is fine
+	copy(returned, m.lastUpdateUserEvents)
+
+	return returned
+}
+
+// UserEventHighlightedIndexChanged indicates that the user has scrolled to a new
+// row.
+type UserEventHighlightedIndexChanged struct {
+	// PreviousRow is the row that was selected before the change.
+	PreviousRowIndex int
+
+	// SelectedRow is the row index that is now selected
+	SelectedRowIndex int
+}
diff --git a/table/model.go b/table/model.go
index bc34d84..b8d5c93 100644
--- a/table/model.go
+++ b/table/model.go
@@ -29,6 +29,9 @@ type Model struct {
 	selectableRows bool
 	rowCursorIndex int
 
+	// Events
+	lastUpdateUserEvents []UserEvent
+
 	// Styles
 	baseStyle      lipgloss.Style
 	highlightStyle lipgloss.Style
diff --git a/table/options.go b/table/options.go
index 19c6e49..171b264 100644
--- a/table/options.go
+++ b/table/options.go
@@ -33,6 +33,10 @@ func (m Model) HeaderStyle(style lipgloss.Style) Model {
 func (m Model) WithRows(rows []Row) Model {
 	m.rows = rows
 
+	if m.rowCursorIndex >= len(m.rows) {
+		m.rowCursorIndex = len(m.rows) - 1
+	}
+
 	if m.pageSize != 0 {
 		maxPage := m.MaxPages()
 
diff --git a/table/scrolling.go b/table/scrolling.go
index c2a0689..3ee3256 100644
--- a/table/scrolling.go
+++ b/table/scrolling.go
@@ -47,10 +47,4 @@ func (m *Model) recalculateLastHorizontalColumn() {
 			m.maxHorizontalColumnIndex = i - m.horizontalScrollFreezeColumnsCount
 		}
 	}
-
-	if m.maxHorizontalColumnIndex <= m.horizontalScrollFreezeColumnsCount {
-		m.maxHorizontalColumnIndex = 0
-
-		return
-	}
 }
diff --git a/table/update.go b/table/update.go
index 2106dbf..8a36177 100644
--- a/table/update.go
+++ b/table/update.go
@@ -52,8 +52,10 @@ func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
 	return m, cmd
 }
 
-// nolint: cyclop // This is just a series of Matches tests
+// nolint: cyclop // This is a series of Matches tests with minimal logic
 func (m *Model) handleKeypress(msg tea.KeyMsg) {
+	previousRowIndex := m.rowCursorIndex
+
 	if key.Matches(msg, m.keyMap.RowDown) {
 		m.moveHighlightDown()
 	}
@@ -97,10 +99,19 @@ func (m *Model) handleKeypress(msg tea.KeyMsg) {
 	if key.Matches(msg, m.keyMap.ScrollLeft) {
 		m.scrollLeft()
 	}
+
+	if m.rowCursorIndex != previousRowIndex {
+		m.appendUserEvent(UserEventHighlightedIndexChanged{
+			PreviousRowIndex: previousRowIndex,
+			SelectedRowIndex: m.rowCursorIndex,
+		})
+	}
 }
 
 // Update responds to input from the user or other messages from Bubble Tea.
 func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+	m.clearUserEvents()
+
 	if !m.focused {
 		return m, nil
 	}


Test patch:
diff --git a/table/events_test.go b/table/events_test.go
new file mode 100644
index 0000000..e598a00
--- /dev/null
+++ b/table/events_test.go
@@ -0,0 +1,94 @@
+package table
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUserEventsEmptyWhenNothingHappens(t *testing.T) {
+	model := New([]Column{})
+
+	events := model.GetLastUpdateUserEvents()
+
+	assert.Len(t, events, 0, "Should be empty when nothing has happened")
+
+	model, _ = model.Update(nil)
+
+	events = model.GetLastUpdateUserEvents()
+
+	assert.Len(t, events, 0, "Should be empty when no changes made in Update")
+}
+
+// nolint: funlen // This is a bunch of checks in a row, this is fine
+func TestUserEventHighlightedIndexChanged(t *testing.T) {
+	// Don't need any actual row data for this
+	empty := RowData{}
+
+	model := New([]Column{}).
+		Focused(true).
+		WithRows(
+			[]Row{
+				NewRow(empty),
+				NewRow(empty),
+				NewRow(empty),
+				NewRow(empty),
+			},
+		)
+
+	hitDown := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
+	}
+
+	hitUp := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
+	}
+
+	checkEvent := func(events []UserEvent, expectedPreviousIndex, expectedCurrentIndex int) {
+		if len(events) != 1 {
+			assert.FailNow(t, "Asked to check events with len of not 1, test is bad")
+		}
+
+		switch event := events[0].(type) {
+		case UserEventHighlightedIndexChanged:
+			assert.Equal(t, expectedPreviousIndex, event.PreviousRowIndex)
+			assert.Equal(t, expectedCurrentIndex, event.SelectedRowIndex)
+
+		default:
+			assert.Failf(t, "Event is not expected type UserEventHighlightedIndexChanged", "%+v", event)
+		}
+	}
+
+	events := model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 0, "Should be empty when nothing has happened")
+
+	// Hit down to change row down by one
+	hitDown()
+	events = model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 1, "Missing event for scrolling down")
+	checkEvent(events, 0, 1)
+
+	// Do some no-op
+	model, _ = model.Update(nil)
+	events = model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 0, "Events not cleared between Updates")
+
+	// Hit up to go back to top
+	hitUp()
+	events = model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 1, "Missing event to scroll back up")
+	checkEvent(events, 1, 0)
+
+	// Hit up to scroll around to bottom
+	hitUp()
+	events = model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 1, "Missing event to scroll up with wrap")
+	checkEvent(events, 0, 3)
+
+	// Now check to make sure it doesn't trigger when row index doesn't change
+	model = model.WithRows([]Row{NewRow(empty)})
+	hitDown()
+	events = model.GetLastUpdateUserEvents()
+	assert.Len(t, events, 0, "There's no row to change to for single row table, event shouldn't exist")
+}
diff --git a/table/scrolling_fuzz_test.go b/table/scrolling_fuzz_test.go
new file mode 100644
index 0000000..93909aa
--- /dev/null
+++ b/table/scrolling_fuzz_test.go
@@ -0,0 +1,96 @@
+//go:build go1.18
+// +build go1.18
+
+package table
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/stretchr/testify/assert"
+)
+
+// This is long because of test cases
+// nolint: funlen
+func FuzzHorizontalScrollingStopEdgeCases(f *testing.F) {
+	const (
+		minNameWidth = 2
+		maxNameWidth = 50
+
+		minColWidth = 4
+		maxColWidth = 50
+
+		minNumCols = 1
+		maxNumCols = 500
+
+		minMaxWidth = 5
+		maxMaxWidth = 200
+
+		borderBuffer = 4
+	)
+
+	f.Add(5, 3, 5, 30)
+	f.Fuzz(func(t *testing.T, nameWidth, colWidth, numCols, maxWidth int) {
+		if nameWidth < minNameWidth ||
+			nameWidth > maxNameWidth ||
+			nameWidth > maxWidth-colWidth ||
+			nameWidth+colWidth+borderBuffer >= maxWidth {
+			return
+		}
+
+		if colWidth < minColWidth ||
+			colWidth > maxColWidth ||
+			colWidth >= maxWidth {
+			return
+		}
+
+		if numCols < minNumCols || numCols > maxNumCols {
+			return
+		}
+
+		if maxWidth < minMaxWidth || maxWidth > maxMaxWidth {
+			return
+		}
+
+		cols := []Column{NewColumn("Name", "Name", nameWidth)}
+		for i := 0; i < numCols; i++ {
+			s := fmt.Sprintf("%d", i+1)
+			cols = append(cols, NewColumn(s, s, colWidth))
+		}
+
+		rowData := RowData{"Name": "A"}
+
+		for i := 0; i < numCols; i++ {
+			s := fmt.Sprintf("%d", i+1)
+			rowData[s] = s
+		}
+
+		rows := []Row{NewRow(rowData)}
+
+		model := New(cols).
+			WithRows(rows).
+			WithStaticFooter("Footer").
+			WithMaxTotalWidth(maxWidth).
+			WithHorizontalFreezeColumnCount(1).
+			Focused(true)
+
+		hitScrollRight := func() {
+			model, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftRight})
+		}
+
+		// Excessive scrolling attempts to be sure
+		for i := 0; i < numCols*2; i++ {
+			hitScrollRight()
+		}
+
+		rendered := model.View()
+
+		assert.NotContains(t, rendered, ">")
+
+		if !strings.Contains(rendered, "…") {
+			assert.Contains(t, rendered, fmt.Sprintf("%d", numCols))
+		}
+	})
+}
diff --git a/table/scrolling_test.go b/table/scrolling_test.go
index 4f2c7c9..52f49a8 100644
--- a/table/scrolling_test.go
+++ b/table/scrolling_test.go
@@ -307,6 +307,18 @@ func TestHorizontalScrollingStopEdgeCases(t *testing.T) {
 			maxWidth:     20,
 			expectedCols: []int{7, 8},
 		},
+		{
+			numCols:   6,
+			nameWidth: 5,
+			colWidth:  3,
+			maxWidth:  30,
+		},
+		{
+			numCols:   50,
+			nameWidth: 20,
+			colWidth:  6,
+			maxWidth:  31,
+		},
 	}
 
 	for i, test := range tests {