mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
Merge remote-tracking branch 'origin/list' into improve_agent_promt
This commit is contained in:
109
cspell.json
109
cspell.json
@@ -1,108 +1 @@
|
||||
{
|
||||
"words": [
|
||||
"afero",
|
||||
"agentic",
|
||||
"alecthomas",
|
||||
"anthropics",
|
||||
"aymanbagabas",
|
||||
"azidentity",
|
||||
"bmatcuk",
|
||||
"bubbletea",
|
||||
"charlievieth",
|
||||
"charmbracelet",
|
||||
"charmtone",
|
||||
"Charple",
|
||||
"chkconfig",
|
||||
"crush",
|
||||
"curlie",
|
||||
"cursorrules",
|
||||
"diffview",
|
||||
"doas",
|
||||
"Dockerfiles",
|
||||
"doublestar",
|
||||
"dpkg",
|
||||
"Emph",
|
||||
"fastwalk",
|
||||
"fdisk",
|
||||
"filepicker",
|
||||
"Focusable",
|
||||
"fseventsd",
|
||||
"fsext",
|
||||
"genai",
|
||||
"goquery",
|
||||
"GROQ",
|
||||
"Guac",
|
||||
"imageorient",
|
||||
"Inex",
|
||||
"jetta",
|
||||
"jsons",
|
||||
"jsonschema",
|
||||
"jspm",
|
||||
"Kaufmann",
|
||||
"killall",
|
||||
"Lanczos",
|
||||
"lipgloss",
|
||||
"LOCALAPPDATA",
|
||||
"lsps",
|
||||
"lucasb",
|
||||
"makepkg",
|
||||
"mcps",
|
||||
"MSYS",
|
||||
"mvdan",
|
||||
"natefinch",
|
||||
"nfnt",
|
||||
"noctx",
|
||||
"nohup",
|
||||
"nolint",
|
||||
"nslookup",
|
||||
"oksvg",
|
||||
"Oneshot",
|
||||
"openrouter",
|
||||
"opkg",
|
||||
"pacman",
|
||||
"paru",
|
||||
"pfctl",
|
||||
"postamble",
|
||||
"postambles",
|
||||
"preconfigured",
|
||||
"Preproc",
|
||||
"Proactiveness",
|
||||
"Puerkito",
|
||||
"pycache",
|
||||
"pytest",
|
||||
"qjebbs",
|
||||
"rasterx",
|
||||
"rivo",
|
||||
"sabhiram",
|
||||
"sess",
|
||||
"shortlog",
|
||||
"sjson",
|
||||
"Sourcegraph",
|
||||
"srwiley",
|
||||
"SSEMCP",
|
||||
"Streamable",
|
||||
"stretchr",
|
||||
"Strikethrough",
|
||||
"substrs",
|
||||
"Suscriber",
|
||||
"systeminfo",
|
||||
"tasklist",
|
||||
"termenv",
|
||||
"textinput",
|
||||
"tidwall",
|
||||
"timedout",
|
||||
"trashhalo",
|
||||
"udiff",
|
||||
"uniseg",
|
||||
"Unticked",
|
||||
"urllib",
|
||||
"USERPROFILE",
|
||||
"VERTEXAI",
|
||||
"webp",
|
||||
"whatis",
|
||||
"whereis"
|
||||
],
|
||||
"flagWords": [],
|
||||
"language": "en",
|
||||
"version": "0.2"
|
||||
}
|
||||
{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]}
|
||||
2
go.mod
2
go.mod
@@ -12,7 +12,7 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.0
|
||||
github.com/charlievieth/fastwalk v1.0.11
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
|
||||
github.com/charmbracelet/catwalk v0.3.1
|
||||
github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
|
||||
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
|
||||
|
||||
4
go.sum
4
go.sum
@@ -70,8 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
|
||||
github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
|
||||
github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
|
||||
github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
// Check cache first
|
||||
cacheKey := settingsHash(opts)
|
||||
animCacheMutex.RLock()
|
||||
cached, exists := animCacheMap[cacheKey]
|
||||
animCacheMutex.RUnlock()
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
// 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 {
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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{}
|
||||
if cmd.Shortcut != "" {
|
||||
opts = append(opts, completions.WithShortcut(cmd.Shortcut))
|
||||
opts := []list.CompletionItemOption{
|
||||
list.WithCompletionID(cmd.ID),
|
||||
}
|
||||
commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
|
||||
if cmd.Shortcut != "" {
|
||||
opts = append(
|
||||
opts,
|
||||
list.WithCompletionShortcut(cmd.Shortcut),
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
|
||||
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(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
|
||||
}
|
||||
}
|
||||
|
||||
308
internal/tui/exp/list/filterable.go
Normal file
308
internal/tui/exp/list/filterable.go
Normal file
@@ -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
|
||||
}
|
||||
260
internal/tui/exp/list/filterable_group.go
Normal file
260
internal/tui/exp/list/filterable_group.go
Normal file
@@ -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
|
||||
}
|
||||
68
internal/tui/exp/list/filterable_test.go
Normal file
68
internal/tui/exp/list/filterable_test.go
Normal file
@@ -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
|
||||
}
|
||||
101
internal/tui/exp/list/grouped.go
Normal file
101
internal/tui/exp/list/grouped.go
Normal file
@@ -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
|
||||
layout.Focusable
|
||||
layout.Sizeable
|
||||
list.HasMatchIndexes
|
||||
list.HasFilterValue
|
||||
Value() any
|
||||
type Indexable interface {
|
||||
SetIndex(int)
|
||||
}
|
||||
|
||||
type completionItemCmp struct {
|
||||
type CompletionItem[T any] interface {
|
||||
FilterableItem
|
||||
layout.Focusable
|
||||
layout.Sizeable
|
||||
HasMatchIndexes
|
||||
Value() T
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func WithBackgroundColor(c color.Color) CompletionOption {
|
||||
return func(cmp *completionItemCmp) {
|
||||
type CompletionItemOption func(*options)
|
||||
|
||||
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,
|
||||
File diff suppressed because it is too large
Load Diff
652
internal/tui/exp/list/list_test.go
Normal file
652
internal/tui/exp/list/list_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
10
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden
generated
vendored
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden
generated
vendored
Normal file
@@ -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
|
||||
20
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden
generated
vendored
Normal file
20
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden
generated
vendored
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
20
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden
generated
vendored
Normal file
20
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden
generated
vendored
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden
generated
vendored
Normal file
@@ -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
|
||||
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden
generated
vendored
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user