fix(tui): wrap menu navigation (#1242)

FromSi created

## What?

Adds wrap-around navigation to the remaining bounded TUI menus and
selection lists, including settings sections, file picker items, and
email attachments. Updates tests to cover the cyclic navigation
behavior.

## Why?

Some TUI areas still stopped at the first or last item while others
already used cyclic navigation. This makes menu movement consistent
across the app: pressing up on the first item selects the last item, and
pressing down on the last item selects the first.

Closes #1237

Change summary

tui/email_view.go           |   8 +-
tui/email_view_test.go      |  12 +-
tui/filepicker.go           |   8 +-
tui/navigation_wrap_test.go | 156 +++++++++++++++++++++++++++++++++++++++
tui/settings.go             |  10 +-
tui/settings_accounts.go    |  10 +-
tui/settings_general.go     |   8 -
tui/settings_lists.go       |  10 +-
tui/settings_theme.go       |   8 +-
9 files changed, 188 insertions(+), 42 deletions(-)

Detailed changes

tui/email_view.go 🔗

@@ -206,13 +206,13 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.focusOnAttachments {
 			switch msg.String() {
 			case "up", kb.Global.NavUp:
-				if m.attachmentCursor > 0 {
-					m.attachmentCursor--
+				if len(m.email.Attachments) > 0 {
+					m.attachmentCursor = (m.attachmentCursor - 1 + len(m.email.Attachments)) % len(m.email.Attachments)
 				}
 				return m, nil
 			case "down", kb.Global.NavDown:
-				if m.attachmentCursor < len(m.email.Attachments)-1 {
-					m.attachmentCursor++
+				if len(m.email.Attachments) > 0 {
+					m.attachmentCursor = (m.attachmentCursor + 1) % len(m.email.Attachments)
 				}
 				return m, nil
 			case "enter":

tui/email_view_test.go 🔗

@@ -79,18 +79,18 @@ func TestEmailViewUpdate(t *testing.T) {
 			t.Errorf("After one down arrow, attachmentCursor should be 1, got %d", emailView.attachmentCursor)
 		}
 
-		// Move down again (should not go past the end)
+		// Move down again (should wrap to the first attachment)
 		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 		emailView = model.(*EmailView)
-		if emailView.attachmentCursor != 1 {
-			t.Errorf("attachmentCursor should not go past the end of the list, got %d", emailView.attachmentCursor)
+		if emailView.attachmentCursor != 0 {
+			t.Errorf("attachmentCursor should wrap to the start of the list, got %d", emailView.attachmentCursor)
 		}
 
-		// Move up
+		// Move up (should wrap to the last attachment)
 		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyUp})
 		emailView = model.(*EmailView)
-		if emailView.attachmentCursor != 0 {
-			t.Errorf("After one up arrow, attachmentCursor should be 0, got %d", emailView.attachmentCursor)
+		if emailView.attachmentCursor != 1 {
+			t.Errorf("After one up arrow from the first item, attachmentCursor should be 1, got %d", emailView.attachmentCursor)
 		}
 	})
 

tui/filepicker.go 🔗

@@ -120,12 +120,12 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Normal browsing mode
 		switch msg.String() {
 		case "up", "k":
-			if m.cursor > 0 {
-				m.cursor--
+			if len(m.items) > 0 {
+				m.cursor = (m.cursor - 1 + len(m.items)) % len(m.items)
 			}
 		case "down", "j":
-			if m.cursor < len(m.items)-1 {
-				m.cursor++
+			if len(m.items) > 0 {
+				m.cursor = (m.cursor + 1) % len(m.items)
 			}
 		case "/":
 			m.editingPath = true

tui/navigation_wrap_test.go 🔗

@@ -0,0 +1,156 @@
+package tui
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/theme"
+)
+
+func TestSettingsNavigationWraps(t *testing.T) {
+	settings := NewSettings(&config.Config{
+		Accounts: []config.Account{
+			{ID: "account-1", Email: "one@example.com"},
+			{ID: "account-2", Email: "two@example.com"},
+		},
+		MailingLists: []config.MailingList{
+			{Name: "List One"},
+			{Name: "List Two"},
+		},
+	})
+
+	t.Run("menu", func(t *testing.T) {
+		settings.menuCursor = 0
+		model, _ := settings.updateMenu(tea.KeyPressMsg{Code: tea.KeyUp})
+		settings = model.(*Settings)
+		if settings.menuCursor != int(CategoryEncryption) {
+			t.Fatalf("up from first menu item should wrap to last, got %d", settings.menuCursor)
+		}
+
+		model, _ = settings.updateMenu(tea.KeyPressMsg{Code: tea.KeyDown})
+		settings = model.(*Settings)
+		if settings.menuCursor != 0 {
+			t.Fatalf("down from last menu item should wrap to first, got %d", settings.menuCursor)
+		}
+	})
+
+	t.Run("general", func(t *testing.T) {
+		settings.generalCursor = 0
+		last := len(settings.buildGeneralOptions()) - 1
+
+		model, _ := settings.updateGeneral(tea.KeyPressMsg{Code: tea.KeyUp})
+		settings = model.(*Settings)
+		if settings.generalCursor != last {
+			t.Fatalf("up from first general item should wrap to last, got %d", settings.generalCursor)
+		}
+
+		model, _ = settings.updateGeneral(tea.KeyPressMsg{Code: tea.KeyDown})
+		settings = model.(*Settings)
+		if settings.generalCursor != 0 {
+			t.Fatalf("down from last general item should wrap to first, got %d", settings.generalCursor)
+		}
+	})
+
+	t.Run("accounts", func(t *testing.T) {
+		settings.accountsCursor = 0
+		last := len(settings.cfg.Accounts)
+
+		model, _ := settings.updateAccounts(tea.KeyPressMsg{Code: tea.KeyUp})
+		settings = model.(*Settings)
+		if settings.accountsCursor != last {
+			t.Fatalf("up from first account item should wrap to add account, got %d", settings.accountsCursor)
+		}
+
+		model, _ = settings.updateAccounts(tea.KeyPressMsg{Code: tea.KeyDown})
+		settings = model.(*Settings)
+		if settings.accountsCursor != 0 {
+			t.Fatalf("down from add account should wrap to first, got %d", settings.accountsCursor)
+		}
+	})
+
+	t.Run("mailing lists", func(t *testing.T) {
+		settings.listsCursor = 0
+		last := len(settings.cfg.MailingLists)
+
+		model, _ := settings.updateMailingLists(tea.KeyPressMsg{Code: tea.KeyUp})
+		settings = model.(*Settings)
+		if settings.listsCursor != last {
+			t.Fatalf("up from first mailing list should wrap to add list, got %d", settings.listsCursor)
+		}
+
+		model, _ = settings.updateMailingLists(tea.KeyPressMsg{Code: tea.KeyDown})
+		settings = model.(*Settings)
+		if settings.listsCursor != 0 {
+			t.Fatalf("down from add list should wrap to first, got %d", settings.listsCursor)
+		}
+	})
+
+	t.Run("theme", func(t *testing.T) {
+		themes := theme.AllThemes()
+		if len(themes) < 2 {
+			t.Skip("need at least two themes to test wrap-around")
+		}
+
+		settings.themeCursor = 0
+		model, _ := settings.updateTheme(tea.KeyPressMsg{Code: tea.KeyUp})
+		settings = model.(*Settings)
+		if settings.themeCursor != len(themes)-1 {
+			t.Fatalf("up from first theme should wrap to last, got %d", settings.themeCursor)
+		}
+
+		model, _ = settings.updateTheme(tea.KeyPressMsg{Code: tea.KeyDown})
+		settings = model.(*Settings)
+		if settings.themeCursor != 0 {
+			t.Fatalf("down from last theme should wrap to first, got %d", settings.themeCursor)
+		}
+	})
+}
+
+func TestFilePickerNavigationWraps(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	picker := NewFilePicker(dir)
+	if len(picker.items) != 2 {
+		t.Fatalf("expected two picker items, got %d", len(picker.items))
+	}
+
+	model, _ := picker.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+	picker = model.(*FilePicker)
+	if picker.cursor != len(picker.items)-1 {
+		t.Fatalf("up from first file should wrap to last, got %d", picker.cursor)
+	}
+
+	model, _ = picker.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	picker = model.(*FilePicker)
+	if picker.cursor != 0 {
+		t.Fatalf("down from last file should wrap to first, got %d", picker.cursor)
+	}
+}
+
+func TestFilePickerNavigationEmptyDirectoryDoesNotWrap(t *testing.T) {
+	picker := NewFilePicker(t.TempDir())
+	if len(picker.items) != 0 {
+		t.Fatalf("expected empty picker, got %d items", len(picker.items))
+	}
+
+	model, _ := picker.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+	picker = model.(*FilePicker)
+	if picker.cursor != 0 {
+		t.Fatalf("empty picker cursor should remain zero after up, got %d", picker.cursor)
+	}
+
+	model, _ = picker.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	picker = model.(*FilePicker)
+	if picker.cursor != 0 {
+		t.Fatalf("empty picker cursor should remain zero after down, got %d", picker.cursor)
+	}
+}

tui/settings.go 🔗

@@ -237,15 +237,13 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	categoryCount := int(CategoryEncryption) + 1
+
 	switch msg.String() {
 	case "up", "k":
-		if m.menuCursor > 0 {
-			m.menuCursor--
-		}
+		m.menuCursor = (m.menuCursor - 1 + categoryCount) % categoryCount
 	case "down", "j":
-		if m.menuCursor < 4 {
-			m.menuCursor++
-		}
+		m.menuCursor = (m.menuCursor + 1) % categoryCount
 	case "right", "l", "enter":
 		m.activeCategory = SettingsCategory(m.menuCursor)
 		m.activePane = PaneContent

tui/settings_accounts.go 🔗

@@ -37,13 +37,11 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 
 	switch msg.String() {
 	case "up", "k":
-		if m.accountsCursor > 0 {
-			m.accountsCursor--
-		}
+		itemCount := len(m.cfg.Accounts) + 1
+		m.accountsCursor = (m.accountsCursor - 1 + itemCount) % itemCount
 	case "down", "j":
-		if m.accountsCursor < len(m.cfg.Accounts) {
-			m.accountsCursor++
-		}
+		itemCount := len(m.cfg.Accounts) + 1
+		m.accountsCursor = (m.accountsCursor + 1) % itemCount
 	case "d":
 		if m.accountsCursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
 			m.confirmingDelete = true

tui/settings_general.go 🔗

@@ -51,13 +51,9 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 
 	switch msg.String() {
 	case "up", "k":
-		if m.generalCursor > 0 {
-			m.generalCursor--
-		}
+		m.generalCursor = (m.generalCursor - 1 + len(opts)) % len(opts)
 	case "down", "j":
-		if m.generalCursor < len(opts)-1 {
-			m.generalCursor++
-		}
+		m.generalCursor = (m.generalCursor + 1) % len(opts)
 	case "enter", "space", "right", "l":
 		if m.generalCursor < len(opts) {
 			opt := opts[m.generalCursor]

tui/settings_lists.go 🔗

@@ -30,13 +30,11 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
 
 	switch msg.String() {
 	case "up", "k":
-		if m.listsCursor > 0 {
-			m.listsCursor--
-		}
+		itemCount := len(m.cfg.MailingLists) + 1
+		m.listsCursor = (m.listsCursor - 1 + itemCount) % itemCount
 	case "down", "j":
-		if m.listsCursor < len(m.cfg.MailingLists) {
-			m.listsCursor++
-		}
+		itemCount := len(m.cfg.MailingLists) + 1
+		m.listsCursor = (m.listsCursor + 1) % itemCount
 	case "d":
 		if m.listsCursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
 			m.confirmingDelete = true

tui/settings_theme.go 🔗

@@ -14,12 +14,12 @@ func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 
 	switch msg.String() {
 	case "up", "k":
-		if m.themeCursor > 0 {
-			m.themeCursor--
+		if len(themes) > 0 {
+			m.themeCursor = (m.themeCursor - 1 + len(themes)) % len(themes)
 		}
 	case "down", "j":
-		if m.themeCursor < len(themes)-1 {
-			m.themeCursor++
+		if len(themes) > 0 {
+			m.themeCursor = (m.themeCursor + 1) % len(themes)
 		}
 	case "enter", "space", "right", "l":
 		if m.themeCursor < len(themes) {