Detailed changes
@@ -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"
-}
@@ -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
@@ -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=
@@ -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
+ }
+ }
+ }
+}
@@ -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())
+ })
+}
@@ -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.
@@ -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()
+}
@@ -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
+}
@@ -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
+}
@@ -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
}
@@ -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
@@ -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
-}
@@ -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)
}
@@ -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
-}
@@ -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) {
@@ -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) {
@@ -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
}
}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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()
+}
@@ -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
+}
@@ -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,
@@ -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...)
}
@@ -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)
+ }
+}
@@ -0,0 +1,10 @@
+[38;2;223;219;221m[38;2;104;255;214m> [m[38;2;96;95;107mT[m[38;2;96;95;107mype to filter[m[38;2;96;95;107m [m[m
+[38;2;223;219;221m│Item 0 [m
+[38;2;223;219;221mItem 1 [m
+[38;2;223;219;221mItem 2 [m
+[38;2;223;219;221mItem 3 [m
+[38;2;223;219;221mItem 4 [m
+
+
+
+
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
+[38;2;223;219;221m│Item 10[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 3[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 20[m
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221mItem 27[m
+[38;2;223;219;221mItem 28[m
+[38;2;223;219;221m│Item 29[m
@@ -0,0 +1,20 @@
+[38;2;223;219;221m│Item 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -0,0 +1,20 @@
+[38;2;223;219;221mItem 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221m│Item 4[m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
+[38;2;223;219;221m│Item 7[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221m│Item 3[m
+[38;2;223;219;221m│Item 3[m
+[38;2;223;219;221m│Item 3[m
+[38;2;223;219;221m│Item 3[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 28[m
+[38;2;223;219;221m│Item 28[m
+[38;2;223;219;221m│Item 28[m
+[38;2;223;219;221m│Item 28[m
+[38;2;223;219;221m│Item 28[m
+[38;2;223;219;221mItem 29[m
+[38;2;223;219;221mItem 29[m
+[38;2;223;219;221mItem 29[m
+[38;2;223;219;221mItem 29[m
+[38;2;223;219;221mItem 29[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221mTesting [m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mTesting [m
+[38;2;223;219;221m│Item 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 3[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
+[38;2;223;219;221mItem 10[m
+[38;2;223;219;221mItem 11[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 18[m
+[38;2;223;219;221mItem 19[m
+[38;2;223;219;221mItem 20[m
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221m│Item 27[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
+[38;2;223;219;221mItem 10[m
+[38;2;223;219;221mItem 11[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 18[m
+[38;2;223;219;221mItem 19[m
+[38;2;223;219;221mItem 20[m
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221m│Item 27[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 0[m
+[38;2;223;219;221mItem 1[m
+[38;2;223;219;221mItem 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 18[m
+[38;2;223;219;221mItem 19[m
+[38;2;223;219;221mItem 20[m
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221m│Item 27[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
+[38;2;223;219;221mItem 10[m
+[38;2;223;219;221mItem 11[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221mItem 27[m
+[38;2;223;219;221mItem 28[m
+[38;2;223;219;221m│Item 29[m
+[38;2;223;219;221mItem 30[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221mItem 18[m
+[38;2;223;219;221mItem 19[m
+[38;2;223;219;221mItem 20[m
+[38;2;223;219;221mItem 21[m
+[38;2;223;219;221mItem 22[m
+[38;2;223;219;221mItem 23[m
+[38;2;223;219;221mItem 24[m
+[38;2;223;219;221mItem 25[m
+[38;2;223;219;221mItem 26[m
+[38;2;223;219;221m│Item 27[m
@@ -0,0 +1,10 @@
+[38;2;223;219;221m│Item 2[m
+[38;2;223;219;221mItem 3[m
+[38;2;223;219;221mItem 4[m
+[38;2;223;219;221mItem 5[m
+[38;2;223;219;221mItem 6[m
+[38;2;223;219;221mItem 7[m
+[38;2;223;219;221mItem 8[m
+[38;2;223;219;221mItem 9[m
+[38;2;223;219;221mItem 10[m
+[38;2;223;219;221mItem 11[m
@@ -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
+}
@@ -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
}