Browse Source

feat(tui): add support for readline list nav (`ctrl-p`/`ctrl-n`) (#955)

Dorian Karter 7 months ago
parent
commit
86a2ea44b5

+ 2 - 2
packages/tui/internal/components/list/list.go

@@ -46,11 +46,11 @@ type listKeyMap struct {
 
 
 var simpleListKeys = listKeyMap{
 var simpleListKeys = listKeyMap{
 	Up: key.NewBinding(
 	Up: key.NewBinding(
-		key.WithKeys("up"),
+		key.WithKeys("up", "ctrl+p"),
 		key.WithHelp("↑", "previous list item"),
 		key.WithHelp("↑", "previous list item"),
 	),
 	),
 	Down: key.NewBinding(
 	Down: key.NewBinding(
-		key.WithKeys("down"),
+		key.WithKeys("down", "ctrl+n"),
 		key.WithHelp("↓", "next list item"),
 		key.WithHelp("↓", "next list item"),
 	),
 	),
 	UpAlpha: key.NewBinding(
 	UpAlpha: key.NewBinding(

+ 160 - 0
packages/tui/internal/components/list/list_test.go

@@ -0,0 +1,160 @@
+package list
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+// testItem is a simple test implementation of ListItem
+type testItem struct {
+	value string
+}
+
+func (t testItem) Render(selected bool, width int) string {
+	return t.value
+}
+
+// createTestList creates a list with test items for testing
+func createTestList() *listComponent[testItem] {
+	items := []testItem{
+		{value: "item1"},
+		{value: "item2"},
+		{value: "item3"},
+	}
+	list := NewListComponent(items, 5, "empty", false)
+	return list.(*listComponent[testItem])
+}
+
+func TestArrowKeyNavigation(t *testing.T) {
+	list := createTestList()
+
+	// Test down arrow navigation
+	downKey := tea.KeyPressMsg{Code: tea.KeyDown}
+	updatedModel, _ := list.Update(downKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx := list.GetSelectedItem()
+	if idx != 1 {
+		t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
+	}
+
+	// Test up arrow navigation
+	upKey := tea.KeyPressMsg{Code: tea.KeyUp}
+	updatedModel, _ = list.Update(upKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx = list.GetSelectedItem()
+	if idx != 0 {
+		t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
+	}
+}
+
+func TestJKKeyNavigation(t *testing.T) {
+	items := []testItem{
+		{value: "item1"},
+		{value: "item2"},
+		{value: "item3"},
+	}
+	// Create list with alpha keys enabled
+	list := NewListComponent(items, 5, "empty", true).(*listComponent[testItem])
+
+	// Test j key (down)
+	jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
+	updatedModel, _ := list.Update(jKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx := list.GetSelectedItem()
+	if idx != 1 {
+		t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
+	}
+
+	// Test k key (up)
+	kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
+	updatedModel, _ = list.Update(kKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx = list.GetSelectedItem()
+	if idx != 0 {
+		t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
+	}
+}
+
+func TestCtrlNavigation(t *testing.T) {
+	list := createTestList()
+
+	// Test Ctrl-N (down)
+	ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
+	updatedModel, _ := list.Update(ctrlN)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx := list.GetSelectedItem()
+	if idx != 1 {
+		t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
+	}
+
+	// Test Ctrl-P (up)
+	ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
+	updatedModel, _ = list.Update(ctrlP)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx = list.GetSelectedItem()
+	if idx != 0 {
+		t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
+	}
+}
+
+func TestNavigationBoundaries(t *testing.T) {
+	list := createTestList()
+
+	// Test up arrow at first item (should stay at 0)
+	upKey := tea.KeyPressMsg{Code: tea.KeyUp}
+	updatedModel, _ := list.Update(upKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx := list.GetSelectedItem()
+	if idx != 0 {
+		t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
+	}
+
+	// Move to last item
+	downKey := tea.KeyPressMsg{Code: tea.KeyDown}
+	updatedModel, _ = list.Update(downKey)
+	list = updatedModel.(*listComponent[testItem])
+	updatedModel, _ = list.Update(downKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx = list.GetSelectedItem()
+	if idx != 2 {
+		t.Errorf("Expected to be at index 2, got %d", idx)
+	}
+
+	// Test down arrow at last item (should stay at 2)
+	updatedModel, _ = list.Update(downKey)
+	list = updatedModel.(*listComponent[testItem])
+	_, idx = list.GetSelectedItem()
+	if idx != 2 {
+		t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
+	}
+}
+
+func TestEmptyList(t *testing.T) {
+	emptyList := NewListComponent([]testItem{}, 5, "empty", false).(*listComponent[testItem])
+
+	// Test navigation on empty list (should not crash)
+	downKey := tea.KeyPressMsg{Code: tea.KeyDown}
+	upKey := tea.KeyPressMsg{Code: tea.KeyUp}
+	ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
+	ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
+
+	updatedModel, _ := emptyList.Update(downKey)
+	emptyList = updatedModel.(*listComponent[testItem])
+	updatedModel, _ = emptyList.Update(upKey)
+	emptyList = updatedModel.(*listComponent[testItem])
+	updatedModel, _ = emptyList.Update(ctrlN)
+	emptyList = updatedModel.(*listComponent[testItem])
+	updatedModel, _ = emptyList.Update(ctrlP)
+	emptyList = updatedModel.(*listComponent[testItem])
+
+	// Verify empty list behavior
+	_, idx := emptyList.GetSelectedItem()
+	if idx != -1 {
+		t.Errorf("Expected index -1 for empty list, got %d", idx)
+	}
+
+	if !emptyList.IsEmpty() {
+		t.Error("Expected IsEmpty() to return true for empty list")
+	}
+}