diff --git a/cspell.json b/cspell.json index 2cffa38ca36558d9273f2781dd7a686be1b3820d..713684deb4cf3f066d92b6a71a063df90cddf0fc 100644 --- a/cspell.json +++ b/cspell.json @@ -1,108 +1 @@ -{ - "words": [ - "afero", - "agentic", - "alecthomas", - "anthropics", - "aymanbagabas", - "azidentity", - "bmatcuk", - "bubbletea", - "charlievieth", - "charmbracelet", - "charmtone", - "Charple", - "chkconfig", - "crush", - "curlie", - "cursorrules", - "diffview", - "doas", - "Dockerfiles", - "doublestar", - "dpkg", - "Emph", - "fastwalk", - "fdisk", - "filepicker", - "Focusable", - "fseventsd", - "fsext", - "genai", - "goquery", - "GROQ", - "Guac", - "imageorient", - "Inex", - "jetta", - "jsons", - "jsonschema", - "jspm", - "Kaufmann", - "killall", - "Lanczos", - "lipgloss", - "LOCALAPPDATA", - "lsps", - "lucasb", - "makepkg", - "mcps", - "MSYS", - "mvdan", - "natefinch", - "nfnt", - "noctx", - "nohup", - "nolint", - "nslookup", - "oksvg", - "Oneshot", - "openrouter", - "opkg", - "pacman", - "paru", - "pfctl", - "postamble", - "postambles", - "preconfigured", - "Preproc", - "Proactiveness", - "Puerkito", - "pycache", - "pytest", - "qjebbs", - "rasterx", - "rivo", - "sabhiram", - "sess", - "shortlog", - "sjson", - "Sourcegraph", - "srwiley", - "SSEMCP", - "Streamable", - "stretchr", - "Strikethrough", - "substrs", - "Suscriber", - "systeminfo", - "tasklist", - "termenv", - "textinput", - "tidwall", - "timedout", - "trashhalo", - "udiff", - "uniseg", - "Unticked", - "urllib", - "USERPROFILE", - "VERTEXAI", - "webp", - "whatis", - "whereis" - ], - "flagWords": [], - "language": "en", - "version": "0.2" -} +{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]} \ No newline at end of file diff --git a/go.mod b/go.mod index 32d9dc28d09cc133801c00d0628d6630922eacf5..b4779ad998320aad820743bddbcad799e544e24c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.9.0 github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 github.com/charmbracelet/catwalk v0.3.1 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe diff --git a/go.sum b/go.sum index 07a4be065e84a47701ed330bde1d881500719597..e9b0c68e0a51903629569192ed1b41fe36a60daf 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U= github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= diff --git a/internal/csync/slices.go b/internal/csync/slices.go index be723655079ccc6b07f55c3237b706a17bb14d40..4e67ac1a127f577d1d13673e4311b2ea44e17393 100644 --- a/internal/csync/slices.go +++ b/internal/csync/slices.go @@ -2,6 +2,7 @@ package csync import ( "iter" + "slices" "sync" ) @@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] { } } } + +// Slice is a thread-safe slice implementation that provides concurrent access. +type Slice[T any] struct { + inner []T + mu sync.RWMutex +} + +// NewSlice creates a new thread-safe slice. +func NewSlice[T any]() *Slice[T] { + return &Slice[T]{ + inner: make([]T, 0), + } +} + +// NewSliceFrom creates a new thread-safe slice from an existing slice. +func NewSliceFrom[T any](s []T) *Slice[T] { + inner := make([]T, len(s)) + copy(inner, s) + return &Slice[T]{ + inner: inner, + } +} + +// Append adds an element to the end of the slice. +func (s *Slice[T]) Append(item T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = append(s.inner, item) +} + +// Prepend adds an element to the beginning of the slice. +func (s *Slice[T]) Prepend(item T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = append([]T{item}, s.inner...) +} + +// Delete removes the element at the specified index. +func (s *Slice[T]) Delete(index int) bool { + s.mu.Lock() + defer s.mu.Unlock() + if index < 0 || index >= len(s.inner) { + return false + } + s.inner = slices.Delete(s.inner, index, index+1) + return true +} + +// Get returns the element at the specified index. +func (s *Slice[T]) Get(index int) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + var zero T + if index < 0 || index >= len(s.inner) { + return zero, false + } + return s.inner[index], true +} + +// Set updates the element at the specified index. +func (s *Slice[T]) Set(index int, item T) bool { + s.mu.Lock() + defer s.mu.Unlock() + if index < 0 || index >= len(s.inner) { + return false + } + s.inner[index] = item + return true +} + +// Len returns the number of elements in the slice. +func (s *Slice[T]) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.inner) +} + +// Slice returns a copy of the underlying slice. +func (s *Slice[T]) Slice() []T { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]T, len(s.inner)) + copy(result, s.inner) + return result +} + +// SetSlice replaces the entire slice with a new one. +func (s *Slice[T]) SetSlice(items []T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = make([]T, len(items)) + copy(s.inner, items) +} + +// Clear removes all elements from the slice. +func (s *Slice[T]) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = s.inner[:0] +} + +// Seq returns an iterator that yields elements from the slice. +func (s *Slice[T]) Seq() iter.Seq[T] { + // Take a snapshot to avoid holding the lock during iteration + items := s.Slice() + return func(yield func(T) bool) { + for _, v := range items { + if !yield(v) { + return + } + } + } +} + +// SeqWithIndex returns an iterator that yields index-value pairs from the slice. +func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] { + // Take a snapshot to avoid holding the lock during iteration + items := s.Slice() + return func(yield func(int, T) bool) { + for i, v := range items { + if !yield(i, v) { + return + } + } + } +} diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index 731cb96f55dd24cae74f55c0ef8e97ebd28aacaa..fd1bd69ba6ca4d07cd5383cb52746e41caa8d901 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -1,11 +1,13 @@ package csync import ( + "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLazySlice_Seq(t *testing.T) { @@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } + +func TestSlice(t *testing.T) { + t.Run("NewSlice", func(t *testing.T) { + s := NewSlice[int]() + assert.Equal(t, 0, s.Len()) + }) + + t.Run("NewSliceFrom", func(t *testing.T) { + original := []int{1, 2, 3} + s := NewSliceFrom(original) + assert.Equal(t, 3, s.Len()) + + // Verify it's a copy, not a reference + original[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 1, val) + }) + + t.Run("Append", func(t *testing.T) { + s := NewSlice[string]() + s.Append("hello") + s.Append("world") + + assert.Equal(t, 2, s.Len()) + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, "hello", val) + + val, ok = s.Get(1) + require.True(t, ok) + assert.Equal(t, "world", val) + }) + + t.Run("Prepend", func(t *testing.T) { + s := NewSlice[string]() + s.Append("world") + s.Prepend("hello") + + assert.Equal(t, 2, s.Len()) + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, "hello", val) + + val, ok = s.Get(1) + require.True(t, ok) + assert.Equal(t, "world", val) + }) + + t.Run("Delete", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3, 4, 5}) + + // Delete middle element + ok := s.Delete(2) + assert.True(t, ok) + assert.Equal(t, 4, s.Len()) + + expected := []int{1, 2, 4, 5} + actual := s.Slice() + assert.Equal(t, expected, actual) + + // Delete out of bounds + ok = s.Delete(10) + assert.False(t, ok) + assert.Equal(t, 4, s.Len()) + + // Delete negative index + ok = s.Delete(-1) + assert.False(t, ok) + assert.Equal(t, 4, s.Len()) + }) + + t.Run("Get", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + val, ok := s.Get(1) + require.True(t, ok) + assert.Equal(t, "b", val) + + // Out of bounds + _, ok = s.Get(10) + assert.False(t, ok) + + // Negative index + _, ok = s.Get(-1) + assert.False(t, ok) + }) + + t.Run("Set", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + ok := s.Set(1, "modified") + assert.True(t, ok) + + val, ok := s.Get(1) + require.True(t, ok) + assert.Equal(t, "modified", val) + + // Out of bounds + ok = s.Set(10, "invalid") + assert.False(t, ok) + + // Negative index + ok = s.Set(-1, "invalid") + assert.False(t, ok) + }) + + t.Run("SetSlice", func(t *testing.T) { + s := NewSlice[int]() + s.Append(1) + s.Append(2) + + newItems := []int{10, 20, 30} + s.SetSlice(newItems) + + assert.Equal(t, 3, s.Len()) + assert.Equal(t, newItems, s.Slice()) + + // Verify it's a copy + newItems[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 10, val) + }) + + t.Run("Clear", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3}) + assert.Equal(t, 3, s.Len()) + + s.Clear() + assert.Equal(t, 0, s.Len()) + }) + + t.Run("Slice", func(t *testing.T) { + original := []int{1, 2, 3} + s := NewSliceFrom(original) + + copy := s.Slice() + assert.Equal(t, original, copy) + + // Verify it's a copy + copy[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 1, val) + }) + + t.Run("Seq", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3}) + + var result []int + for v := range s.Seq() { + result = append(result, v) + } + + assert.Equal(t, []int{1, 2, 3}, result) + }) + + t.Run("SeqWithIndex", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + var indices []int + var values []string + for i, v := range s.SeqWithIndex() { + indices = append(indices, i) + values = append(values, v) + } + + assert.Equal(t, []int{0, 1, 2}, indices) + assert.Equal(t, []string{"a", "b", "c"}, values) + }) + + t.Run("ConcurrentAccess", func(t *testing.T) { + s := NewSlice[int]() + const numGoroutines = 100 + const itemsPerGoroutine = 10 + + var wg sync.WaitGroup + + // Concurrent appends + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(start int) { + defer wg.Done() + for j := 0; j < itemsPerGoroutine; j++ { + s.Append(start*itemsPerGoroutine + j) + } + }(i) + } + + // Concurrent reads + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < itemsPerGoroutine; j++ { + s.Len() // Just read the length + } + }() + } + + wg.Wait() + + // Should have all items + assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len()) + }) +} diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 241522c8989c89bf8eb877c69b9a72f01508c5f4..6f69e7b5332c7bbbb1aee6f1191acda5c7ddcfd8 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -2,12 +2,16 @@ package anim import ( + "fmt" "image/color" "math/rand/v2" "strings" + "sync" "sync/atomic" "time" + "github.com/zeebo/xxh3" + tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/lucasb-eyer/go-colorful" @@ -58,6 +62,29 @@ func nextID() int { return int(atomic.AddInt64(&lastID, 1)) } +// Cache for expensive animation calculations +type animCache struct { + initialFrames [][]string + cyclingFrames [][]string + width int + labelWidth int + label []string + ellipsisFrames []string +} + +var ( + animCacheMutex sync.RWMutex + animCacheMap = make(map[string]*animCache) +) + +// settingsHash creates a hash key for the settings to use for caching +func settingsHash(opts Settings) string { + h := xxh3.New() + fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", + opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) + return fmt.Sprintf("%x", h.Sum(nil)) +} + // StepMsg is a message type used to trigger the next step in the animation. type StepMsg struct{ id int } @@ -109,79 +136,109 @@ func New(opts Settings) (a Anim) { } a.id = nextID() - a.startTime = time.Now() a.cyclingCharWidth = opts.Size - a.labelWidth = lipgloss.Width(opts.Label) a.labelColor = opts.LabelColor - // Total width of anim, in cells. - a.width = opts.Size - if opts.Label != "" { - a.width += labelGapWidth + lipgloss.Width(opts.Label) - } - - // Render the label - a.renderLabel(opts.Label) - - // Pre-generate gradient. - var ramp []color.Color - numFrames := prerenderedFrames - if opts.CycleColors { - ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) - numFrames = a.width * 2 + // Check cache first + cacheKey := settingsHash(opts) + animCacheMutex.RLock() + cached, exists := animCacheMap[cacheKey] + animCacheMutex.RUnlock() + + if exists { + // Use cached values + a.width = cached.width + a.labelWidth = cached.labelWidth + a.label = cached.label + a.ellipsisFrames = cached.ellipsisFrames + a.initialFrames = cached.initialFrames + a.cyclingFrames = cached.cyclingFrames } else { - ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) - } + // Generate new values and cache them + a.labelWidth = lipgloss.Width(opts.Label) - // Pre-render initial characters. - a.initialFrames = make([][]string, numFrames) - offset := 0 - for i := range a.initialFrames { - a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) - for j := range a.initialFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors - } + // Total width of anim, in cells. + a.width = opts.Size + if opts.Label != "" { + a.width += labelGapWidth + lipgloss.Width(opts.Label) + } - var c color.Color - if j <= a.cyclingCharWidth { - c = ramp[j+offset] - } else { - c = opts.LabelColor - } + // Render the label + a.renderLabel(opts.Label) - // Also prerender the initial character with Lip Gloss to avoid - // processing in the render loop. - a.initialFrames[i][j] = lipgloss.NewStyle(). - Foreground(c). - Render(string(initialChar)) - } + // Pre-generate gradient. + var ramp []color.Color + numFrames := prerenderedFrames if opts.CycleColors { - offset++ + ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) + numFrames = a.width * 2 + } else { + ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) } - } - // Prerender scrambled rune frames for the animation. - a.cyclingFrames = make([][]string, numFrames) - offset = 0 - for i := range a.cyclingFrames { - a.cyclingFrames[i] = make([]string, a.width) - for j := range a.cyclingFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors + // Pre-render initial characters. + a.initialFrames = make([][]string, numFrames) + offset := 0 + for i := range a.initialFrames { + a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) + for j := range a.initialFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + var c color.Color + if j <= a.cyclingCharWidth { + c = ramp[j+offset] + } else { + c = opts.LabelColor + } + + // Also prerender the initial character with Lip Gloss to avoid + // processing in the render loop. + a.initialFrames[i][j] = lipgloss.NewStyle(). + Foreground(c). + Render(string(initialChar)) } + if opts.CycleColors { + offset++ + } + } - // Also prerender the color with Lip Gloss here to avoid processing - // in the render loop. - r := availableRunes[rand.IntN(len(availableRunes))] - a.cyclingFrames[i][j] = lipgloss.NewStyle(). - Foreground(ramp[j+offset]). - Render(string(r)) + // Prerender scrambled rune frames for the animation. + a.cyclingFrames = make([][]string, numFrames) + offset = 0 + for i := range a.cyclingFrames { + a.cyclingFrames[i] = make([]string, a.width) + for j := range a.cyclingFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + // Also prerender the color with Lip Gloss here to avoid processing + // in the render loop. + r := availableRunes[rand.IntN(len(availableRunes))] + a.cyclingFrames[i][j] = lipgloss.NewStyle(). + Foreground(ramp[j+offset]). + Render(string(r)) + } + if opts.CycleColors { + offset++ + } } - if opts.CycleColors { - offset++ + + // Cache the results + cached = &animCache{ + initialFrames: a.initialFrames, + cyclingFrames: a.cyclingFrames, + width: a.width, + labelWidth: a.labelWidth, + label: a.label, + ellipsisFrames: a.ellipsisFrames, } + animCacheMutex.Lock() + animCacheMap[cacheKey] = cached + animCacheMutex.Unlock() } // Random assign a birth to each character for a stagged entrance effect. diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 87b6feaedbf83f63977e97439292a5ccf0121fe3..211808b88b1291ed2359dc137e14d1eeea8f2c14 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat/messages" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -42,6 +42,7 @@ type MessageListCmp interface { layout.Help SetSession(session.Session) tea.Cmd + GoToBottom() tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -51,8 +52,8 @@ type messageListCmp struct { app *app.App width, height int session session.Session - listCmp list.ListModel - previousSelected int // Last selected item index for restoring focus + listCmp list.List[list.Item] + previousSelected string // Last selected item index for restoring focus lastUserMessageTime int64 defaultListKeyMap list.KeyMap @@ -63,21 +64,24 @@ type messageListCmp struct { func New(app *app.App) MessageListCmp { defaultListKeyMap := list.DefaultKeyMap() listCmp := list.New( - list.WithGapSize(1), - list.WithReverse(true), + []list.Item{}, + list.WithGap(1), + list.WithDirectionBackward(), + list.WithFocus(false), list.WithKeyMap(defaultListKeyMap), + list.WithEnableMouse(), ) return &messageListCmp{ app: app, listCmp: listCmp, - previousSelected: list.NoSelection, + previousSelected: "", defaultListKeyMap: defaultListKeyMap, } } // Init initializes the component. func (m *messageListCmp) Init() tea.Cmd { - return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur()) + return m.listCmp.Init() } // Update handles incoming messages and updates the component state. @@ -93,15 +97,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case SessionClearedMsg: m.session = session.Session{} - return m, m.listCmp.SetItems([]util.Model{}) + return m, m.listCmp.SetItems([]list.Item{}) case pubsub.Event[message.Message]: cmd := m.handleMessageEvent(msg) return m, cmd + + case tea.MouseWheelMsg: + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.List[list.Item]) + return m, cmd default: var cmds []tea.Cmd u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.ListModel) + m.listCmp = u.(list.List[list.Item]) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } @@ -128,7 +137,7 @@ func (m *messageListCmp) handlePermissionRequest(permission permission.Permissio if permission.Granted { toolCall.SetPermissionGranted() } - m.listCmp.UpdateItem(toolCallIndex, toolCall) + m.listCmp.UpdateItem(toolCall.ID(), toolCall) } return nil } @@ -188,7 +197,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) toolCall.SetNestedToolCalls(nestedToolCalls) m.listCmp.UpdateItem( - toolCallInx, + toolCall.ID(), toolCall, ) return tea.Batch(cmds...) @@ -257,7 +266,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound { toolCall := items[toolCallIndex].(messages.ToolCallCmp) toolCall.SetToolResult(tr) - m.listCmp.UpdateItem(toolCallIndex, toolCall) + m.listCmp.UpdateItem(toolCall.ID(), toolCall) } } return nil @@ -265,7 +274,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { // findToolCallByID searches for a tool call with the specified ID. // Returns the index if found, NotFound otherwise. -func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int { +func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int { // Search backwards as tool calls are more likely to be recent for i := len(items) - 1; i >= 0; i-- { if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID { @@ -298,7 +307,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C } // findAssistantMessageAndToolCalls locates the assistant message and its tool calls. -func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) { +func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) { assistantIndex := NotFound toolCalls := make(map[int]messages.ToolCallCmp) @@ -334,7 +343,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi uiMsg := items[assistantIndex].(messages.MessageCmp) uiMsg.SetMessage(msg) m.listCmp.UpdateItem( - assistantIndex, + items[assistantIndex].ID(), uiMsg, ) if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { @@ -346,7 +355,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi ) } } else if hasToolCallsOnly { - m.listCmp.DeleteItem(assistantIndex) + items := m.listCmp.Items() + m.listCmp.DeleteItem(items[assistantIndex].ID()) } return cmd @@ -373,13 +383,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls // updateOrAddToolCall updates an existing tool call or adds a new one. func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { // Try to find existing tool call - for index, existingTC := range existingToolCalls { + for _, existingTC := range existingToolCalls { if tc.ID == existingTC.GetToolCall().ID { existingTC.SetToolCall(tc) if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { existingTC.SetCancelled() } - m.listCmp.UpdateItem(index, existingTC) + m.listCmp.UpdateItem(tc.ID, existingTC) return nil } } @@ -424,7 +434,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { } if len(sessionMessages) == 0 { - return m.listCmp.SetItems([]util.Model{}) + return m.listCmp.SetItems([]list.Item{}) } // Initialize with first message timestamp @@ -451,8 +461,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri } // convertMessagesToUI converts database messages to UI components. -func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model { - uiMessages := make([]util.Model, 0) +func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item { + uiMessages := make([]list.Item, 0) for _, msg := range sessionMessages { switch msg.Role { @@ -471,8 +481,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, } // convertAssistantMessage converts an assistant message and its tool calls to UI components. -func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model { - var uiMessages []util.Model +func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item { + var uiMessages []list.Item // Add assistant message if it should be displayed if m.shouldShowAssistantMessage(msg) { @@ -553,3 +563,7 @@ func (m *messageListCmp) IsFocused() bool { func (m *messageListCmp) Bindings() []key.Binding { return m.defaultListKeyMap.KeyBindings() } + +func (m *messageListCmp) GoToBottom() tea.Cmd { + return m.listCmp.GoToBottom() +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index d5aca88108cad83115cad5bd046c72e146935f78..9f70691aa9843b8d823b26be247636b31212d2eb 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -11,13 +11,14 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/google/uuid" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -31,6 +32,7 @@ type MessageCmp interface { GetMessage() message.Message // Access to underlying message data SetMessage(msg message.Message) // Update the message content Spinning() bool // Animation state for loading messages + ID() string } // messageCmp implements the MessageCmp interface for displaying chat messages. @@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool { } type AssistantSection interface { - util.Model + list.Item layout.Sizeable - list.SectionHeader } type assistantSectionModel struct { width int + id string message message.Message lastUserMessageTime time.Time } +// ID implements AssistantSection. +func (m *assistantSectionModel) ID() string { + return m.id +} + func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection { return &assistantSectionModel{ width: 0, + id: uuid.NewString(), message: message, lastUserMessageTime: lastUserMessageTime, } @@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd { func (m *assistantSectionModel) IsSectionHeader() bool { return true } + +func (m *messageCmp) ID() string { + return m.message.ID +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 3c4e4bd3595d079ca8394f49f0d6089957a93a12..2f9cab78c5ed5dbb448abad7f3e363a0936e8924 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -32,6 +32,7 @@ type ToolCallCmp interface { SetIsNested(bool) // Set whether this tool call is nested SetPermissionRequested() // Mark permission request SetPermissionGranted() // Mark permission granted + ID() string } // toolCallCmp implements the ToolCallCmp interface for displaying tool calls. @@ -338,3 +339,7 @@ func (m *toolCallCmp) SetPermissionRequested() { func (m *toolCallCmp) SetPermissionGranted() { m.permissionGranted = true } + +func (m *toolCallCmp) ID() string { + return m.call.ID +} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 4c139d7ea4236feed33998d41535a68842778b1e..66f8f697aa4e6c51b199d1fee8667263f4608714 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -14,12 +14,11 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/version" @@ -86,9 +85,7 @@ func New() Splash { listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - t := styles.CurrentTheme() - inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") + modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false) apiKeyInput := models.NewAPIKeyInput() return &splashCmp{ @@ -195,20 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.saveAPIKeyAndContinue(s.apiKeyValue) } if s.isOnboarding && !s.needsAPIKey { - modelInx := s.modelList.SelectedIndex() - if modelInx == -1 { + selectedItem := s.modelList.SelectedModel() + if selectedItem == nil { return s, nil } - items := s.modelList.Items() - selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) if s.isProviderConfigured(string(selectedItem.Provider.ID)) { - cmd := s.setPreferredModel(selectedItem) + cmd := s.setPreferredModel(*selectedItem) s.isOnboarding = false return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) } else { // Provider not configured, show API key input s.needsAPIKey = true - s.selectedModel = &selectedItem + s.selectedModel = selectedItem s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) return s, nil } @@ -267,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } case key.Matches(msg, s.keyMap.Yes): + if s.isOnboarding { + return s, nil + } if s.needsAPIKey { u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -277,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.initializeProject() } case key.Matches(msg, s.keyMap.No): + if s.isOnboarding { + return s, nil + } if s.needsAPIKey { u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -606,7 +607,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { cursor.Y += offset cursor.X = cursor.X + 1 } else if s.isOnboarding { - offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3 + offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2 cursor.Y += offset cursor.X = cursor.X + 1 } diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 6c63afd22e982e5ba40f5d175fc71449bcd0879e..0d5b814952dcdb8b6fdabc2f9e6aa8873936babc 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -5,7 +5,7 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -50,6 +50,8 @@ type Completions interface { Height() int } +type listModel = list.FilterableList[list.CompletionItem[any]] + type completionsCmp struct { width int height int // Height of the completions component` @@ -58,7 +60,7 @@ type completionsCmp struct { open bool // Indicates if the completions are open keyMap KeyMap - list list.ListModel + list listModel query string // The current filter query } @@ -76,10 +78,13 @@ func New() Completions { keyMap.UpOneItem = completionsKeyMap.Up keyMap.DownOneItem = completionsKeyMap.Down - l := list.New( - list.WithReverse(true), - list.WithKeyMap(keyMap), - list.WithHideFilterInput(true), + l := list.NewFilterableList( + []list.CompletionItem[any]{}, + list.WithFilterInputHidden(), + list.WithFilterListOptions( + list.WithDirectionBackward(), + list.WithKeyMap(keyMap), + ), ) return &completionsCmp{ width: 0, @@ -109,44 +114,41 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, c.keyMap.Up): u, cmd := c.list.Update(msg) - c.list = u.(list.ListModel) + c.list = u.(listModel) return c, cmd case key.Matches(msg, c.keyMap.Down): d, cmd := c.list.Update(msg) - c.list = d.(list.ListModel) + c.list = d.(listModel) return c, cmd case key.Matches(msg, c.keyMap.UpInsert): - selectedItemInx := c.list.SelectedIndex() - 1 - items := c.list.Items() - if selectedItemInx == list.NoSelection || selectedItemInx < 0 { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - selectedItem := items[selectedItemInx].(CompletionItem).Value() - c.list.SetSelected(selectedItemInx) + selectedItem := *s + c.list.SetSelected(selectedItem.ID()) return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, Insert: true, }) case key.Matches(msg, c.keyMap.DownInsert): - selectedItemInx := c.list.SelectedIndex() + 1 - items := c.list.Items() - if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - selectedItem := items[selectedItemInx].(CompletionItem).Value() - c.list.SetSelected(selectedItemInx) + selectedItem := *s + c.list.SetSelected(selectedItem.ID()) return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, Insert: true, }) case key.Matches(msg, c.keyMap.Select): - selectedItemInx := c.list.SelectedIndex() - if selectedItemInx == list.NoSelection { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - items := c.list.Items() - selectedItem := items[selectedItemInx].(CompletionItem).Value() + selectedItem := *s c.open = false // Close completions after selection return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, @@ -162,10 +164,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.query = "" c.x = msg.X c.y = msg.Y - items := []util.Model{} + items := []list.CompletionItem[any]{} t := styles.CurrentTheme() for _, completion := range msg.Completions { - item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) + item := list.NewCompletionItem( + completion.Title, + completion.Value, + list.WithCompletionBackgroundColor(t.BgSubtle), + ) items = append(items, item) } c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go deleted file mode 100644 index 3f99eda5d979e72f0497a120e056df10aca228c3..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/list/list.go +++ /dev/null @@ -1,1371 +0,0 @@ -package list - -import ( - "slices" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" - "github.com/sahilm/fuzzy" -) - -// Constants for special index values and defaults -const ( - NoSelection = -1 // Indicates no item is currently selected - NotRendered = -1 // Indicates an item hasn't been rendered yet - NoFinalHeight = -1 // Indicates final height hasn't been calculated - DefaultGapSize = 0 // Default spacing between list items -) - -// ListModel defines the interface for a scrollable, selectable list component. -// It combines the basic Model interface with sizing capabilities and list-specific operations. -type ListModel interface { - util.Model - layout.Sizeable - layout.Focusable - SetItems([]util.Model) tea.Cmd // Replace all items in the list - AppendItem(util.Model) tea.Cmd // Add an item to the end of the list - PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list - DeleteItem(int) // Remove an item at the specified index - UpdateItem(int, util.Model) // Replace an item at the specified index - ResetView() // Clear rendering cache and reset scroll position - Items() []util.Model // Get all items in the list - SelectedIndex() int // Get the index of the currently selected item - SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it - Filter(string) tea.Cmd // Filter items based on a search term - SetFilterPlaceholder(string) // Set the placeholder text for the filter input - Cursor() *tea.Cursor // Get the current cursor position in the filter input -} - -// HasAnim interface identifies items that support animation. -// Items implementing this interface will receive animation update messages. -type HasAnim interface { - util.Model - Spinning() bool // Returns true if the item is currently animating -} - -// HasFilterValue interface allows items to provide a filter value for searching. -type HasFilterValue interface { - FilterValue() string // Returns a string value used for filtering/searching -} - -// HasMatchIndexes interface allows items to set matched character indexes. -type HasMatchIndexes interface { - MatchIndexes([]int) // Sets the indexes of matched characters in the item's content -} - -// SectionHeader interface identifies items that are section headers. -// Section headers are rendered differently and are skipped during navigation. -type SectionHeader interface { - util.Model - IsSectionHeader() bool // Returns true if this item is a section header -} - -// renderedItem represents a cached rendered item with its position and content. -type renderedItem struct { - lines []string // The rendered lines of text for this item - start int // Starting line position in the overall rendered content - height int // Number of lines this item occupies -} - -// renderState manages the rendering cache and state for the list. -// It tracks which items have been rendered and their positions. -type renderState struct { - items map[int]renderedItem // Cache of rendered items by index - lines []string // All rendered lines concatenated - lastIndex int // Index of the last rendered item - finalHeight int // Total height when all items are rendered - needsRerender bool // Flag indicating if re-rendering is needed -} - -// newRenderState creates a new render state with default values. -func newRenderState() *renderState { - return &renderState{ - items: make(map[int]renderedItem), - lines: []string{}, - lastIndex: NotRendered, - finalHeight: NoFinalHeight, - needsRerender: true, - } -} - -// reset clears all cached rendering data and resets state to initial values. -func (rs *renderState) reset() { - rs.items = make(map[int]renderedItem) - rs.lines = []string{} - rs.lastIndex = NotRendered - rs.finalHeight = NoFinalHeight - rs.needsRerender = true -} - -// viewState manages the visual display properties of the list. -type viewState struct { - width, height int // Dimensions of the list viewport - offset int // Current scroll offset in lines - reverse bool // Whether to render in reverse order (bottom-up) - content string // The final rendered content to display -} - -// selectionState manages which item is currently selected. -type selectionState struct { - selectedIndex int // Index of the currently selected item, or NoSelection -} - -// isValidIndex checks if the selected index is within the valid range of items. -func (ss *selectionState) isValidIndex(itemCount int) bool { - return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount -} - -// model is the main implementation of the ListModel interface. -// It coordinates between view state, render state, and selection state. -type model struct { - viewState viewState // Display and scrolling state - renderState *renderState // Rendering cache and state - selectionState selectionState // Item selection state - help help.Model // Help system for keyboard shortcuts - keyMap KeyMap // Key bindings for navigation - allItems []util.Model // The actual list items - gapSize int // Number of empty lines between items - padding []int // Padding around the list content - wrapNavigation bool // Whether to wrap navigation at the ends - - filterable bool // Whether items can be filtered - filterPlaceholder string // Placeholder text for filter input - filteredItems []util.Model // Filtered items based on current search - input textinput.Model // Input field for filtering items - inputStyle lipgloss.Style // Style for the input field - hideFilterInput bool // Whether to hide the filter input field - currentSearch string // Current search term for filtering - - isFocused bool // Whether the list is currently focused -} - -// listOptions is a function type for configuring list options. -type listOptions func(*model) - -// WithKeyMap sets custom key bindings for the list. -func WithKeyMap(k KeyMap) listOptions { - return func(m *model) { - m.keyMap = k - } -} - -// WithReverse sets whether the list should render in reverse order (newest items at bottom). -func WithReverse(reverse bool) listOptions { - return func(m *model) { - m.setReverse(reverse) - } -} - -// WithGapSize sets the number of empty lines to insert between list items. -func WithGapSize(gapSize int) listOptions { - return func(m *model) { - m.gapSize = gapSize - } -} - -// WithPadding sets the padding around the list content. -// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal, -// 4 values = top/right/bottom/left. -func WithPadding(padding ...int) listOptions { - return func(m *model) { - m.padding = padding - } -} - -// WithItems sets the initial items for the list. -func WithItems(items []util.Model) listOptions { - return func(m *model) { - m.allItems = items - m.filteredItems = items // Initially, all items are visible - } -} - -// WithFilterable enables filtering of items based on their FilterValue. -func WithFilterable(filterable bool) listOptions { - return func(m *model) { - m.filterable = filterable - } -} - -// WithHideFilterInput hides the filter input field. -func WithHideFilterInput(hide bool) listOptions { - return func(m *model) { - m.hideFilterInput = hide - } -} - -// WithFilterPlaceholder sets the placeholder text for the filter input field. -func WithFilterPlaceholder(placeholder string) listOptions { - return func(m *model) { - m.filterPlaceholder = placeholder - } -} - -// WithInputStyle sets the style for the filter input field. -func WithInputStyle(style lipgloss.Style) listOptions { - return func(m *model) { - m.inputStyle = style - } -} - -// WithWrapNavigation enables wrapping navigation at the ends of the list. -func WithWrapNavigation(wrap bool) listOptions { - return func(m *model) { - m.wrapNavigation = wrap - } -} - -// New creates a new list model with the specified options. -// The list starts with no items selected and requires SetItems to be called -// or items to be provided via WithItems option. -func New(opts ...listOptions) ListModel { - t := styles.CurrentTheme() - - m := &model{ - help: help.New(), - keyMap: DefaultKeyMap(), - allItems: []util.Model{}, - filteredItems: []util.Model{}, - renderState: newRenderState(), - gapSize: DefaultGapSize, - padding: []int{}, - selectionState: selectionState{selectedIndex: NoSelection}, - filterPlaceholder: "Type to filter...", - inputStyle: t.S().Base.Padding(0, 1, 1, 1), - isFocused: true, - } - for _, opt := range opts { - opt(m) - } - - if m.filterable && !m.hideFilterInput { - t := styles.CurrentTheme() - ti := textinput.New() - ti.Placeholder = m.filterPlaceholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - m.input = ti - } - return m -} - -// Init initializes the list component and sets up the initial items. -// This is called automatically by the Bubble Tea framework. -func (m *model) Init() tea.Cmd { - return m.SetItems(m.filteredItems) -} - -// Update handles incoming messages and updates the list state accordingly. -// It processes keyboard input, animation messages, and forwards other messages -// to the currently selected item. -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - return m.handleKeyPress(msg) - case anim.StepMsg: - return m.handleAnimationMsg(msg) - } - if m.selectionState.isValidIndex(len(m.filteredItems)) { - return m.updateSelectedItem(msg) - } - - return m, nil -} - -// Cursor returns the current cursor position in the input field. -func (m *model) Cursor() *tea.Cursor { - if m.filterable && !m.hideFilterInput { - return m.input.Cursor() - } - return nil -} - -// View renders the list to a string for display. -// Returns empty string if the list has no dimensions. -// Triggers re-rendering if needed before returning content. -func (m *model) View() string { - if m.viewState.height == 0 || m.viewState.width == 0 { - return "" // No content to display - } - if m.renderState.needsRerender { - m.renderVisible() - } - - content := lipgloss.NewStyle(). - Padding(m.padding...). - Height(m.viewState.height). - Render(m.viewState.content) - - if m.filterable && !m.hideFilterInput { - content = lipgloss.JoinVertical( - lipgloss.Left, - m.inputStyle.Render(m.input.View()), - content, - ) - } - return content -} - -// handleKeyPress processes keyboard input for list navigation. -// Supports scrolling, item selection, and navigation to top/bottom. -func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(msg, m.keyMap.Down): - m.scrollDown(1) - case key.Matches(msg, m.keyMap.Up): - m.scrollUp(1) - case key.Matches(msg, m.keyMap.DownOneItem): - return m, m.selectNextItem() - case key.Matches(msg, m.keyMap.UpOneItem): - return m, m.selectPreviousItem() - case key.Matches(msg, m.keyMap.HalfPageDown): - m.scrollDown(m.listHeight() / 2) - case key.Matches(msg, m.keyMap.HalfPageUp): - m.scrollUp(m.listHeight() / 2) - case key.Matches(msg, m.keyMap.PageDown): - m.scrollDown(m.listHeight()) - case key.Matches(msg, m.keyMap.PageUp): - m.scrollUp(m.listHeight()) - case key.Matches(msg, m.keyMap.Home): - return m, m.goToTop() - case key.Matches(msg, m.keyMap.End): - return m, m.goToBottom() - default: - if !m.filterable || m.hideFilterInput { - return m, nil // Ignore other keys if not filterable or input is hidden - } - var cmds []tea.Cmd - u, cmd := m.input.Update(msg) - m.input = u - cmds = append(cmds, cmd) - if m.currentSearch != m.input.Value() { - cmd = m.Filter(m.input.Value()) - cmds = append(cmds, cmd) - } - m.currentSearch = m.input.Value() - return m, tea.Batch(cmds...) - } - return m, nil -} - -// handleAnimationMsg forwards animation messages to items that support animation. -// Only items implementing HasAnim and currently spinning receive these messages. -func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - for inx, item := range m.filteredItems { - if i, ok := item.(HasAnim); ok && i.Spinning() { - updated, cmd := i.Update(msg) - cmds = append(cmds, cmd) - if u, ok := updated.(util.Model); ok { - m.UpdateItem(inx, u) - } - } - } - return m, tea.Batch(cmds...) -} - -// updateSelectedItem forwards messages to the currently selected item. -// This allows the selected item to handle its own input and state changes. -func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg) - cmds = append(cmds, cmd) - if updated, ok := u.(util.Model); ok { - m.UpdateItem(m.selectionState.selectedIndex, updated) - } - return m, tea.Batch(cmds...) -} - -// scrollDown scrolls the list down by the specified amount. -// Direction is automatically adjusted based on reverse mode. -func (m *model) scrollDown(amount int) { - if m.viewState.reverse { - m.decreaseOffset(amount) - } else { - m.increaseOffset(amount) - } -} - -// scrollUp scrolls the list up by the specified amount. -// Direction is automatically adjusted based on reverse mode. -func (m *model) scrollUp(amount int) { - if m.viewState.reverse { - m.increaseOffset(amount) - } else { - m.decreaseOffset(amount) - } -} - -// Items returns a copy of all items in the list. -func (m *model) Items() []util.Model { - return m.filteredItems -} - -// renderVisible determines which rendering strategy to use and triggers rendering. -// Uses forward rendering for normal mode and reverse rendering for reverse mode. -func (m *model) renderVisible() { - if m.viewState.reverse { - m.renderVisibleReverse() - } else { - m.renderVisibleForward() - } -} - -// renderVisibleForward renders items from top to bottom (normal mode). -// Only renders items that are currently visible or near the viewport. -func (m *model) renderVisibleForward() { - renderer := &forwardRenderer{ - model: m, - start: 0, - cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd - items: m.filteredItems, - realIdx: m.renderState.lastIndex, - } - - if m.renderState.lastIndex > NotRendered { - renderer.items = m.filteredItems[m.renderState.lastIndex+1:] - renderer.start = len(m.renderState.lines) - } - - renderer.render() - m.finalizeRender() -} - -// renderVisibleReverse renders items from bottom to top (reverse mode). -// Used when new items should appear at the bottom (like chat messages). -func (m *model) renderVisibleReverse() { - renderer := &reverseRenderer{ - model: m, - start: 0, - cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, - items: m.filteredItems, - realIdx: m.renderState.lastIndex, - } - - if m.renderState.lastIndex > NotRendered { - renderer.items = m.filteredItems[:m.renderState.lastIndex] - renderer.start = len(m.renderState.lines) - } else { - m.renderState.lastIndex = len(m.filteredItems) - renderer.realIdx = len(m.filteredItems) - } - - renderer.render() - m.finalizeRender() -} - -// finalizeRender completes the rendering process by updating scroll bounds and content. -func (m *model) finalizeRender() { - m.renderState.needsRerender = false - if m.renderState.finalHeight > NoFinalHeight { - m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight) - } - m.updateContent() -} - -// updateContent extracts the visible portion of rendered content for display. -// Handles both normal and reverse rendering modes. -func (m *model) updateContent() { - maxHeight := min(m.listHeight(), len(m.renderState.lines)) - if m.viewState.offset >= len(m.renderState.lines) { - m.viewState.content = "" - return - } - - if m.viewState.reverse { - end := len(m.renderState.lines) - m.viewState.offset - start := max(0, end-maxHeight) - m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n") - } else { - endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines)) - m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n") - } -} - -// forwardRenderer handles rendering items from top to bottom. -// It builds up the rendered content incrementally, caching results for performance. -type forwardRenderer struct { - model *model // Reference to the parent list model - start int // Current line position in the overall content - cutoff int // Line position where we can stop rendering - items []util.Model // Items to render (may be a subset) - realIdx int // Real index in the full item list -} - -// render processes items in forward order, building up the rendered content. -func (r *forwardRenderer) render() { - for _, item := range r.items { - r.realIdx++ - if r.start > r.cutoff { - break - } - - itemLines := r.getOrRenderItem(item) - if r.realIdx == len(r.model.filteredItems)-1 { - r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) - } - - r.model.renderState.lines = append(r.model.renderState.lines, itemLines...) - r.model.renderState.lastIndex = r.realIdx - r.start += len(itemLines) - } -} - -// getOrRenderItem retrieves cached content or renders the item if not cached. -func (r *forwardRenderer) getOrRenderItem(item util.Model) []string { - if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { - return cachedContent.lines - } - - itemLines := r.renderItemLines(item) - r.model.renderState.items[r.realIdx] = renderedItem{ - lines: itemLines, - start: r.start, - height: len(itemLines), - } - return itemLines -} - -// renderItemLines converts an item to its string representation with gaps. -func (r *forwardRenderer) renderItemLines(item util.Model) []string { - return r.model.getItemLines(item) -} - -// reverseRenderer handles rendering items from bottom to top. -// Used in reverse mode where new items appear at the bottom. -type reverseRenderer struct { - model *model // Reference to the parent list model - start int // Current line position in the overall content - cutoff int // Line position where we can stop rendering - items []util.Model // Items to render (may be a subset) - realIdx int // Real index in the full item list -} - -// render processes items in reverse order, prepending to the rendered content. -func (r *reverseRenderer) render() { - for i := len(r.items) - 1; i >= 0; i-- { - r.realIdx-- - if r.start > r.cutoff { - break - } - - itemLines := r.getOrRenderItem(r.items[i]) - if r.realIdx == 0 { - r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) - } - - r.model.renderState.lines = append(itemLines, r.model.renderState.lines...) - r.model.renderState.lastIndex = r.realIdx - r.start += len(itemLines) - } -} - -// getOrRenderItem retrieves cached content or renders the item if not cached. -func (r *reverseRenderer) getOrRenderItem(item util.Model) []string { - if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { - return cachedContent.lines - } - - itemLines := r.renderItemLines(item) - r.model.renderState.items[r.realIdx] = renderedItem{ - lines: itemLines, - start: r.start, - height: len(itemLines), - } - return itemLines -} - -// renderItemLines converts an item to its string representation with gaps. -func (r *reverseRenderer) renderItemLines(item util.Model) []string { - return r.model.getItemLines(item) -} - -// selectPreviousItem moves selection to the previous item in the list. -// Handles focus management and ensures the selected item remains visible. -// Skips section headers during navigation. -func (m *model) selectPreviousItem() tea.Cmd { - if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation { - // If at the beginning and wrapping is enabled, go to the last item - return m.goToBottom() - } - if m.selectionState.selectedIndex <= 0 { - return nil - } - - cmds := []tea.Cmd{m.blurSelected()} - m.selectionState.selectedIndex-- - - // Skip section headers - for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) { - m.selectionState.selectedIndex-- - } - - // If we went past the beginning, stay at the first non-header item - if m.selectionState.selectedIndex <= 0 { - cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed - return tea.Batch(cmds...) - } - - cmds = append(cmds, m.focusSelected()) - m.ensureSelectedItemVisible() - return tea.Batch(cmds...) -} - -// selectNextItem moves selection to the next item in the list. -// Handles focus management and ensures the selected item remains visible. -// Skips section headers during navigation. -func (m *model) selectNextItem() tea.Cmd { - if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation { - // If at the end and wrapping is enabled, go to the first item - return m.goToTop() - } - if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 { - return nil - } - - cmds := []tea.Cmd{m.blurSelected()} - m.selectionState.selectedIndex++ - - // Skip section headers - for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) { - m.selectionState.selectedIndex++ - } - - // If we went past the end, stay at the last non-header item - if m.selectionState.selectedIndex >= len(m.filteredItems) { - m.selectionState.selectedIndex = m.findLastSelectableItem() - } - - cmds = append(cmds, m.focusSelected()) - m.ensureSelectedItemVisible() - return tea.Batch(cmds...) -} - -// isSectionHeader checks if the item at the given index is a section header. -func (m *model) isSectionHeader(index int) bool { - if index < 0 || index >= len(m.filteredItems) { - return false - } - if header, ok := m.filteredItems[index].(SectionHeader); ok { - return header.IsSectionHeader() - } - return false -} - -// findFirstSelectableItem finds the first item that is not a section header. -func (m *model) findFirstSelectableItem() int { - for i := range m.filteredItems { - if !m.isSectionHeader(i) { - return i - } - } - return NoSelection -} - -// findLastSelectableItem finds the last item that is not a section header. -func (m *model) findLastSelectableItem() int { - for i := len(m.filteredItems) - 1; i >= 0; i-- { - if !m.isSectionHeader(i) { - return i - } - } - return NoSelection -} - -// ensureSelectedItemVisible scrolls the list to make the selected item visible. -// Uses different strategies for forward and reverse rendering modes. -func (m *model) ensureSelectedItemVisible() { - cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex] - if !ok { - m.renderState.needsRerender = true - return - } - - if m.viewState.reverse { - m.ensureVisibleReverse(cachedItem) - } else { - m.ensureVisibleForward(cachedItem) - } - m.renderState.needsRerender = true -} - -// ensureVisibleForward ensures the selected item is visible in forward rendering mode. -// Handles both large items (taller than viewport) and normal items. -func (m *model) ensureVisibleForward(cachedItem renderedItem) { - if cachedItem.height >= m.listHeight() { - if m.selectionState.selectedIndex > 0 { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } else { - changeNeeded := cachedItem.start - m.viewState.offset - m.increaseOffset(changeNeeded) - } - return - } - - if cachedItem.start < m.viewState.offset { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } else { - end := cachedItem.start + cachedItem.height - if end > m.viewState.offset+m.listHeight() { - changeNeeded := end - (m.viewState.offset + m.listHeight()) - m.increaseOffset(changeNeeded) - } - } -} - -// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode. -// Handles both large items (taller than viewport) and normal items. -func (m *model) ensureVisibleReverse(cachedItem renderedItem) { - if cachedItem.height >= m.listHeight() { - if m.selectionState.selectedIndex < len(m.filteredItems)-1 { - changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight()) - m.decreaseOffset(changeNeeded) - } else { - changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset - m.increaseOffset(changeNeeded) - } - return - } - - if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() { - changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset - m.increaseOffset(changeNeeded) - } else if cachedItem.start < m.viewState.offset { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } -} - -// goToBottom switches to reverse mode and selects the last selectable item. -// Commonly used for chat-like interfaces where new content appears at the bottom. -// Skips section headers when selecting the last item. -func (m *model) goToBottom() tea.Cmd { - cmds := []tea.Cmd{m.blurSelected()} - m.viewState.reverse = true - m.selectionState.selectedIndex = m.findLastSelectableItem() - if m.isFocused { - cmds = append(cmds, m.focusSelected()) - } - m.ResetView() - return tea.Batch(cmds...) -} - -// goToTop switches to forward mode and selects the first selectable item. -// Standard behavior for most list interfaces. -// Skips section headers when selecting the first item. -func (m *model) goToTop() tea.Cmd { - cmds := []tea.Cmd{m.blurSelected()} - m.viewState.reverse = false - m.selectionState.selectedIndex = m.findFirstSelectableItem() - if m.isFocused { - cmds = append(cmds, m.focusSelected()) - } - m.ResetView() - return tea.Batch(cmds...) -} - -// ResetView clears all cached rendering data and resets scroll position. -// Forces a complete re-render on the next View() call. -func (m *model) ResetView() { - m.renderState.reset() - m.viewState.offset = 0 -} - -// focusSelected gives focus to the currently selected item if it supports focus. -// Triggers a re-render of the item to show its focused state. -func (m *model) focusSelected() tea.Cmd { - if !m.isFocused { - return nil // No focus change if the list is not focused - } - if !m.selectionState.isValidIndex(len(m.filteredItems)) { - return nil - } - if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { - cmd := i.Focus() - m.rerenderItem(m.selectionState.selectedIndex) - return cmd - } - return nil -} - -// blurSelected removes focus from the currently selected item if it supports focus. -// Triggers a re-render of the item to show its unfocused state. -func (m *model) blurSelected() tea.Cmd { - if !m.selectionState.isValidIndex(len(m.filteredItems)) { - return nil - } - if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { - cmd := i.Blur() - m.rerenderItem(m.selectionState.selectedIndex) - return cmd - } - return nil -} - -// rerenderItem updates the cached rendering of a specific item. -// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed. -// It efficiently updates only the changed item and adjusts positions of subsequent items if needed. -func (m *model) rerenderItem(inx int) { - if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 { - return - } - - cachedItem, ok := m.renderState.items[inx] - if !ok { - return - } - - rerenderedLines := m.getItemLines(m.filteredItems[inx]) - if slices.Equal(cachedItem.lines, rerenderedLines) { - return - } - - m.updateRenderedLines(cachedItem, rerenderedLines) - m.updateItemPositions(inx, cachedItem, len(rerenderedLines)) - m.updateCachedItem(inx, cachedItem, rerenderedLines) - m.renderState.needsRerender = true -} - -// getItemLines converts an item to its rendered lines, including any gap spacing. -// Handles section headers with special styling. -func (m *model) getItemLines(item util.Model) []string { - var itemLines []string - - itemLines = strings.Split(item.View(), "\n") - - if m.gapSize > 0 { - gap := make([]string, m.gapSize) - itemLines = append(itemLines, gap...) - } - return itemLines -} - -// updateRenderedLines replaces the lines for a specific item in the overall rendered content. -func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) { - start, end := m.getItemBounds(cachedItem) - totalLines := len(m.renderState.lines) - - if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines { - m.renderState.lines = slices.Delete(m.renderState.lines, start, end) - m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...) - } -} - -// getItemBounds calculates the start and end line positions for an item. -// Handles both forward and reverse rendering modes. -func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) { - start = cachedItem.start - end = start + cachedItem.height - - if m.viewState.reverse { - totalLines := len(m.renderState.lines) - end = totalLines - cachedItem.start - start = end - cachedItem.height - } - return start, end -} - -// updateItemPositions recalculates positions for items after the changed item. -// This is necessary when an item's height changes, affecting subsequent items. -func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) { - if cachedItem.height == newHeight { - return - } - - if inx == len(m.filteredItems)-1 { - m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight()) - } - - currentStart := cachedItem.start + newHeight - if m.viewState.reverse { - m.updatePositionsReverse(inx, currentStart) - } else { - m.updatePositionsForward(inx, currentStart) - } -} - -// updatePositionsForward updates positions for items after the changed item in forward mode. -func (m *model) updatePositionsForward(inx int, currentStart int) { - for i := inx + 1; i < len(m.filteredItems); i++ { - if existing, ok := m.renderState.items[i]; ok { - existing.start = currentStart - currentStart += existing.height - m.renderState.items[i] = existing - } else { - break - } - } -} - -// updatePositionsReverse updates positions for items before the changed item in reverse mode. -func (m *model) updatePositionsReverse(inx int, currentStart int) { - for i := inx - 1; i >= 0; i-- { - if existing, ok := m.renderState.items[i]; ok { - existing.start = currentStart - currentStart += existing.height - m.renderState.items[i] = existing - } else { - break - } - } -} - -// updateCachedItem updates the cached rendering information for a specific item. -func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) { - m.renderState.items[inx] = renderedItem{ - lines: newLines, - start: cachedItem.start, - height: len(newLines), - } -} - -// increaseOffset scrolls the list down by increasing the offset. -// Respects the final height limit to prevent scrolling past the end. -func (m *model) increaseOffset(n int) { - if m.renderState.finalHeight > NoFinalHeight { - if m.viewState.offset < m.renderState.finalHeight { - m.viewState.offset += n - if m.viewState.offset > m.renderState.finalHeight { - m.viewState.offset = m.renderState.finalHeight - } - m.renderState.needsRerender = true - } - } else { - m.viewState.offset += n - m.renderState.needsRerender = true - } -} - -// decreaseOffset scrolls the list up by decreasing the offset. -// Prevents scrolling above the beginning of the list. -func (m *model) decreaseOffset(n int) { - if m.viewState.offset > 0 { - m.viewState.offset -= n - if m.viewState.offset < 0 { - m.viewState.offset = 0 - } - m.renderState.needsRerender = true - } -} - -// UpdateItem replaces an item at the specified index with a new item. -// Handles focus management and triggers re-rendering as needed. -func (m *model) UpdateItem(inx int, item util.Model) { - if inx < 0 || inx >= len(m.filteredItems) { - return - } - m.filteredItems[inx] = item - if m.selectionState.selectedIndex == inx { - m.focusSelected() - } - m.setItemSize(inx) - m.rerenderItem(inx) - m.renderState.needsRerender = true -} - -// GetSize returns the current dimensions of the list. -func (m *model) GetSize() (int, int) { - return m.viewState.width, m.viewState.height -} - -// SetSize updates the list dimensions and triggers a complete re-render. -// Also updates the size of all items that support sizing. -func (m *model) SetSize(width int, height int) tea.Cmd { - if m.filterable && !m.hideFilterInput { - height -= 2 // adjust for input field height and border - } - - if m.viewState.width == width && m.viewState.height == height { - return nil - } - if m.viewState.height != height { - m.renderState.finalHeight = NoFinalHeight - m.viewState.height = height - } - m.viewState.width = width - m.ResetView() - if m.filterable && !m.hideFilterInput { - m.input.SetWidth(m.getItemWidth() - 5) - } - return m.setAllItemsSize() -} - -// getItemWidth calculates the available width for items, accounting for padding. -func (m *model) getItemWidth() int { - width := m.viewState.width - switch len(m.padding) { - case 1: - width -= m.padding[0] * 2 - case 2, 3: - width -= m.padding[1] * 2 - case 4: - width -= m.padding[1] + m.padding[3] - } - return max(0, width) -} - -// setItemSize updates the size of a specific item if it supports sizing. -func (m *model) setItemSize(inx int) tea.Cmd { - if inx < 0 || inx >= len(m.filteredItems) { - return nil - } - if i, ok := m.filteredItems[inx].(layout.Sizeable); ok { - return i.SetSize(m.getItemWidth(), 0) - } - return nil -} - -// setAllItemsSize updates the size of all items that support sizing. -func (m *model) setAllItemsSize() tea.Cmd { - var cmds []tea.Cmd - for i := range m.filteredItems { - if cmd := m.setItemSize(i); cmd != nil { - cmds = append(cmds, cmd) - } - } - return tea.Batch(cmds...) -} - -// listHeight calculates the available height for list content, accounting for padding. -func (m *model) listHeight() int { - height := m.viewState.height - switch len(m.padding) { - case 1: - height -= m.padding[0] * 2 - case 2: - height -= m.padding[0] * 2 - case 3, 4: - height -= m.padding[0] + m.padding[2] - } - if m.filterable && !m.hideFilterInput { - height -= lipgloss.Height(m.inputStyle.Render("dummy")) - } - return max(0, height) -} - -// AppendItem adds a new item to the end of the list. -// Automatically switches to reverse mode and scrolls to show the new item. -func (m *model) AppendItem(item util.Model) tea.Cmd { - cmds := []tea.Cmd{ - item.Init(), - } - m.allItems = append(m.allItems, item) - m.filteredItems = m.allItems - cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1)) - cmds = append(cmds, m.goToBottom()) - m.renderState.needsRerender = true - return tea.Batch(cmds...) -} - -// DeleteItem removes an item at the specified index. -// Adjusts selection if necessary and triggers a complete re-render. -func (m *model) DeleteItem(i int) { - if i < 0 || i >= len(m.filteredItems) { - return - } - m.allItems = slices.Delete(m.allItems, i, i+1) - delete(m.renderState.items, i) - m.filteredItems = m.allItems - - if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 { - m.selectionState.selectedIndex-- - } else if m.selectionState.selectedIndex > i { - m.selectionState.selectedIndex-- - } - - m.ResetView() - m.renderState.needsRerender = true -} - -// PrependItem adds a new item to the beginning of the list. -// Adjusts cached positions and selection index, then switches to forward mode. -func (m *model) PrependItem(item util.Model) tea.Cmd { - cmds := []tea.Cmd{item.Init()} - m.allItems = append([]util.Model{item}, m.allItems...) - m.filteredItems = m.allItems - - // Shift all cached item indices by 1 - newItems := make(map[int]renderedItem, len(m.renderState.items)) - for k, v := range m.renderState.items { - newItems[k+1] = v - } - m.renderState.items = newItems - - if m.selectionState.selectedIndex >= 0 { - m.selectionState.selectedIndex++ - } - - cmds = append(cmds, m.goToTop()) - cmds = append(cmds, m.setItemSize(0)) - m.renderState.needsRerender = true - return tea.Batch(cmds...) -} - -// setReverse switches between forward and reverse rendering modes. -func (m *model) setReverse(reverse bool) { - if reverse { - m.goToBottom() - } else { - m.goToTop() - } -} - -// SetItems replaces all items in the list with a new set. -// Initializes all items, sets their sizes, and establishes initial selection. -// Ensures the initial selection skips section headers. -func (m *model) SetItems(items []util.Model) tea.Cmd { - m.allItems = items - m.filteredItems = items - cmds := []tea.Cmd{m.setAllItemsSize()} - - for _, item := range m.filteredItems { - cmds = append(cmds, item.Init()) - } - - if len(m.filteredItems) > 0 { - if m.viewState.reverse { - m.selectionState.selectedIndex = m.findLastSelectableItem() - } else { - m.selectionState.selectedIndex = m.findFirstSelectableItem() - } - if cmd := m.focusSelected(); cmd != nil { - cmds = append(cmds, cmd) - } - } else { - m.selectionState.selectedIndex = NoSelection - } - - m.ResetView() - return tea.Batch(cmds...) -} - -// section represents a group of items under a section header. -type section struct { - header SectionHeader - items []util.Model -} - -// parseSections parses the flat item list into sections. -func (m *model) parseSections() []section { - var sections []section - var currentSection *section - - for _, item := range m.allItems { - if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() { - // Start a new section - if currentSection != nil { - sections = append(sections, *currentSection) - } - currentSection = §ion{ - header: header, - items: []util.Model{}, - } - } else if currentSection != nil { - // Add item to current section - currentSection.items = append(currentSection.items, item) - } else { - // Item without a section header - create an implicit section - if len(sections) == 0 || sections[len(sections)-1].header != nil { - sections = append(sections, section{ - header: nil, - items: []util.Model{item}, - }) - } else { - // Add to the last implicit section - sections[len(sections)-1].items = append(sections[len(sections)-1].items, item) - } - } - } - - // Don't forget the last section - if currentSection != nil { - sections = append(sections, *currentSection) - } - - return sections -} - -// flattenSections converts sections back to a flat list. -func (m *model) flattenSections(sections []section) []util.Model { - var result []util.Model - - for _, sect := range sections { - if sect.header != nil { - result = append(result, sect.header) - } - result = append(result, sect.items...) - } - - return result -} - -func (m *model) Filter(search string) tea.Cmd { - var cmds []tea.Cmd - search = strings.TrimSpace(search) - search = strings.ToLower(search) - - // Clear focus and match indexes from all items - for _, item := range m.allItems { - if i, ok := item.(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := item.(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - - if search == "" { - cmds = append(cmds, m.SetItems(m.allItems)) - return tea.Batch(cmds...) - } - - // Parse items into sections - sections := m.parseSections() - var filteredSections []section - - for _, sect := range sections { - filteredSection := m.filterSection(sect, search) - if filteredSection != nil { - filteredSections = append(filteredSections, *filteredSection) - } - } - - // Rebuild flat list from filtered sections - m.filteredItems = m.flattenSections(filteredSections) - - // Set initial selection - if len(m.filteredItems) > 0 { - if m.viewState.reverse { - slices.Reverse(m.filteredItems) - m.selectionState.selectedIndex = m.findLastSelectableItem() - } else { - m.selectionState.selectedIndex = m.findFirstSelectableItem() - } - if cmd := m.focusSelected(); cmd != nil { - cmds = append(cmds, cmd) - } - } else { - m.selectionState.selectedIndex = NoSelection - } - - m.ResetView() - return tea.Batch(cmds...) -} - -// filterSection filters items within a section and returns the section if it has matches. -func (m *model) filterSection(sect section, search string) *section { - var matchedItems []util.Model - var hasHeaderMatch bool - - // Check if section header itself matches - if sect.header != nil { - headerText := strings.ToLower(sect.header.View()) - if strings.Contains(headerText, search) { - hasHeaderMatch = true - // If header matches, include all items in the section - matchedItems = sect.items - } - } - - // If header didn't match, filter items within the section - if !hasHeaderMatch && len(sect.items) > 0 { - // Create words array for items in this section - words := make([]string, len(sect.items)) - for i, item := range sect.items { - if f, ok := item.(HasFilterValue); ok { - words[i] = strings.ToLower(f.FilterValue()) - } else { - words[i] = "" - } - } - - // Find matches within this section - matches := fuzzy.Find(search, words) - - // Sort matches by score but preserve relative order for equal scores - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) - - // Build matched items list - for _, match := range matches { - item := sect.items[match.Index] - if i, ok := item.(HasMatchIndexes); ok { - i.MatchIndexes(match.MatchedIndexes) - } - matchedItems = append(matchedItems, item) - } - } - - // Return section only if it has matches - if len(matchedItems) > 0 { - return §ion{ - header: sect.header, - items: matchedItems, - } - } - - return nil -} - -// SelectedIndex returns the index of the currently selected item. -func (m *model) SelectedIndex() int { - if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) { - return NoSelection - } - return m.selectionState.selectedIndex -} - -// SetSelected sets the selected item by index and automatically scrolls to make it visible. -// If the index is invalid or points to a section header, it finds the nearest selectable item. -func (m *model) SetSelected(index int) tea.Cmd { - changeNeeded := m.selectionState.selectedIndex - index - cmds := []tea.Cmd{} - if changeNeeded < 0 { - for range -changeNeeded { - cmds = append(cmds, m.selectNextItem()) - m.renderVisible() - } - } else if changeNeeded > 0 { - for range changeNeeded { - cmds = append(cmds, m.selectPreviousItem()) - m.renderVisible() - } - } - return tea.Batch(cmds...) -} - -// Blur implements ListModel. -func (m *model) Blur() tea.Cmd { - m.isFocused = false - cmd := m.blurSelected() - return cmd -} - -// Focus implements ListModel. -func (m *model) Focus() tea.Cmd { - m.isFocused = true - cmd := m.focusSelected() - return cmd -} - -// IsFocused implements ListModel. -func (m *model) IsFocused() bool { - return m.isFocused -} - -func (m *model) SetFilterPlaceholder(placeholder string) { - m.input.Placeholder = placeholder -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index c1b96f0bac7d0b665aad77794392b7417d60457a..50a67b77be373f987849953d0d60d9773caeb752 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -10,10 +10,9 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -29,6 +28,8 @@ const ( UserCommands ) +type listModel = list.FilterableList[list.CompletionItem[Command]] + // Command represents a command that can be executed type Command struct { ID string @@ -48,7 +49,7 @@ type commandDialogCmp struct { wWidth int // Width of the terminal window wHeight int // Height of the terminal window - commandList list.ListModel + commandList listModel keyMap CommandsDialogKeyMap help help.Model commandType int // SystemCommands or UserCommands @@ -67,24 +68,23 @@ type ( ) func NewCommandDialog(sessionID string) CommandsDialog { - listKeyMap := list.DefaultKeyMap() keyMap := DefaultCommandsDialogKeyMap() - + listKeyMap := list.DefaultKeyMap() listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous t := styles.CurrentTheme() - commandList := list.New( - list.WithFilterable(true), - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(true), + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + commandList := list.NewFilterableList( + []list.CompletionItem[Command]{}, + list.WithFilterInputStyle(inputStyle), + list.WithFilterListOptions( + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(), + list.WithResizeByList(), + ), ) help := help.New() help.Styles = t.S().Help @@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd { if err != nil { return util.ReportError(err) } - c.userCommands = commands - c.SetCommandType(c.commandType) - return c.commandList.Init() + return c.SetCommandType(c.commandType) } func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: c.wWidth = msg.Width c.wHeight = msg.Height - c.SetCommandType(c.commandType) return c, c.commandList.SetSize(c.listWidth(), c.listHeight()) case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Select): - selectedItemInx := c.commandList.SelectedIndex() - if selectedItemInx == list.NoSelection { + selectedItem := c.commandList.SelectedItem() + if selectedItem == nil { return c, nil // No item selected, do nothing } - items := c.commandList.Items() - selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command) + command := (*selectedItem).Value() return c, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), - selectedItem.Handler(selectedItem), + command.Handler(command), ) case key.Matches(msg, c.keyMap.Tab): + if len(c.userCommands) == 0 { + return c, nil + } // Toggle command type between System and User commands if c.commandType == SystemCommands { return c, c.SetCommandType(UserCommands) @@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := c.commandList.Update(msg) - c.commandList = u.(list.ListModel) + c.commandList = u.(listModel) return c, cmd } } @@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string { t := styles.CurrentTheme() listView := c.commandList radio := c.commandTypeRadio() + + header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio) + if len(c.userCommands) == 0 { + header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4)) + } content := lipgloss.JoinVertical( lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio), + header, listView.View(), "", t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), @@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd { commands = c.userCommands } - commandItems := []util.Model{} + commandItems := []list.CompletionItem[Command]{} for _, cmd := range commands { - opts := []completions.CompletionOption{} + opts := []list.CompletionItemOption{ + list.WithCompletionID(cmd.ID), + } if cmd.Shortcut != "" { - opts = append(opts, completions.WithShortcut(cmd.Shortcut)) + opts = append( + opts, + list.WithCompletionShortcut(cmd.Shortcut), + ) } - commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...)) + commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...)) } return c.commandList.SetItems(commandItems) } diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go deleted file mode 100644 index 990423958cdc41ab4a04afafed71762ab5e7f122..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/item.go +++ /dev/null @@ -1,69 +0,0 @@ -package commands - -import ( - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type ItemSection interface { - util.Model - layout.Sizeable - list.SectionHeader - SetInfo(info string) -} -type itemSectionModel struct { - width int - title string - info string -} - -func NewItemSection(title string) ItemSection { - return &itemSectionModel{ - title: title, - } -} - -func (m *itemSectionModel) Init() tea.Cmd { - return nil -} - -func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m *itemSectionModel) View() string { - t := styles.CurrentTheme() - title := ansi.Truncate(m.title, m.width-2, "…") - style := t.S().Base.Padding(1, 1, 0, 1) - title = t.S().Muted.Render(title) - section := "" - if m.info != "" { - section = core.SectionWithInfo(title, m.width-2, m.info) - } else { - section = core.Section(title, m.width-2) - } - - return style.Render(section) -} - -func (m *itemSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *itemSectionModel) IsSectionHeader() bool { - return true -} - -func (m *itemSectionModel) SetInfo(info string) { - m.info = info -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 5a36ab736351f2c92154da997f01ba7360470d8a..d68e701160e99f36d68a453f0f8095a281d584ed 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -7,27 +7,36 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core/list" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" ) +type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]] + type ModelListComponent struct { - list list.ListModel + list listModel modelType int providers []catwalk.Provider } -func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { - modelList := list.New( - list.WithFilterable(true), +func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent { + t := styles.CurrentTheme() + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + options := []list.ListOption{ list.WithKeyMap(keyMap), - list.WithInputStyle(inputStyle), + list.WithWrapNavigation(), + } + if shouldResize { + options = append(options, list.WithResizeByList()) + } + modelList := list.NewFilterableGroupedList( + []list.Group[list.CompletionItem[ModelOption]]{}, + list.WithFilterInputStyle(inputStyle), list.WithFilterPlaceholder(inputPlaceholder), - list.WithWrapNavigation(true), + list.WithFilterListOptions( + options..., + ), ) return &ModelListComponent{ @@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd { func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { u, cmd := m.list.Update(msg) - m.list = u.(list.ListModel) + m.list = u.(listModel) return m, cmd } @@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { return m.list.SetSize(width, height) } -func (m *ModelListComponent) Items() []util.Model { - return m.list.Items() -} - -func (m *ModelListComponent) SelectedIndex() int { - return m.list.SelectedIndex() +func (m *ModelListComponent) SelectedModel() *ModelOption { + s := m.list.SelectedItem() + if s == nil { + return nil + } + sv := *s + model := sv.Value() + return &model } func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { t := styles.CurrentTheme() m.modelType = modelType - modelItems := []util.Model{} + var groups []list.Group[list.CompletionItem[ModelOption]] // first none section - selectIndex := 1 + selectedItemID := "" cfg := config.Get() var currentModel config.SelectedModel @@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { if name == "" { name = string(configProvider.ID) } - section := commands.NewItemSection(name) + section := list.NewItemSection(name) section.SetInfo(configured) - modelItems = append(modelItems, section) + group := list.Group[list.CompletionItem[ModelOption]]{ + Section: section, + } for _, model := range configProvider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + item := list.NewCompletionItem(model.Name, ModelOption{ Provider: configProvider, Model: model, - })) + }, + list.WithCompletionID( + fmt.Sprintf("%s:%s", providerConfig.ID, model.ID), + ), + ) + + group.Items = append(group.Items, item) if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model + selectedItemID = item.ID() } } + groups = append(groups, group) + addedProviders[providerID] = true } } @@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { name = string(provider.ID) } - section := commands.NewItemSection(name) + section := list.NewItemSection(name) if _, ok := cfg.Providers.Get(string(provider.ID)); ok { section.SetInfo(configured) } - modelItems = append(modelItems, section) + group := list.Group[list.CompletionItem[ModelOption]]{ + Section: section, + } for _, model := range provider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + item := list.NewCompletionItem(model.Name, ModelOption{ Provider: provider, Model: model, - })) + }, + list.WithCompletionID( + fmt.Sprintf("%s:%s", provider.ID, model.ID), + ), + ) + group.Items = append(group.Items, item) if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model + selectedItemID = item.ID() } } + groups = append(groups, group) + } + + var cmds []tea.Cmd + + cmd := m.list.SetGroups(groups) + + if cmd != nil { + cmds = append(cmds, cmd) + } + cmd = m.list.SetSelected(selectedItemID) + if cmd != nil { + cmds = append(cmds, cmd) } - return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex)) + return tea.Sequence(cmds...) } // GetModelType returns the current model type @@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int { } func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { - m.list.SetFilterPlaceholder(placeholder) + m.list.SetInputPlaceholder(placeholder) } func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) { diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 795e2585760391bcd711491533a156f9b2c810ba..e09b040a52ebf911ceefc455b0892c7c9ceba754 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -10,10 +10,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -71,22 +70,16 @@ type modelDialogCmp struct { } func NewModelDialogCmp() ModelDialog { - listKeyMap := list.DefaultKeyMap() keyMap := DefaultKeyMap() + listKeyMap := list.DefaultKeyMap() listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous t := styles.CurrentTheme() - inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks") + modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true) apiKeyInput := NewAPIKeyInput() apiKeyInput.SetShowTitle(false) help := help.New() @@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) } // Normal model selection - selectedItemInx := m.modelList.SelectedIndex() - if selectedItemInx == list.NoSelection { - return m, nil - } - items := m.modelList.Items() - selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption) + selectedItem := m.modelList.SelectedModel() var modelType config.SelectedModelType if m.modelList.GetModelType() == LargeModelType { @@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // Provider not configured, show API key input m.needsAPIKey = true - m.selectedModel = &selectedItem + m.selectedModel = selectedItem m.selectedModelType = modelType m.apiKeyInput.SetProviderName(selectedItem.Provider.Name) return m, nil @@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style { } func (m *modelDialogCmp) listWidth() int { - return defaultWidth - 2 // 4 for padding + return m.width - 2 } func (m *modelDialogCmp) listHeight() int { - items := m.modelList.Items() - listHeigh := len(items) + 2 + 4 - return min(listHeigh, m.wHeight/2) + return m.wHeight / 2 } func (m *modelDialogCmp) Position() (int, int) { diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index a95ae0c5ce9b07d499d4f78834a69ccd7ed5635f..4e5cbdef7fdb42f4c667de7ac5bdd5066e7be4df 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -6,10 +6,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -22,6 +21,8 @@ type SessionDialog interface { dialogs.DialogModel } +type SessionsList = list.FilterableList[list.CompletionItem[session.Session]] + type sessionDialogCmp struct { selectedInx int wWidth int @@ -29,8 +30,7 @@ type sessionDialogCmp struct { width int selectedSessionID string keyMap KeyMap - sessionsList list.ListModel - renderedSelected bool + sessionsList SessionsList help help.Model } @@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD t := styles.CurrentTheme() listKeyMap := list.DefaultKeyMap() keyMap := DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - selectedInx := 0 - items := make([]util.Model, len(sessions)) + items := make([]list.CompletionItem[session.Session], len(sessions)) if len(sessions) > 0 { for i, session := range sessions { - items[i] = completions.NewCompletionItem(session.Title, session) - if session.ID == selectedID { - selectedInx = i - } + items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID)) } } - sessionsList := list.New( - list.WithFilterable(true), + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + sessionsList := list.NewFilterableList( + items, list.WithFilterPlaceholder("Enter a session name"), - list.WithKeyMap(listKeyMap), - list.WithItems(items), - list.WithWrapNavigation(true), + list.WithFilterInputStyle(inputStyle), + list.WithFilterListOptions( + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(), + ), ) help := help.New() help.Styles = t.S().Help s := &sessionDialogCmp{ - selectedInx: selectedInx, selectedSessionID: selectedID, keyMap: DefaultKeyMap(), sessionsList: sessionsList, @@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD } func (s *sessionDialogCmp) Init() tea.Cmd { - return s.sessionsList.Init() + var cmds []tea.Cmd + cmds = append(cmds, s.sessionsList.Init()) + cmds = append(cmds, s.sessionsList.Focus()) + return tea.Sequence(cmds...) } func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + var cmds []tea.Cmd s.wWidth = msg.Width s.wHeight = msg.Height - s.width = s.wWidth / 2 - var cmds []tea.Cmd + s.width = min(120, s.wWidth-8) + s.sessionsList.SetInputWidth(s.listWidth() - 2) cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) - if !s.renderedSelected { - cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx)) - s.renderedSelected = true + if s.selectedSessionID != "" { + cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID)) } - return s, tea.Sequence(cmds...) + return s, tea.Batch(cmds...) case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Select): - if len(s.sessionsList.Items()) > 0 { - items := s.sessionsList.Items() - selectedItemInx := s.sessionsList.SelectedIndex() + selectedItem := s.sessionsList.SelectedItem() + if selectedItem != nil { + selected := *selectedItem return s, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler( - chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)), + chat.SessionSelectedMsg(selected.Value()), ), ) } @@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := s.sessionsList.Update(msg) - s.sessionsList = u.(list.ListModel) + s.sessionsList = u.(SessionsList) return s, cmd } } diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..909bcf8a42c728aea398bdc16794b63d7d6e725d --- /dev/null +++ b/internal/tui/exp/list/filterable.go @@ -0,0 +1,308 @@ +package list + +import ( + "regexp" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +type FilterableItem interface { + Item + FilterValue() string +} + +type FilterableList[T FilterableItem] interface { + List[T] + Cursor() *tea.Cursor + SetInputWidth(int) + SetInputPlaceholder(string) + Filter(q string) tea.Cmd +} + +type HasMatchIndexes interface { + MatchIndexes([]int) +} + +type filterableOptions struct { + listOptions []ListOption + placeholder string + inputHidden bool + inputWidth int + inputStyle lipgloss.Style +} +type filterableList[T FilterableItem] struct { + *list[T] + *filterableOptions + width, height int + // stores all available items + items []T + input textinput.Model + inputWidth int + query string +} + +type filterableListOption func(*filterableOptions) + +func WithFilterPlaceholder(ph string) filterableListOption { + return func(f *filterableOptions) { + f.placeholder = ph + } +} + +func WithFilterInputHidden() filterableListOption { + return func(f *filterableOptions) { + f.inputHidden = true + } +} + +func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { + return func(f *filterableOptions) { + f.inputStyle = inputStyle + } +} + +func WithFilterListOptions(opts ...ListOption) filterableListOption { + return func(f *filterableOptions) { + f.listOptions = opts + } +} + +func WithFilterInputWidth(inputWidth int) filterableListOption { + return func(f *filterableOptions) { + f.inputWidth = inputWidth + } +} + +func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] { + t := styles.CurrentTheme() + + f := &filterableList[T]{ + filterableOptions: &filterableOptions{ + inputStyle: t.S().Base, + placeholder: "Type to filter", + }, + } + for _, opt := range opts { + opt(f.filterableOptions) + } + f.list = New[T](items, f.listOptions...).(*list[T]) + + f.updateKeyMaps() + f.items = f.list.items.Slice() + + if f.inputHidden { + return f + } + + ti := textinput.New() + ti.Placeholder = f.placeholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + f.input = ti + return f +} + +func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // handle movements + case key.Matches(msg, f.keyMap.Down), + key.Matches(msg, f.keyMap.Up), + key.Matches(msg, f.keyMap.DownOneItem), + key.Matches(msg, f.keyMap.UpOneItem), + key.Matches(msg, f.keyMap.HalfPageDown), + key.Matches(msg, f.keyMap.HalfPageUp), + key.Matches(msg, f.keyMap.PageDown), + key.Matches(msg, f.keyMap.PageUp), + key.Matches(msg, f.keyMap.End), + key.Matches(msg, f.keyMap.Home): + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd + default: + if !f.inputHidden { + var cmds []tea.Cmd + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + cmds = append(cmds, cmd) + + if f.query != f.input.Value() { + cmd = f.Filter(f.input.Value()) + cmds = append(cmds, cmd) + } + f.query = f.input.Value() + return f, tea.Batch(cmds...) + } + } + } + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd +} + +func (f *filterableList[T]) View() string { + if f.inputHidden { + return f.list.View() + } + + return lipgloss.JoinVertical( + lipgloss.Left, + f.inputStyle.Render(f.input.View()), + f.list.View(), + ) +} + +// removes bindings that are used for search +func (f *filterableList[T]) updateKeyMaps() { + alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$") + + removeLettersAndNumbers := func(bindings []string) []string { + var keep []string + for _, b := range bindings { + if len(b) != 1 { + keep = append(keep, b) + continue + } + if b == " " { + continue + } + m := alphanumeric.MatchString(b) + if !m { + keep = append(keep, b) + } + } + return keep + } + + updateBinding := func(binding key.Binding) key.Binding { + newKeys := removeLettersAndNumbers(binding.Keys()) + if len(newKeys) == 0 { + binding.SetEnabled(false) + return binding + } + binding.SetKeys(newKeys...) + return binding + } + + f.keyMap.Down = updateBinding(f.keyMap.Down) + f.keyMap.Up = updateBinding(f.keyMap.Up) + f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) + f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) + f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) + f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) + f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) + f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) + f.keyMap.End = updateBinding(f.keyMap.End) + f.keyMap.Home = updateBinding(f.keyMap.Home) +} + +func (m *filterableList[T]) GetSize() (int, int) { + return m.width, m.height +} + +func (f *filterableList[T]) SetSize(w, h int) tea.Cmd { + f.width = w + f.height = h + if f.inputHidden { + return f.list.SetSize(w, h) + } + if f.inputWidth == 0 { + f.input.SetWidth(w) + } else { + f.input.SetWidth(f.inputWidth) + } + return f.list.SetSize(w, h-(f.inputHeight())) +} + +func (f *filterableList[T]) inputHeight() int { + return lipgloss.Height(f.inputStyle.Render(f.input.View())) +} + +func (f *filterableList[T]) Filter(query string) tea.Cmd { + var cmds []tea.Cmd + for _, item := range f.items { + if i, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + f.selectedItem = "" + if query == "" { + return f.list.SetItems(f.items) + } + + words := make([]string, len(f.items)) + for i, item := range f.items { + words[i] = strings.ToLower(item.FilterValue()) + } + + matches := fuzzy.Find(query, words) + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + var matchedItems []T + for _, match := range matches { + item := f.items[match.Index] + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + + if f.direction == DirectionBackward { + slices.Reverse(matchedItems) + } + + cmds = append(cmds, f.list.SetItems(matchedItems)) + return tea.Batch(cmds...) +} + +func (f *filterableList[T]) SetItems(items []T) tea.Cmd { + f.items = items + return f.list.SetItems(items) +} + +func (f *filterableList[T]) Cursor() *tea.Cursor { + if f.inputHidden { + return nil + } + return f.input.Cursor() +} + +func (f *filterableList[T]) Blur() tea.Cmd { + f.input.Blur() + return f.list.Blur() +} + +func (f *filterableList[T]) Focus() tea.Cmd { + f.input.Focus() + return f.list.Focus() +} + +func (f *filterableList[T]) IsFocused() bool { + return f.list.IsFocused() +} + +func (f *filterableList[T]) SetInputWidth(w int) { + f.inputWidth = w +} + +func (f *filterableList[T]) SetInputPlaceholder(ph string) { + f.placeholder = ph +} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go new file mode 100644 index 0000000000000000000000000000000000000000..2543723b4b74072510c31453441495d27ec05c9d --- /dev/null +++ b/internal/tui/exp/list/filterable_group.go @@ -0,0 +1,260 @@ +package list + +import ( + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +type FilterableGroupList[T FilterableItem] interface { + GroupedList[T] + Cursor() *tea.Cursor + SetInputWidth(int) + SetInputPlaceholder(string) +} +type filterableGroupList[T FilterableItem] struct { + *groupedList[T] + *filterableOptions + width, height int + groups []Group[T] + // stores all available items + input textinput.Model + inputWidth int + query string +} + +func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] { + t := styles.CurrentTheme() + + f := &filterableGroupList[T]{ + filterableOptions: &filterableOptions{ + inputStyle: t.S().Base, + placeholder: "Type to filter", + }, + } + for _, opt := range opts { + opt(f.filterableOptions) + } + f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T]) + + f.updateKeyMaps() + + if f.inputHidden { + return f + } + + ti := textinput.New() + ti.Placeholder = f.placeholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + f.input = ti + return f +} + +func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // handle movements + case key.Matches(msg, f.keyMap.Down), + key.Matches(msg, f.keyMap.Up), + key.Matches(msg, f.keyMap.DownOneItem), + key.Matches(msg, f.keyMap.UpOneItem), + key.Matches(msg, f.keyMap.HalfPageDown), + key.Matches(msg, f.keyMap.HalfPageUp), + key.Matches(msg, f.keyMap.PageDown), + key.Matches(msg, f.keyMap.PageUp), + key.Matches(msg, f.keyMap.End), + key.Matches(msg, f.keyMap.Home): + u, cmd := f.groupedList.Update(msg) + f.groupedList = u.(*groupedList[T]) + return f, cmd + default: + if !f.inputHidden { + var cmds []tea.Cmd + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + cmds = append(cmds, cmd) + + if f.query != f.input.Value() { + cmd = f.Filter(f.input.Value()) + cmds = append(cmds, cmd) + } + f.query = f.input.Value() + return f, tea.Batch(cmds...) + } + } + } + u, cmd := f.groupedList.Update(msg) + f.groupedList = u.(*groupedList[T]) + return f, cmd +} + +func (f *filterableGroupList[T]) View() string { + if f.inputHidden { + return f.groupedList.View() + } + + return lipgloss.JoinVertical( + lipgloss.Left, + f.inputStyle.Render(f.input.View()), + f.groupedList.View(), + ) +} + +// removes bindings that are used for search +func (f *filterableGroupList[T]) updateKeyMaps() { + alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$") + + removeLettersAndNumbers := func(bindings []string) []string { + var keep []string + for _, b := range bindings { + if len(b) != 1 { + keep = append(keep, b) + continue + } + if b == " " { + continue + } + m := alphanumeric.MatchString(b) + if !m { + keep = append(keep, b) + } + } + return keep + } + + updateBinding := func(binding key.Binding) key.Binding { + newKeys := removeLettersAndNumbers(binding.Keys()) + if len(newKeys) == 0 { + binding.SetEnabled(false) + return binding + } + binding.SetKeys(newKeys...) + return binding + } + + f.keyMap.Down = updateBinding(f.keyMap.Down) + f.keyMap.Up = updateBinding(f.keyMap.Up) + f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) + f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) + f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) + f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) + f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) + f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) + f.keyMap.End = updateBinding(f.keyMap.End) + f.keyMap.Home = updateBinding(f.keyMap.Home) +} + +func (m *filterableGroupList[T]) GetSize() (int, int) { + return m.width, m.height +} + +func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd { + f.width = w + f.height = h + if f.inputHidden { + return f.groupedList.SetSize(w, h) + } + if f.inputWidth == 0 { + f.input.SetWidth(w) + } else { + f.input.SetWidth(f.inputWidth) + } + return f.groupedList.SetSize(w, h-(f.inputHeight())) +} + +func (f *filterableGroupList[T]) inputHeight() int { + return lipgloss.Height(f.inputStyle.Render(f.input.View())) +} + +func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { + var cmds []tea.Cmd + for _, item := range f.items.Slice() { + if i, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + f.selectedItem = "" + if query == "" { + return f.groupedList.SetGroups(f.groups) + } + + var newGroups []Group[T] + for _, g := range f.groups { + words := make([]string, len(g.Items)) + for i, item := range g.Items { + words[i] = strings.ToLower(item.FilterValue()) + } + + matches := fuzzy.Find(query, words) + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + var matchedItems []T + for _, match := range matches { + item := g.Items[match.Index] + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + if len(matchedItems) > 0 { + newGroups = append(newGroups, Group[T]{ + Section: g.Section, + Items: matchedItems, + }) + } + } + cmds = append(cmds, f.groupedList.SetGroups(newGroups)) + return tea.Batch(cmds...) +} + +func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd { + f.groups = groups + return f.groupedList.SetGroups(groups) +} + +func (f *filterableGroupList[T]) Cursor() *tea.Cursor { + if f.inputHidden { + return nil + } + return f.input.Cursor() +} + +func (f *filterableGroupList[T]) Blur() tea.Cmd { + f.input.Blur() + return f.groupedList.Blur() +} + +func (f *filterableGroupList[T]) Focus() tea.Cmd { + f.input.Focus() + return f.groupedList.Focus() +} + +func (f *filterableGroupList[T]) IsFocused() bool { + return f.groupedList.IsFocused() +} + +func (f *filterableGroupList[T]) SetInputWidth(w int) { + f.inputWidth = w +} + +func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) { + f.placeholder = ph +} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go new file mode 100644 index 0000000000000000000000000000000000000000..13208d393ab1086a48b06ab6e8cfd8a72a849ace --- /dev/null +++ b/internal/tui/exp/list/filterable_test.go @@ -0,0 +1,68 @@ +package list + +import ( + "fmt" + "slices" + "testing" + + "github.com/charmbracelet/x/exp/golden" + "github.com/stretchr/testify/assert" +) + +func TestFilterableList(t *testing.T) { + t.Parallel() + t.Run("should create simple filterable list", func(t *testing.T) { + t.Parallel() + items := []FilterableItem{} + for i := range 5 { + item := NewFilterableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := NewFilterableList( + items, + WithFilterListOptions(WithDirectionForward()), + ).(*filterableList[FilterableItem]) + + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + assert.Equal(t, items[0].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +func TestUpdateKeyMap(t *testing.T) { + t.Parallel() + l := NewFilterableList( + []FilterableItem{}, + WithFilterListOptions(WithDirectionForward()), + ).(*filterableList[FilterableItem]) + + hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") + fmt.Println(l.keyMap.Down.Keys()) + hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") + + hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") + + assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") + assert.False(t, hasJ, "should not contain j") + assert.False(t, hasUpperCaseK, "should also remove upper case K") + assert.True(t, hasCtrlJ, "should still have ctrl+j") +} + +type filterableItem struct { + *selectableItem +} + +func NewFilterableItem(content string) FilterableItem { + return &filterableItem{ + selectableItem: NewSelectableItem(content).(*selectableItem), + } +} + +func (f *filterableItem) FilterValue() string { + return f.content +} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go new file mode 100644 index 0000000000000000000000000000000000000000..4b9124fdd0a0e3335d3b22ef122dc5cd86c5785b --- /dev/null +++ b/internal/tui/exp/list/grouped.go @@ -0,0 +1,101 @@ +package list + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/util" +) + +type Group[T Item] struct { + Section ItemSection + Items []T +} +type GroupedList[T Item] interface { + util.Model + layout.Sizeable + Items() []Item + Groups() []Group[T] + SetGroups([]Group[T]) tea.Cmd + MoveUp(int) tea.Cmd + MoveDown(int) tea.Cmd + GoToTop() tea.Cmd + GoToBottom() tea.Cmd + SelectItemAbove() tea.Cmd + SelectItemBelow() tea.Cmd + SetSelected(string) tea.Cmd + SelectedItem() *T +} +type groupedList[T Item] struct { + *list[Item] + groups []Group[T] +} + +func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] { + list := &list[Item]{ + confOptions: &confOptions{ + direction: DirectionForward, + keyMap: DefaultKeyMap(), + focused: true, + }, + items: csync.NewSlice[Item](), + indexMap: csync.NewMap[string, int](), + renderedItems: csync.NewMap[string, renderedItem](), + } + for _, opt := range opts { + opt(list.confOptions) + } + + return &groupedList[T]{ + list: list, + } +} + +func (g *groupedList[T]) Init() tea.Cmd { + g.convertItems() + return g.render() +} + +func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + u, cmd := l.list.Update(msg) + l.list = u.(*list[Item]) + return l, cmd +} + +func (g *groupedList[T]) SelectedItem() *T { + item := g.list.SelectedItem() + if item == nil { + return nil + } + dRef := *item + c, ok := any(dRef).(T) + if !ok { + return nil + } + return &c +} + +func (g *groupedList[T]) convertItems() { + var items []Item + for _, g := range g.groups { + items = append(items, g.Section) + for _, g := range g.Items { + items = append(items, g) + } + } + g.items.SetSlice(items) +} + +func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { + g.groups = groups + g.convertItems() + return g.SetItems(g.items.Slice()) +} + +func (g *groupedList[T]) Groups() []Group[T] { + return g.groups +} + +func (g *groupedList[T]) Items() []Item { + return g.list.Items() +} diff --git a/internal/tui/components/completions/item.go b/internal/tui/exp/list/items.go similarity index 62% rename from internal/tui/components/completions/item.go rename to internal/tui/exp/list/items.go index 414ad94b9ffaae3792f80169feb4cdfff9a71d64..1c09e402352b0d354f01f551279c198c387042a0 100644 --- a/internal/tui/components/completions/item.go +++ b/internal/tui/exp/list/items.go @@ -1,81 +1,107 @@ -package completions +package list import ( "image/color" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/google/uuid" "github.com/rivo/uniseg" ) -type CompletionItem interface { - util.Model +type Indexable interface { + SetIndex(int) +} + +type CompletionItem[T any] interface { + FilterableItem layout.Focusable layout.Sizeable - list.HasMatchIndexes - list.HasFilterValue - Value() any + HasMatchIndexes + Value() T } -type completionItemCmp struct { +type completionItemCmp[T any] struct { width int + id string text string - value any + value T focus bool matchIndexes []int bgColor color.Color shortcut string } -type CompletionOption func(*completionItemCmp) +type options struct { + id string + text string + bgColor color.Color + matchIndexes []int + shortcut string +} + +type CompletionItemOption func(*options) -func WithBackgroundColor(c color.Color) CompletionOption { - return func(cmp *completionItemCmp) { +func WithCompletionBackgroundColor(c color.Color) CompletionItemOption { + return func(cmp *options) { cmp.bgColor = c } } -func WithMatchIndexes(indexes ...int) CompletionOption { - return func(cmp *completionItemCmp) { +func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption { + return func(cmp *options) { cmp.matchIndexes = indexes } } -func WithShortcut(shortcut string) CompletionOption { - return func(cmp *completionItemCmp) { +func WithCompletionShortcut(shortcut string) CompletionItemOption { + return func(cmp *options) { cmp.shortcut = shortcut } } -func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem { - c := &completionItemCmp{ +func WithCompletionID(id string) CompletionItemOption { + return func(cmp *options) { + cmp.id = id + } +} + +func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] { + c := &completionItemCmp[T]{ text: text, value: value, } + o := &options{} for _, opt := range opts { - opt(c) + opt(o) + } + if o.id == "" { + o.id = uuid.NewString() } + c.id = o.id + c.bgColor = o.bgColor + c.matchIndexes = o.matchIndexes + c.shortcut = o.shortcut return c } // Init implements CommandItem. -func (c *completionItemCmp) Init() tea.Cmd { +func (c *completionItemCmp[T]) Init() tea.Cmd { return nil } // Update implements CommandItem. -func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) { return c, nil } // View implements CommandItem. -func (c *completionItemCmp) View() string { +func (c *completionItemCmp[T]) View() string { t := styles.CurrentTheme() itemStyle := t.S().Base.Padding(0, 1).Width(c.width) @@ -140,47 +166,47 @@ func (c *completionItemCmp) View() string { } // Blur implements CommandItem. -func (c *completionItemCmp) Blur() tea.Cmd { +func (c *completionItemCmp[T]) Blur() tea.Cmd { c.focus = false return nil } // Focus implements CommandItem. -func (c *completionItemCmp) Focus() tea.Cmd { +func (c *completionItemCmp[T]) Focus() tea.Cmd { c.focus = true return nil } // GetSize implements CommandItem. -func (c *completionItemCmp) GetSize() (int, int) { +func (c *completionItemCmp[T]) GetSize() (int, int) { return c.width, 1 } // IsFocused implements CommandItem. -func (c *completionItemCmp) IsFocused() bool { +func (c *completionItemCmp[T]) IsFocused() bool { return c.focus } // SetSize implements CommandItem. -func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd { +func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd { c.width = width return nil } -func (c *completionItemCmp) MatchIndexes(indexes []int) { +func (c *completionItemCmp[T]) MatchIndexes(indexes []int) { c.matchIndexes = indexes } -func (c *completionItemCmp) FilterValue() string { +func (c *completionItemCmp[T]) FilterValue() string { return c.text } -func (c *completionItemCmp) Value() any { +func (c *completionItemCmp[T]) Value() T { return c.value } // smartTruncate implements fzf-style truncation that ensures the last matching part is visible -func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string { +func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string { if width <= 0 { return "" } @@ -280,3 +306,80 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { stop = pos return start, stop } + +// ID implements CompletionItem. +func (c *completionItemCmp[T]) ID() string { + return c.id +} + +type ItemSection interface { + Item + layout.Sizeable + Indexable + SetInfo(info string) +} +type itemSectionModel struct { + width int + title string + inx int + info string +} + +// ID implements ItemSection. +func (m *itemSectionModel) ID() string { + return uuid.NewString() +} + +func NewItemSection(title string) ItemSection { + return &itemSectionModel{ + title: title, + inx: -1, + } +} + +func (m *itemSectionModel) Init() tea.Cmd { + return nil +} + +func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *itemSectionModel) View() string { + t := styles.CurrentTheme() + title := ansi.Truncate(m.title, m.width-2, "…") + style := t.S().Base.Padding(1, 1, 0, 1) + if m.inx == 0 { + style = style.Padding(0, 1, 0, 1) + } + title = t.S().Muted.Render(title) + section := "" + if m.info != "" { + section = core.SectionWithInfo(title, m.width-2, m.info) + } else { + section = core.Section(title, m.width-2) + } + + return style.Render(section) +} + +func (m *itemSectionModel) GetSize() (int, int) { + return m.width, 1 +} + +func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { + m.width = width + return nil +} + +func (m *itemSectionModel) IsSectionHeader() bool { + return true +} + +func (m *itemSectionModel) SetInfo(info string) { + m.info = info +} + +func (m *itemSectionModel) SetIndex(inx int) { + m.inx = inx +} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/exp/list/keys.go similarity index 94% rename from internal/tui/components/core/list/keys.go rename to internal/tui/exp/list/keys.go index fb0f461d810b74039ad466bfc5ade6e4be36d56f..ba0f6cec97ed1d0cdc91ff70f69a8f2e1cd386d7 100644 --- a/internal/tui/components/core/list/keys.go +++ b/internal/tui/exp/list/keys.go @@ -46,7 +46,8 @@ func DefaultKeyMap() KeyMap { PageUp: key.NewBinding( key.WithKeys("pgup", "b"), key.WithHelp("b/pgup", "page up"), - ), HalfPageUp: key.NewBinding( + ), + HalfPageUp: key.NewBinding( key.WithKeys("u"), key.WithHelp("u", "half page up"), ), @@ -61,7 +62,6 @@ func DefaultKeyMap() KeyMap { } } -// KeyBindings implements layout.KeyMapProvider func (k KeyMap) KeyBindings() []key.Binding { return []key.Binding{ k.Down, diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index d2cb49d7ab09048e518dbff8ce55427d4a16dc75..1970abb3ea925715fe01d74358d0e89c1a637fb7 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,87 +1,1022 @@ package list import ( + "strings" + + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) type Item interface { util.Model layout.Sizeable + ID() string +} + +type HasAnim interface { + Item + Spinning() bool } -type List interface { +type List[T Item] interface { util.Model + layout.Sizeable + layout.Focusable + + // Just change state + MoveUp(int) tea.Cmd + MoveDown(int) tea.Cmd + GoToTop() tea.Cmd + GoToBottom() tea.Cmd + SelectItemAbove() tea.Cmd + SelectItemBelow() tea.Cmd + SetItems([]T) tea.Cmd + SetSelected(string) tea.Cmd + SelectedItem() *T + Items() []T + UpdateItem(string, T) tea.Cmd + DeleteItem(string) tea.Cmd + PrependItem(T) tea.Cmd + AppendItem(T) tea.Cmd } -type list struct { +type direction int + +const ( + DirectionForward direction = iota + DirectionBackward +) + +const ( + ItemNotFound = -1 + ViewportDefaultScrollSize = 2 +) + +type renderedItem struct { + id string + view string + height int + start int + end int +} + +type confOptions struct { width, height int gap int + // if you are at the last item and go down it will wrap to the top + wrap bool + keyMap KeyMap + direction direction + selectedItem string + focused bool + resize bool + enableMouse bool +} - items []Item +type list[T Item] struct { + *confOptions - // Filter options - filterable bool - filterPlaceholder string -} + offset int -type listOption func(*list) + indexMap *csync.Map[string, int] + items *csync.Slice[T] -// WithFilterable enables filtering on the list. -func WithFilterable(placeholder string) listOption { - return func(l *list) { - l.filterable = true - l.filterPlaceholder = placeholder - } -} + renderedItems *csync.Map[string, renderedItem] -// WithItems sets the initial items for the list. -func WithItems(items ...Item) listOption { - return func(l *list) { - l.items = items - } + rendered string + + movingByItem bool } +type ListOption func(*confOptions) + // WithSize sets the size of the list. -func WithSize(width, height int) listOption { - return func(l *list) { +func WithSize(width, height int) ListOption { + return func(l *confOptions) { l.width = width l.height = height } } // WithGap sets the gap between items in the list. -func WithGap(gap int) listOption { - return func(l *list) { +func WithGap(gap int) ListOption { + return func(l *confOptions) { l.gap = gap } } -func New(opts ...listOption) List { - list := &list{ - items: make([]Item, 0), +// WithDirectionForward sets the direction to forward +func WithDirectionForward() ListOption { + return func(l *confOptions) { + l.direction = DirectionForward + } +} + +// WithDirectionBackward sets the direction to forward +func WithDirectionBackward() ListOption { + return func(l *confOptions) { + l.direction = DirectionBackward + } +} + +// WithSelectedItem sets the initially selected item in the list. +func WithSelectedItem(id string) ListOption { + return func(l *confOptions) { + l.selectedItem = id + } +} + +func WithKeyMap(keyMap KeyMap) ListOption { + return func(l *confOptions) { + l.keyMap = keyMap + } +} + +func WithWrapNavigation() ListOption { + return func(l *confOptions) { + l.wrap = true + } +} + +func WithFocus(focus bool) ListOption { + return func(l *confOptions) { + l.focused = focus + } +} + +func WithResizeByList() ListOption { + return func(l *confOptions) { + l.resize = true + } +} + +func WithEnableMouse() ListOption { + return func(l *confOptions) { + l.enableMouse = true + } +} + +func New[T Item](items []T, opts ...ListOption) List[T] { + list := &list[T]{ + confOptions: &confOptions{ + direction: DirectionForward, + keyMap: DefaultKeyMap(), + focused: true, + }, + items: csync.NewSliceFrom(items), + indexMap: csync.NewMap[string, int](), + renderedItems: csync.NewMap[string, renderedItem](), } for _, opt := range opts { - opt(list) + opt(list.confOptions) + } + + for inx, item := range items { + if i, ok := any(item).(Indexable); ok { + i.SetIndex(inx) + } + list.indexMap.Set(item.ID(), inx) } return list } // Init implements List. -func (l *list) Init() tea.Cmd { +func (l *list[T]) Init() tea.Cmd { + return l.render() +} + +// Update implements List. +func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.MouseWheelMsg: + if l.enableMouse { + return l.handleMouseWheel(msg) + } + return l, nil + case anim.StepMsg: + var cmds []tea.Cmd + for _, item := range l.items.Slice() { + if i, ok := any(item).(HasAnim); ok && i.Spinning() { + updated, cmd := i.Update(msg) + cmds = append(cmds, cmd) + if u, ok := updated.(T); ok { + cmds = append(cmds, l.UpdateItem(u.ID(), u)) + } + } + } + return l, tea.Batch(cmds...) + case tea.KeyPressMsg: + if l.focused { + switch { + case key.Matches(msg, l.keyMap.Down): + return l, l.MoveDown(ViewportDefaultScrollSize) + case key.Matches(msg, l.keyMap.Up): + return l, l.MoveUp(ViewportDefaultScrollSize) + case key.Matches(msg, l.keyMap.DownOneItem): + return l, l.SelectItemBelow() + case key.Matches(msg, l.keyMap.UpOneItem): + return l, l.SelectItemAbove() + case key.Matches(msg, l.keyMap.HalfPageDown): + return l, l.MoveDown(l.height / 2) + case key.Matches(msg, l.keyMap.HalfPageUp): + return l, l.MoveUp(l.height / 2) + case key.Matches(msg, l.keyMap.PageDown): + return l, l.MoveDown(l.height) + case key.Matches(msg, l.keyMap.PageUp): + return l, l.MoveUp(l.height) + case key.Matches(msg, l.keyMap.End): + return l, l.GoToBottom() + case key.Matches(msg, l.keyMap.Home): + return l, l.GoToTop() + } + } + } + return l, nil +} + +func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg.Button { + case tea.MouseWheelDown: + cmd = l.MoveDown(ViewportDefaultScrollSize) + case tea.MouseWheelUp: + cmd = l.MoveUp(ViewportDefaultScrollSize) + } + return l, cmd +} + +// View implements List. +func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { + return "" + } + t := styles.CurrentTheme() + view := l.rendered + lines := strings.Split(view, "\n") + + start, end := l.viewPosition() + viewStart := max(0, start) + viewEnd := min(len(lines), end+1) + lines = lines[viewStart:viewEnd] + if l.resize { + return strings.Join(lines, "\n") + } + return t.S().Base. + Height(l.height). + Width(l.width). + Render(strings.Join(lines, "\n")) +} + +func (l *list[T]) viewPosition() (int, int) { + start, end := 0, 0 + renderedLines := lipgloss.Height(l.rendered) - 1 + if l.direction == DirectionForward { + start = max(0, l.offset) + end = min(l.offset+l.height-1, renderedLines) + } else { + start = max(0, renderedLines-l.offset-l.height+1) + end = max(0, renderedLines-l.offset) + } + return start, end +} + +func (l *list[T]) recalculateItemPositions() { + currentContentHeight := 0 + for _, item := range l.items.Slice() { + rItem, ok := l.renderedItems.Get(item.ID()) + if !ok { + continue + } + rItem.start = currentContentHeight + rItem.end = currentContentHeight + rItem.height - 1 + l.renderedItems.Set(item.ID(), rItem) + currentContentHeight = rItem.end + 1 + l.gap + } +} + +func (l *list[T]) render() tea.Cmd { + if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 { return nil } + l.setDefaultSelected() + + var focusChangeCmd tea.Cmd + if l.focused { + focusChangeCmd = l.focusSelectedItem() + } else { + focusChangeCmd = l.blurSelectedItem() + } + // we are not rendering the first time + if l.rendered != "" { + // rerender everything will mostly hit cache + l.rendered, _ = l.renderIterator(0, false, "") + if l.direction == DirectionBackward { + l.recalculateItemPositions() + } + // in the end scroll to the selected item + if l.focused { + l.scrollToSelection() + } + return focusChangeCmd + } + rendered, finishIndex := l.renderIterator(0, true, "") + l.rendered = rendered + + // recalculate for the initial items + if l.direction == DirectionBackward { + l.recalculateItemPositions() + } + renderCmd := func() tea.Msg { + l.offset = 0 + // render the rest + l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered) + // needed for backwards + if l.direction == DirectionBackward { + l.recalculateItemPositions() + } + // in the end scroll to the selected item + if l.focused { + l.scrollToSelection() + } + + return nil + } + return tea.Batch(focusChangeCmd, renderCmd) +} + +func (l *list[T]) setDefaultSelected() { + if l.selectedItem == "" { + if l.direction == DirectionForward { + l.selectFirstItem() + } else { + l.selectLastItem() + } + } +} + +func (l *list[T]) scrollToSelection() { + rItem, ok := l.renderedItems.Get(l.selectedItem) + if !ok { + l.selectedItem = "" + l.setDefaultSelected() + return + } + + start, end := l.viewPosition() + // item bigger or equal to the viewport do nothing + if rItem.start <= start && rItem.end >= end { + return + } + // if we are moving by item we want to move the offset so that the + // whole item is visible not just portions of it + if l.movingByItem { + if rItem.start >= start && rItem.end <= end { + return + } + defer func() { l.movingByItem = false }() + } else { + // item already in view do nothing + if rItem.start >= start && rItem.start <= end { + return + } + if rItem.end >= start && rItem.end <= end { + return + } + } + + if rItem.height >= l.height { + if l.direction == DirectionForward { + l.offset = rItem.start + } else { + l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height)) + } + return + } + + renderedLines := lipgloss.Height(l.rendered) - 1 + + // If item is above the viewport, make it the first item + if rItem.start < start { + if l.direction == DirectionForward { + l.offset = rItem.start + } else { + l.offset = max(0, renderedLines-rItem.start-l.height+1) + } + } else if rItem.end > end { + // If item is below the viewport, make it the last item + if l.direction == DirectionForward { + l.offset = max(0, rItem.end-l.height+1) + } else { + l.offset = max(0, renderedLines-rItem.end) + } + } +} + +func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { + rItem, ok := l.renderedItems.Get(l.selectedItem) + if !ok { + return nil + } + start, end := l.viewPosition() + // item bigger than the viewport do nothing + if rItem.start <= start && rItem.end >= end { + return nil + } + // item already in view do nothing + if rItem.start >= start && rItem.end <= end { + return nil + } + + itemMiddle := rItem.start + rItem.height/2 + + if itemMiddle < start { + // select the first item in the viewport + // the item is most likely an item coming after this item + inx, ok := l.indexMap.Get(rItem.id) + if !ok { + return nil + } + for { + inx = l.firstSelectableItemBelow(inx) + if inx == ItemNotFound { + return nil + } + item, ok := l.items.Get(inx) + if !ok { + continue + } + renderedItem, ok := l.renderedItems.Get(item.ID()) + if !ok { + continue + } + + // If the item is bigger than the viewport, select it + if renderedItem.start <= start && renderedItem.end >= end { + l.selectedItem = renderedItem.id + return l.render() + } + // item is in the view + if renderedItem.start >= start && renderedItem.start <= end { + l.selectedItem = renderedItem.id + return l.render() + } + } + } else if itemMiddle > end { + // select the first item in the viewport + // the item is most likely an item coming after this item + inx, ok := l.indexMap.Get(rItem.id) + if !ok { + return nil + } + for { + inx = l.firstSelectableItemAbove(inx) + if inx == ItemNotFound { + return nil + } + item, ok := l.items.Get(inx) + if !ok { + continue + } + renderedItem, ok := l.renderedItems.Get(item.ID()) + if !ok { + continue + } + + // If the item is bigger than the viewport, select it + if renderedItem.start <= start && renderedItem.end >= end { + l.selectedItem = renderedItem.id + return l.render() + } + // item is in the view + if renderedItem.end >= start && renderedItem.end <= end { + l.selectedItem = renderedItem.id + return l.render() + } + } + } return nil } -// Update implements List. -func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) { - panic("unimplemented") +func (l *list[T]) selectFirstItem() { + inx := l.firstSelectableItemBelow(-1) + if inx != ItemNotFound { + item, ok := l.items.Get(inx) + if ok { + l.selectedItem = item.ID() + } + } } -// View implements List. -func (l *list) View() string { - panic("unimplemented") +func (l *list[T]) selectLastItem() { + inx := l.firstSelectableItemAbove(l.items.Len()) + if inx != ItemNotFound { + item, ok := l.items.Get(inx) + if ok { + l.selectedItem = item.ID() + } + } +} + +func (l *list[T]) firstSelectableItemAbove(inx int) int { + for i := inx - 1; i >= 0; i-- { + item, ok := l.items.Get(i) + if !ok { + continue + } + if _, ok := any(item).(layout.Focusable); ok { + return i + } + } + if inx == 0 && l.wrap { + return l.firstSelectableItemAbove(l.items.Len()) + } + return ItemNotFound +} + +func (l *list[T]) firstSelectableItemBelow(inx int) int { + itemsLen := l.items.Len() + for i := inx + 1; i < itemsLen; i++ { + item, ok := l.items.Get(i) + if !ok { + continue + } + if _, ok := any(item).(layout.Focusable); ok { + return i + } + } + if inx == itemsLen-1 && l.wrap { + return l.firstSelectableItemBelow(-1) + } + return ItemNotFound +} + +func (l *list[T]) focusSelectedItem() tea.Cmd { + if l.selectedItem == "" || !l.focused { + return nil + } + var cmds []tea.Cmd + for _, item := range l.items.Slice() { + if f, ok := any(item).(layout.Focusable); ok { + if item.ID() == l.selectedItem && !f.IsFocused() { + cmds = append(cmds, f.Focus()) + l.renderedItems.Del(item.ID()) + } else if item.ID() != l.selectedItem && f.IsFocused() { + cmds = append(cmds, f.Blur()) + l.renderedItems.Del(item.ID()) + } + } + } + return tea.Batch(cmds...) +} + +func (l *list[T]) blurSelectedItem() tea.Cmd { + if l.selectedItem == "" || l.focused { + return nil + } + var cmds []tea.Cmd + for _, item := range l.items.Slice() { + if f, ok := any(item).(layout.Focusable); ok { + if item.ID() == l.selectedItem && f.IsFocused() { + cmds = append(cmds, f.Blur()) + l.renderedItems.Del(item.ID()) + } + } + } + return tea.Batch(cmds...) +} + +// render iterator renders items starting from the specific index and limits hight if limitHeight != -1 +// returns the last index and the rendered content so far +// we pass the rendered content around and don't use l.rendered to prevent jumping of the content +func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { + currentContentHeight := lipgloss.Height(rendered) - 1 + itemsLen := l.items.Len() + for i := startInx; i < itemsLen; i++ { + if currentContentHeight >= l.height && limitHeight { + return rendered, i + } + // cool way to go through the list in both directions + inx := i + + if l.direction != DirectionForward { + inx = (itemsLen - 1) - i + } + + item, ok := l.items.Get(inx) + if !ok { + continue + } + var rItem renderedItem + if cache, ok := l.renderedItems.Get(item.ID()); ok { + rItem = cache + } else { + rItem = l.renderItem(item) + rItem.start = currentContentHeight + rItem.end = currentContentHeight + rItem.height - 1 + l.renderedItems.Set(item.ID(), rItem) + } + gap := l.gap + 1 + if inx == itemsLen-1 { + gap = 0 + } + + if l.direction == DirectionForward { + rendered += rItem.view + strings.Repeat("\n", gap) + } else { + rendered = rItem.view + strings.Repeat("\n", gap) + rendered + } + currentContentHeight = rItem.end + 1 + l.gap + } + return rendered, itemsLen +} + +func (l *list[T]) renderItem(item Item) renderedItem { + view := item.View() + return renderedItem{ + id: item.ID(), + view: view, + height: lipgloss.Height(view), + } +} + +// AppendItem implements List. +func (l *list[T]) AppendItem(item T) tea.Cmd { + var cmds []tea.Cmd + cmd := item.Init() + if cmd != nil { + cmds = append(cmds, cmd) + } + + l.items.Append(item) + l.indexMap = csync.NewMap[string, int]() + for inx, item := range l.items.Slice() { + l.indexMap.Set(item.ID(), inx) + } + if l.width > 0 && l.height > 0 { + cmd = item.SetSize(l.width, l.height) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + cmd = l.render() + if cmd != nil { + cmds = append(cmds, cmd) + } + if l.direction == DirectionBackward { + if l.offset == 0 { + cmd = l.GoToBottom() + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height + if l.items.Len() > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + } + } + } + return tea.Sequence(cmds...) +} + +// Blur implements List. +func (l *list[T]) Blur() tea.Cmd { + l.focused = false + return l.render() +} + +// DeleteItem implements List. +func (l *list[T]) DeleteItem(id string) tea.Cmd { + inx, ok := l.indexMap.Get(id) + if !ok { + return nil + } + l.items.Delete(inx) + l.renderedItems.Del(id) + for inx, item := range l.items.Slice() { + l.indexMap.Set(item.ID(), inx) + } + + if l.selectedItem == id { + if inx > 0 { + item, ok := l.items.Get(inx - 1) + if ok { + l.selectedItem = item.ID() + } else { + l.selectedItem = "" + } + } else { + l.selectedItem = "" + } + } + cmd := l.render() + if l.rendered != "" { + renderedHeight := lipgloss.Height(l.rendered) + if renderedHeight <= l.height { + l.offset = 0 + } else { + maxOffset := renderedHeight - l.height + if l.offset > maxOffset { + l.offset = maxOffset + } + } + } + return cmd +} + +// Focus implements List. +func (l *list[T]) Focus() tea.Cmd { + l.focused = true + return l.render() +} + +// GetSize implements List. +func (l *list[T]) GetSize() (int, int) { + return l.width, l.height +} + +// GoToBottom implements List. +func (l *list[T]) GoToBottom() tea.Cmd { + if l.offset != 0 { + l.selectedItem = "" + } + l.offset = 0 + l.direction = DirectionBackward + return l.render() +} + +// GoToTop implements List. +func (l *list[T]) GoToTop() tea.Cmd { + if l.offset != 0 { + l.selectedItem = "" + } + l.offset = 0 + l.direction = DirectionForward + return l.render() +} + +// IsFocused implements List. +func (l *list[T]) IsFocused() bool { + return l.focused +} + +// Items implements List. +func (l *list[T]) Items() []T { + return l.items.Slice() +} + +func (l *list[T]) incrementOffset(n int) { + renderedHeight := lipgloss.Height(l.rendered) + // no need for offset + if renderedHeight <= l.height { + return + } + maxOffset := renderedHeight - l.height + n = min(n, maxOffset-l.offset) + if n <= 0 { + return + } + l.offset += n +} + +func (l *list[T]) decrementOffset(n int) { + n = min(n, l.offset) + if n <= 0 { + return + } + l.offset -= n + if l.offset < 0 { + l.offset = 0 + } +} + +// MoveDown implements List. +func (l *list[T]) MoveDown(n int) tea.Cmd { + if l.direction == DirectionForward { + l.incrementOffset(n) + } else { + l.decrementOffset(n) + } + return l.changeSelectionWhenScrolling() +} + +// MoveUp implements List. +func (l *list[T]) MoveUp(n int) tea.Cmd { + if l.direction == DirectionForward { + l.decrementOffset(n) + } else { + l.incrementOffset(n) + } + return l.changeSelectionWhenScrolling() +} + +// PrependItem implements List. +func (l *list[T]) PrependItem(item T) tea.Cmd { + cmds := []tea.Cmd{ + item.Init(), + } + l.items.Prepend(item) + l.indexMap = csync.NewMap[string, int]() + for inx, item := range l.items.Slice() { + l.indexMap.Set(item.ID(), inx) + } + if l.width > 0 && l.height > 0 { + cmds = append(cmds, item.SetSize(l.width, l.height)) + } + cmds = append(cmds, l.render()) + if l.direction == DirectionForward { + if l.offset == 0 { + cmd := l.GoToTop() + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height + if l.items.Len() > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + } + } + } + return tea.Batch(cmds...) +} + +// SelectItemAbove implements List. +func (l *list[T]) SelectItemAbove() tea.Cmd { + inx, ok := l.indexMap.Get(l.selectedItem) + if !ok { + return nil + } + + newIndex := l.firstSelectableItemAbove(inx) + if newIndex == ItemNotFound { + // no item above + return nil + } + var cmds []tea.Cmd + if newIndex == 1 { + peakAboveIndex := l.firstSelectableItemAbove(newIndex) + if peakAboveIndex == ItemNotFound { + // this means there is a section above move to the top + cmd := l.GoToTop() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + } + item, ok := l.items.Get(newIndex) + if !ok { + return nil + } + l.selectedItem = item.ID() + l.movingByItem = true + renderCmd := l.render() + if renderCmd != nil { + cmds = append(cmds, renderCmd) + } + return tea.Sequence(cmds...) +} + +// SelectItemBelow implements List. +func (l *list[T]) SelectItemBelow() tea.Cmd { + inx, ok := l.indexMap.Get(l.selectedItem) + if !ok { + return nil + } + + newIndex := l.firstSelectableItemBelow(inx) + if newIndex == ItemNotFound { + // no item above + return nil + } + item, ok := l.items.Get(newIndex) + if !ok { + return nil + } + l.selectedItem = item.ID() + l.movingByItem = true + return l.render() +} + +// SelectedItem implements List. +func (l *list[T]) SelectedItem() *T { + inx, ok := l.indexMap.Get(l.selectedItem) + if !ok { + return nil + } + if inx > l.items.Len()-1 { + return nil + } + item, ok := l.items.Get(inx) + if !ok { + return nil + } + return &item +} + +// SetItems implements List. +func (l *list[T]) SetItems(items []T) tea.Cmd { + l.items.SetSlice(items) + var cmds []tea.Cmd + for inx, item := range l.items.Slice() { + if i, ok := any(item).(Indexable); ok { + i.SetIndex(inx) + } + cmds = append(cmds, item.Init()) + } + cmds = append(cmds, l.reset("")) + return tea.Batch(cmds...) +} + +// SetSelected implements List. +func (l *list[T]) SetSelected(id string) tea.Cmd { + l.selectedItem = id + return l.render() +} + +func (l *list[T]) reset(selectedItem string) tea.Cmd { + var cmds []tea.Cmd + l.rendered = "" + l.offset = 0 + l.selectedItem = selectedItem + l.indexMap = csync.NewMap[string, int]() + l.renderedItems = csync.NewMap[string, renderedItem]() + for inx, item := range l.items.Slice() { + l.indexMap.Set(item.ID(), inx) + if l.width > 0 && l.height > 0 { + cmds = append(cmds, item.SetSize(l.width, l.height)) + } + } + cmds = append(cmds, l.render()) + return tea.Batch(cmds...) +} + +// SetSize implements List. +func (l *list[T]) SetSize(width int, height int) tea.Cmd { + oldWidth := l.width + l.width = width + l.height = height + if oldWidth != width { + cmd := l.reset(l.selectedItem) + return cmd + } + return nil +} + +// UpdateItem implements List. +func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { + var cmds []tea.Cmd + if inx, ok := l.indexMap.Get(id); ok { + l.items.Set(inx, item) + oldItem, hasOldItem := l.renderedItems.Get(id) + oldPosition := l.offset + if l.direction == DirectionBackward { + oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset + } + + l.renderedItems.Del(id) + cmd := l.render() + + // need to check for nil because of sequence not handling nil + if cmd != nil { + cmds = append(cmds, cmd) + } + if hasOldItem && l.direction == DirectionBackward { + // if we are the last item and there is no offset + // make sure to go to the bottom + if inx == l.items.Len()-1 && l.offset == 0 { + cmd = l.GoToBottom() + if cmd != nil { + cmds = append(cmds, cmd) + } + + // if the item is at least partially below the viewport + } else if oldPosition < oldItem.end { + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height - oldItem.height + l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + } + } + } else if hasOldItem && l.offset > oldItem.start { + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height - oldItem.height + l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + } + } + } + return tea.Sequence(cmds...) } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..63cfa599e8ce1c96aad1cae67243caa2b097ee0b --- /dev/null +++ b/internal/tui/exp/list/list_test.go @@ -0,0 +1,652 @@ +package list + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/exp/golden" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestList(t *testing.T) { + t.Parallel() + t.Run("should have correct positions in list that fits the items", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 5 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 5, l.indexMap.Len()) + require.Equal(t, 5, l.items.Len()) + require.Equal(t, 5, l.renderedItems.Len()) + assert.Equal(t, 5, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, 0, start) + assert.Equal(t, 4, end) + for i := range 5 { + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) + } + + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 5 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[4].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 5, l.indexMap.Len()) + require.Equal(t, 5, l.items.Len()) + require.Equal(t, 5, l.renderedItems.Len()) + assert.Equal(t, 5, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, 0, start) + assert.Equal(t, 4, end) + for i := range 5 { + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) + } + + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 30, l.indexMap.Len()) + require.Equal(t, 30, l.items.Len()) + require.Equal(t, 30, l.renderedItems.Len()) + assert.Equal(t, 30, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, 0, start) + assert.Equal(t, 9, end) + for i := range 30 { + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) + } + + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[29].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 30, l.indexMap.Len()) + require.Equal(t, 30, l.items.Len()) + require.Equal(t, 30, l.renderedItems.Len()) + assert.Equal(t, 30, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, 20, start) + assert.Equal(t, 29, end) + for i := range 30 { + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) + } + + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 30, l.indexMap.Len()) + require.Equal(t, 30, l.items.Len()) + require.Equal(t, 30, l.renderedItems.Len()) + expectedLines := 0 + for i := range 30 { + expectedLines += (i + 1) * 1 + } + assert.Equal(t, expectedLines, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, 0, start) + assert.Equal(t, 9, end) + currentPosition := 0 + for i := range 30 { + rItem, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, currentPosition, rItem.start) + assert.Equal(t, currentPosition+i, rItem.end) + currentPosition += i + 1 + } + + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[29].ID(), l.selectedItem) + assert.Equal(t, 0, l.offset) + require.Equal(t, 30, l.indexMap.Len()) + require.Equal(t, 30, l.items.Len()) + require.Equal(t, 30, l.renderedItems.Len()) + expectedLines := 0 + for i := range 30 { + expectedLines += (i + 1) * 1 + } + assert.Equal(t, expectedLines, lipgloss.Height(l.rendered)) + assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") + start, end := l.viewPosition() + assert.Equal(t, expectedLines-10, start) + assert.Equal(t, expectedLines-1, end) + currentPosition := 0 + for i := range 30 { + rItem, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, currentPosition, rItem.start) + assert.Equal(t, currentPosition+i, rItem.end) + currentPosition += i + 1 + } + + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should go to selected item at the beginning", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[10].ID(), l.selectedItem) + + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should go to selected item at the beginning backwards", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) + execCmd(l, l.Init()) + + // should select the last item + assert.Equal(t, items[10].ID(), l.selectedItem) + + golden.RequireEqual(t, []byte(l.View())) + }) +} + +func TestListMovement(t *testing.T) { + t.Parallel() + t.Run("should move viewport up", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(25)) + + assert.Equal(t, 25, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should move viewport up and down", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(25)) + execCmd(l, l.MoveDown(25)) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should move viewport down", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(25)) + + assert.Equal(t, 25, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should move viewport down and up", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(25)) + execCmd(l, l.MoveUp(25)) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + execCmd(l, l.AppendItem(NewSelectableItem("Testing"))) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 5, l.offset) + assert.Equal(t, 33, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[29] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 4, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3")) + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[30] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 0, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[1] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + execCmd(l, l.PrependItem(NewSelectableItem("New"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) + content = strings.TrimSuffix(content, "\n") + item := NewSelectableItem(content) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + execCmd(l, l.PrependItem(NewSelectableItem("Testing"))) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 5, l.offset) + assert.Equal(t, 33, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + item := items[0] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 4, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + items = append(items, NewSelectableItem("At top\nLine 2\nLine 3")) + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(3)) + viewBefore := l.View() + item := items[0] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 1, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + item := items[29] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + execCmd(l, l.AppendItem(NewSelectableItem("New"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +type SelectableItem interface { + Item + layout.Focusable +} + +type simpleItem struct { + width int + content string + id string +} +type selectableItem struct { + *simpleItem + focused bool +} + +func NewSimpleItem(content string) *simpleItem { + return &simpleItem{ + id: uuid.NewString(), + width: 0, + content: content, + } +} + +func NewSelectableItem(content string) SelectableItem { + return &selectableItem{ + simpleItem: NewSimpleItem(content), + focused: false, + } +} + +func (s *simpleItem) ID() string { + return s.id +} + +func (s *simpleItem) Init() tea.Cmd { + return nil +} + +func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return s, nil +} + +func (s *simpleItem) View() string { + return lipgloss.NewStyle().Width(s.width).Render(s.content) +} + +func (l *simpleItem) GetSize() (int, int) { + return l.width, 0 +} + +// SetSize implements Item. +func (s *simpleItem) SetSize(width int, height int) tea.Cmd { + s.width = width + return nil +} + +func (s *selectableItem) View() string { + if s.focused { + return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content) + } + return lipgloss.NewStyle().Width(s.width).Render(s.content) +} + +// Blur implements SimpleItem. +func (s *selectableItem) Blur() tea.Cmd { + s.focused = false + return nil +} + +// Focus implements SimpleItem. +func (s *selectableItem) Focus() tea.Cmd { + s.focused = true + return nil +} + +// IsFocused implements SimpleItem. +func (s *selectableItem) IsFocused() bool { + return s.focused +} + +func execCmd(m tea.Model, cmd tea.Cmd) { + for cmd != nil { + msg := cmd() + m, cmd = m.Update(msg) + } +} diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..01668d35b2d07b73b1daf709578d1dccf72a4cea --- /dev/null +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -0,0 +1,10 @@ +> Type to filter  +│Item 0  +Item 1  +Item 2  +Item 3  +Item 4  + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden new file mode 100644 index 0000000000000000000000000000000000000000..7775902a7b151f55d9182fe2af00bd1a0f8e261b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden @@ -0,0 +1,10 @@ +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..7775902a7b151f55d9182fe2af00bd1a0f8e261b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden @@ -0,0 +1,10 @@ +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..4eb402d4d275af1e95c28c538b0059f75fd15a88 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..f167f64ffd978440b6df4f59911c384ed0538a66 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..d54f38ec7432b9f24930015a7415aa3604b97025 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..aaa3c01a3e5cec4da20bdb25af8bc9c86d8ccfd5 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden @@ -0,0 +1,10 @@ +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..a11b23ef049201e56929376a6638bd12718b7a3f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden @@ -0,0 +1,20 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..55b683ef02e235e03bbe941093d557dd06dfd888 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden @@ -0,0 +1,20 @@ +Item 0 +Item 1 +Item 2 +Item 3 +│Item 4 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden new file mode 100644 index 0000000000000000000000000000000000000000..d304f35cc7594d9070555ff914980787b7cfb987 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden @@ -0,0 +1,10 @@ +Item 6 +Item 6 +Item 6 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..65c98367d817411de97cfae7a34737efe0217d6b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden @@ -0,0 +1,10 @@ +Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +│Item 3 +│Item 3 +│Item 3 +│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..03582cc911ee2f3d50e428cd320c25a13c99147b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden @@ -0,0 +1,10 @@ +│Item 28 +│Item 28 +│Item 28 +│Item 28 +│Item 28 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden new file mode 100644 index 0000000000000000000000000000000000000000..d54f38ec7432b9f24930015a7415aa3604b97025 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..9166c3d388f45243860fde6827b42196305d84ea --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +Testing  \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..5493247c7713c227dc5a46ea5422c2b8c10a492e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden @@ -0,0 +1,10 @@ +Testing  +│Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..1a5650ba234a86b20584a146124d7b0c8023679f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..1a5650ba234a86b20584a146124d7b0c8023679f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..4eb402d4d275af1e95c28c538b0059f75fd15a88 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..1a5650ba234a86b20584a146124d7b0c8023679f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..f377a4fd04f868d775c279849fd65723afaac901 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 +Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..1a5650ba234a86b20584a146124d7b0c8023679f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 6a43ac62330fe6ae41e42f02185f08dcaf589468..e8e9e97bce5f98cef91886c05df6988a4561825c 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -165,6 +165,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: p.keyboardEnhancements = msg return p, nil + case tea.MouseWheelMsg: + if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) { + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd + } + return p, nil case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: @@ -610,6 +617,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te if err != nil { return util.ReportError(err) } + cmds = append(cmds, p.chat.GoToBottom()) return tea.Batch(cmds...) } @@ -911,3 +919,31 @@ func (p *chatPage) Help() help.KeyMap { func (p *chatPage) IsChatFocused() bool { return p.focusedPane == PanelTypeChat } + +// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds. +// Returns true if the mouse is over the chat area, false otherwise. +func (p *chatPage) isMouseOverChat(x, y int) bool { + // No session means no chat area + if p.session.ID == "" { + return false + } + + var chatX, chatY, chatWidth, chatHeight int + + if p.compact { + // In compact mode: chat area starts after header and spans full width + chatX = 0 + chatY = HeaderHeight + chatWidth = p.width + chatHeight = p.height - EditorHeight - HeaderHeight + } else { + // In non-compact mode: chat area spans from left edge to sidebar + chatX = 0 + chatY = 0 + chatWidth = p.width - SideBarWidth + chatHeight = p.height - EditorHeight + } + + // Check if mouse coordinates are within chat bounds + return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 89e46947e5c7162c3268bddea49d3f65d6dd0412..af89ce9bfa2165caa25c2671f4ba8096e11bd4f9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -33,26 +34,18 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) -// MouseEventFilter filters mouse events based on the current focus state -// This is used with tea.WithFilter to prevent mouse scroll events from -// interfering with typing performance in the editor +var lastMouseEvent time.Time + func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { - // Only filter mouse events switch msg.(type) { case tea.MouseWheelMsg, tea.MouseMotionMsg: - // Check if we have an appModel and if editor is focused - if appModel, ok := m.(*appModel); ok { - if appModel.currentPage == chat.ChatPageID { - if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok { - // If editor is focused (not chatFocused), filter out mouse wheel/motion events - if !chatPage.IsChatFocused() { - return nil // Filter out the event - } - } - } + now := time.Now() + // trackpad is sending too many requests + if now.Sub(lastMouseEvent) < 5*time.Millisecond { + return nil } + lastMouseEvent = now } - // Allow all other events to pass through return msg }