Detailed changes
@@ -791,6 +791,22 @@
"created_at": "2025-10-30T02:04:26Z",
"repoId": 987670088,
"pullRequestNo": 1335
+ },
+ {
+ "name": "heynemann",
+ "id": 60965,
+ "comment_id": 3475594747,
+ "created_at": "2025-11-01T03:21:03Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1357
+ },
+ {
+ "name": "niklasschaeffer",
+ "id": 1948226,
+ "comment_id": 3476119118,
+ "created_at": "2025-11-01T10:06:05Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1358
}
]
}
@@ -21,6 +21,6 @@ jobs:
with:
commit_message: "chore: auto-update generated files"
branch: main
- commit_user_name: actions-user
- commit_user_email: actions@github.com
- commit_author: actions-user <actions@github.com>
+ commit_user_name: Charm
+ commit_user_email: 124303983+charmcli@users.noreply.github.com
+ commit_author: Charm <124303983+charmcli@users.noreply.github.com>
@@ -3,7 +3,7 @@ module github.com/charmbracelet/crush
go 1.25.0
require (
- charm.land/fantasy v0.1.4
+ charm.land/fantasy v0.1.5
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/MakeNowJust/heredoc v1.0.0
github.com/PuerkitoBio/goquery v1.10.3
@@ -14,7 +14,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6
- github.com/charmbracelet/catwalk v0.8.1
+ github.com/charmbracelet/catwalk v0.8.2
github.com/charmbracelet/colorprofile v0.3.2
github.com/charmbracelet/fang v0.4.3
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
@@ -27,21 +27,21 @@ require (
github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
- github.com/charmbracelet/x/term v0.2.1
+ github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/joho/godotenv v1.5.1
github.com/lucasb-eyer/go-colorful v1.3.0
- github.com/modelcontextprotocol/go-sdk v1.0.0
+ github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/muesli/termenv v0.16.0
github.com/ncruces/go-sqlite3 v0.29.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go/v2 v2.7.1
github.com/posthog/posthog-go v1.6.12
- github.com/pressly/goose/v3 v3.25.0
+ github.com/pressly/goose/v3 v3.26.0
github.com/qjebbs/go-jsons v1.0.0-alpha.4
github.com/rivo/uniseg v0.4.7
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
@@ -1,5 +1,5 @@
-charm.land/fantasy v0.1.4 h1:H/l2GfMy6Pon0GcXoj4/kuHKZ0jm//xDe2Got0eF3AU=
-charm.land/fantasy v0.1.4/go.mod h1:GT1Y8uYNmmu7OkUxWEiOyzdAf1jYopPJfpWvoDRzGiM=
+charm.land/fantasy v0.1.5 h1:7sta5yC+cSU32Kb+cNQb4b/3fyn13tYOgXsnXhdMlX0=
+charm.land/fantasy v0.1.5/go.mod h1:GT1Y8uYNmmu7OkUxWEiOyzdAf1jYopPJfpWvoDRzGiM=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
@@ -82,8 +82,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6 h1:nXNg4TmtfoQXFdF2BSSjTxFp9bSHQCILkIKK3FXMW/E=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
-github.com/charmbracelet/catwalk v0.8.1 h1:Okn6EgMSHlNCYQrSQkyAjLuLiSzDmReGLc0MPcG2F9g=
-github.com/charmbracelet/catwalk v0.8.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+github.com/charmbracelet/catwalk v0.8.2 h1:J7xq/ft/ZByJCHl3JpgvxlCd59bzZPugy66XuoL4vAs=
+github.com/charmbracelet/catwalk v0.8.2/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
@@ -114,8 +114,8 @@ github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQA
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
@@ -214,8 +214,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
-github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
+github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
+github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -249,8 +249,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
github.com/posthog/posthog-go v1.6.12/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
-github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
-github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -33,7 +33,7 @@ func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.Lang
return large, small
}
-func setupAgent(t *testing.T, pair modelPair) (SessionAgent, env) {
+func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
r := newRecorder(t)
large, small := getModels(t, r, pair)
env := testEnv(t)
@@ -30,7 +30,8 @@ import (
_ "github.com/joho/godotenv/autoload"
)
-type env struct {
+// fakeEnv is an environment for testing.
+type fakeEnv struct {
workingDir string
sessions session.Service
messages message.Service
@@ -100,7 +101,7 @@ func zAIBuilder(model string) builderFunc {
}
}
-func testEnv(t *testing.T) env {
+func testEnv(t *testing.T) fakeEnv {
workingDir := filepath.Join("/tmp/crush-test/", t.Name())
os.RemoveAll(workingDir)
@@ -123,7 +124,7 @@ func testEnv(t *testing.T) env {
os.RemoveAll(workingDir)
})
- return env{
+ return fakeEnv{
workingDir,
sessions,
messages,
@@ -133,7 +134,7 @@ func testEnv(t *testing.T) env {
}
}
-func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent {
+func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent {
largeModel := Model{
Model: large,
CatwalkCfg: catwalk.Model{
@@ -152,7 +153,7 @@ func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt
return agent
}
-func coderAgent(r *recorder.Recorder, env env, large, small fantasy.LanguageModel) (SessionAgent, error) {
+func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) {
fixedTime := func() time.Time {
t, _ := time.Parse("1/2/2006", "1/1/2025")
return t
@@ -193,7 +193,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
}
switch providerCfg.Type {
- case openai.Name:
+ case openai.Name, azure.Name:
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
@@ -250,16 +250,6 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
if err == nil {
options[google.Name] = parsed
}
- case azure.Name:
- _, hasReasoningEffort := mergedOptions["reasoning_effort"]
- if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
- mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
- }
- // azure uses the same options as openaicompat
- parsed, err := openaicompat.ParseOptions(mergedOptions)
- if err == nil {
- options[azure.Name] = parsed
- }
case openaicompat.Name:
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
@@ -566,6 +556,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
opts := []azure.Option{
azure.WithBaseURL(baseURL),
azure.WithAPIKey(apiKey),
+ azure.WithUseResponsesAPI(),
}
if c.cfg.Options.Debug {
httpClient := log.NewHTTPClient()
@@ -654,6 +645,9 @@ func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
headers := maps.Clone(providerCfg.ExtraHeaders)
+ if headers == nil {
+ headers = make(map[string]string)
+ }
// handle special headers for anthropic
if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
@@ -736,7 +736,7 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
case v.result.ToolCallID == "":
if v.permissionRequested && !v.permissionGranted {
- message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
+ message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
} else {
message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
}
@@ -102,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
f.list = New(items, f.listOptions...).(*list[T])
f.updateKeyMaps()
- f.items = slices.Collect(f.list.items.Seq())
+ f.items = f.list.items
if f.inputHidden {
return f
@@ -243,7 +243,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
}
}
- f.selectedItem = ""
+ f.selectedItemIdx = -1
if query == "" || len(f.items) == 0 {
return f.list.SetItems(f.items)
}
@@ -2,7 +2,6 @@ package list
import (
"regexp"
- "slices"
"sort"
"strings"
@@ -183,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
var cmds []tea.Cmd
- for _, item := range slices.Collect(f.items.Seq()) {
+ for _, item := range f.items {
if i, ok := any(item).(layout.Focusable); ok {
cmds = append(cmds, i.Blur())
}
@@ -253,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string
func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
cmds := f.clearItemState()
- f.selectedItem = ""
+ f.selectedItemIdx = -1
if query == "" {
return f.groupedList.SetGroups(f.groups)
@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
cmd()
}
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.selectedItemIdx)
golden.RequireEqual(t, []byte(l.View()))
})
}
@@ -1,10 +1,7 @@
package list
import (
- "slices"
-
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"
)
@@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
keyMap: DefaultKeyMap(),
focused: true,
},
- items: csync.NewSlice[Item](),
- indexMap: csync.NewMap[string, int](),
- renderedItems: csync.NewMap[string, renderedItem](),
+ items: []Item{},
+ indexMap: make(map[string]int),
+ renderedItems: make(map[string]renderedItem),
}
for _, opt := range opts {
opt(list.confOptions)
@@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() {
items = append(items, g)
}
}
- g.items.SetSlice(items)
+ g.items = items
}
func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
g.groups = groups
g.convertItems()
- return g.SetItems(slices.Collect(g.items.Seq()))
+ return g.SetItems(g.items)
}
func (g *groupedList[T]) Groups() []Group[T] {
@@ -1,13 +1,11 @@
package list
import (
- "slices"
"strings"
"sync"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
@@ -19,6 +17,25 @@ import (
"github.com/rivo/uniseg"
)
+const maxGapSize = 100
+
+var newlineBuffer = strings.Repeat("\n", maxGapSize)
+
+var (
+ specialCharsMap map[string]struct{}
+ specialCharsOnce sync.Once
+)
+
+func getSpecialCharsMap() map[string]struct{} {
+ specialCharsOnce.Do(func() {
+ specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
+ for _, icon := range styles.SelectionIgnoreIcons {
+ specialCharsMap[icon] = struct{}{}
+ }
+ })
+ return specialCharsMap
+}
+
type Item interface {
util.Model
layout.Sizeable
@@ -35,7 +52,6 @@ type List[T Item] interface {
layout.Sizeable
layout.Focusable
- // Just change state
MoveUp(int) tea.Cmd
MoveDown(int) tea.Cmd
GoToTop() tea.Cmd
@@ -69,11 +85,10 @@ const (
const (
ItemNotFound = -1
- ViewportDefaultScrollSize = 2
+ ViewportDefaultScrollSize = 5
)
type renderedItem struct {
- id string
view string
height int
start int
@@ -81,16 +96,16 @@ type renderedItem struct {
}
type confOptions struct {
- width, height int
- gap int
- // if you are at the last item and go down it will wrap to the top
- wrap bool
- keyMap KeyMap
- direction direction
- selectedItem string
- focused bool
- resize bool
- enableMouse bool
+ width, height int
+ gap int
+ wrap bool
+ keyMap KeyMap
+ direction direction
+ selectedItemIdx int // Index of selected item (-1 if none)
+ selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
+ focused bool
+ resize bool
+ enableMouse bool
}
type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
offset int
- indexMap *csync.Map[string, int]
- items *csync.Slice[T]
+ indexMap map[string]int
+ items []T
+ renderedItems map[string]renderedItem
- renderedItems *csync.Map[string, renderedItem]
+ rendered string
+ renderedHeight int // cached height of rendered content
+ lineOffsets []int // cached byte offsets for each line (for fast slicing)
- renderMu sync.Mutex
- rendered string
+ cachedView string
+ cachedViewOffset int
+ cachedViewDirty bool
- movingByItem bool
- selectionStartCol int
- selectionStartLine int
- selectionEndCol int
- selectionEndLine int
+ movingByItem bool
+ prevSelectedItemIdx int // Index of previously selected item (-1 if none)
+ selectionStartCol int
+ selectionStartLine int
+ selectionEndCol int
+ selectionEndLine int
selectionActive bool
}
@@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption {
// WithSelectedItem sets the initially selected item in the list.
func WithSelectedItem(id string) ListOption {
return func(l *confOptions) {
- l.selectedItem = id
+ l.selectedItemID = id // Will be resolved to index in New()
}
}
@@ -186,17 +206,19 @@ func WithEnableMouse() ListOption {
func New[T Item](items []T, opts ...ListOption) List[T] {
list := &list[T]{
confOptions: &confOptions{
- direction: DirectionForward,
- keyMap: DefaultKeyMap(),
- focused: true,
+ direction: DirectionForward,
+ keyMap: DefaultKeyMap(),
+ focused: true,
+ selectedItemIdx: -1,
},
- items: csync.NewSliceFrom(items),
- indexMap: csync.NewMap[string, int](),
- renderedItems: csync.NewMap[string, renderedItem](),
- selectionStartCol: -1,
- selectionStartLine: -1,
- selectionEndLine: -1,
- selectionEndCol: -1,
+ items: items,
+ indexMap: make(map[string]int, len(items)),
+ renderedItems: make(map[string]renderedItem),
+ prevSelectedItemIdx: -1,
+ selectionStartCol: -1,
+ selectionStartLine: -1,
+ selectionEndLine: -1,
+ selectionEndCol: -1,
}
for _, opt := range opts {
opt(list.confOptions)
@@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
if i, ok := any(item).(Indexable); ok {
i.SetIndex(inx)
}
- list.indexMap.Set(item.ID(), inx)
+ list.indexMap[item.ID()] = inx
+ }
+
+ // Resolve selectedItemID to selectedItemIdx if specified
+ if list.selectedItemID != "" {
+ if idx, ok := list.indexMap[list.selectedItemID]; ok {
+ list.selectedItemIdx = idx
+ }
+ list.selectedItemID = "" // Clear temporary storage
}
+
return list
}
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
return l, nil
case anim.StepMsg:
+ // Fast path: if no items, skip processing
+ if len(l.items) == 0 {
+ return l, nil
+ }
+
+ // Fast path: check if ANY items are actually spinning before processing
+ if !l.hasSpinningItems() {
+ return l, nil
+ }
+
var cmds []tea.Cmd
- for _, item := range slices.Collect(l.items.Seq()) {
- if i, ok := any(item).(HasAnim); ok && i.Spinning() {
- updated, cmd := i.Update(msg)
+ itemsLen := len(l.items)
+ for i := range itemsLen {
+ if i >= len(l.items) {
+ continue
+ }
+ item := l.items[i]
+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+ updated, cmd := animItem.Update(msg)
cmds = append(cmds, cmd)
if u, ok := updated.(T); ok {
cmds = append(cmds, l.UpdateItem(u.ID(), u))
@@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd)
return l, cmd
}
-// selectionView renders the highlighted selection in the view and returns it
-// as a string. If textOnly is true, it won't render any styles.
+func (l *list[T]) hasSpinningItems() bool {
+ for i := range l.items {
+ item := l.items[i]
+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+ return true
+ }
+ }
+ return false
+}
+
func (l *list[T]) selectionView(view string, textOnly bool) string {
t := styles.CurrentTheme()
area := uv.Rect(0, 0, l.width, l.height)
@@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
}
selArea = selArea.Canon()
- specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
- for _, icon := range styles.SelectionIgnoreIcons {
- specialChars[icon] = true
- }
+ specialChars := getSpecialCharsMap()
isNonWhitespace := func(r rune) bool {
return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
}
char := rune(cellStr[0])
- isSpecial := specialChars[cellStr]
+ _, isSpecial := specialChars[cellStr]
if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
if bounds.start == -1 {
@@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
}
cellStr := cell.String()
- if len(cellStr) > 0 && !specialChars[cellStr] {
+ if len(cellStr) > 0 {
+ if _, isSpecial := specialChars[cellStr]; isSpecial {
+ continue
+ }
if textOnly {
// Collect selected text without styles
selectedText.WriteString(cell.String())
@@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
return scr.Render()
}
-// View implements List.
func (l *list[T]) View() string {
if l.height <= 0 || l.width <= 0 {
return ""
}
+
+ if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
+ return l.cachedView
+ }
+
t := styles.CurrentTheme()
- view := l.rendered
- lines := strings.Split(view, "\n")
start, end := l.viewPosition()
viewStart := max(0, start)
- viewEnd := min(len(lines), end+1)
+ viewEnd := end
if viewStart > viewEnd {
- viewStart = viewEnd
+ return ""
}
- lines = lines[viewStart:viewEnd]
+
+ view := l.getLines(viewStart, viewEnd)
if l.resize {
- return strings.Join(lines, "\n")
+ return view
}
+
view = t.S().Base.
Height(l.height).
Width(l.width).
- Render(strings.Join(lines, "\n"))
+ Render(view)
if !l.hasSelection() {
+ l.cachedView = view
+ l.cachedViewOffset = l.offset
+ l.cachedViewDirty = false
return view
}
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
func (l *list[T]) viewPosition() (int, int) {
start, end := 0, 0
- renderedLines := lipgloss.Height(l.rendered) - 1
+ renderedLines := l.renderedHeight - 1
if l.direction == DirectionForward {
start = max(0, l.offset)
end = min(l.offset+l.height-1, renderedLines)
@@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) {
return start, end
}
+func (l *list[T]) setRendered(rendered string) {
+ l.rendered = rendered
+ l.renderedHeight = lipgloss.Height(rendered)
+ l.cachedViewDirty = true // Mark view cache as dirty
+
+ if len(rendered) > 0 {
+ l.lineOffsets = make([]int, 0, l.renderedHeight)
+ l.lineOffsets = append(l.lineOffsets, 0)
+
+ offset := 0
+ for {
+ idx := strings.IndexByte(rendered[offset:], '\n')
+ if idx == -1 {
+ break
+ }
+ offset += idx + 1
+ l.lineOffsets = append(l.lineOffsets, offset)
+ }
+ } else {
+ l.lineOffsets = nil
+ }
+}
+
+func (l *list[T]) getLines(start, end int) string {
+ if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
+ return ""
+ }
+
+ if end >= len(l.lineOffsets) {
+ end = len(l.lineOffsets) - 1
+ }
+ if start > end {
+ return ""
+ }
+
+ startOffset := l.lineOffsets[start]
+ var endOffset int
+ if end+1 < len(l.lineOffsets) {
+ endOffset = l.lineOffsets[end+1] - 1
+ } else {
+ endOffset = len(l.rendered)
+ }
+
+ if startOffset >= len(l.rendered) {
+ return ""
+ }
+ endOffset = min(endOffset, len(l.rendered))
+
+ return l.rendered[startOffset:endOffset]
+}
+
+// getLine returns a single line from the rendered content using lineOffsets.
+// This avoids allocating a new string for each line like strings.Split does.
+func (l *list[T]) getLine(index int) string {
+ if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
+ return ""
+ }
+
+ startOffset := l.lineOffsets[index]
+ var endOffset int
+ if index+1 < len(l.lineOffsets) {
+ endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
+ } else {
+ endOffset = len(l.rendered)
+ }
+
+ if startOffset >= len(l.rendered) {
+ return ""
+ }
+ endOffset = min(endOffset, len(l.rendered))
+
+ return l.rendered[startOffset:endOffset]
+}
+
+// lineCount returns the number of lines in the rendered content.
+func (l *list[T]) lineCount() int {
+ return len(l.lineOffsets)
+}
+
func (l *list[T]) recalculateItemPositions() {
- currentContentHeight := 0
- for _, item := range slices.Collect(l.items.Seq()) {
- rItem, ok := l.renderedItems.Get(item.ID())
+ l.recalculateItemPositionsFrom(0)
+}
+
+func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
+ var currentContentHeight int
+
+ if startIdx > 0 && startIdx <= len(l.items) {
+ prevItem := l.items[startIdx-1]
+ if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
+ currentContentHeight = rItem.end + 1 + l.gap
+ }
+ }
+
+ for i := startIdx; i < len(l.items); i++ {
+ item := l.items[i]
+ rItem, ok := l.renderedItems[item.ID()]
if !ok {
continue
}
rItem.start = currentContentHeight
rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems.Set(item.ID(), rItem)
+ l.renderedItems[item.ID()] = rItem
currentContentHeight = rItem.end + 1 + l.gap
}
}
func (l *list[T]) render() tea.Cmd {
- if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
return nil
}
l.setDefaultSelected()
@@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd {
} else {
focusChangeCmd = l.blurSelectedItem()
}
- // we are not rendering the first time
if l.rendered != "" {
- // rerender everything will mostly hit cache
- l.renderMu.Lock()
- l.rendered, _ = l.renderIterator(0, false, "")
- l.renderMu.Unlock()
+ rendered, _ := l.renderIterator(0, false, "")
+ l.setRendered(rendered)
if l.direction == DirectionBackward {
l.recalculateItemPositions()
}
- // in the end scroll to the selected item
if l.focused {
l.scrollToSelection()
}
return focusChangeCmd
}
- l.renderMu.Lock()
rendered, finishIndex := l.renderIterator(0, true, "")
- l.rendered = rendered
- l.renderMu.Unlock()
- // recalculate for the initial items
+ l.setRendered(rendered)
if l.direction == DirectionBackward {
l.recalculateItemPositions()
}
- renderCmd := func() tea.Msg {
- l.offset = 0
- // render the rest
- l.renderMu.Lock()
- l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
- l.renderMu.Unlock()
- // needed for backwards
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- // in the end scroll to the selected item
- if l.focused {
- l.scrollToSelection()
- }
- return nil
+ l.offset = 0
+ rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+ l.setRendered(rendered)
+ if l.direction == DirectionBackward {
+ l.recalculateItemPositions()
}
- return tea.Batch(focusChangeCmd, renderCmd)
+ if l.focused {
+ l.scrollToSelection()
+ }
+
+ return focusChangeCmd
}
func (l *list[T]) setDefaultSelected() {
- if l.selectedItem == "" {
+ if l.selectedItemIdx < 0 {
if l.direction == DirectionForward {
l.selectFirstItem()
} else {
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
}
func (l *list[T]) scrollToSelection() {
- rItem, ok := l.renderedItems.Get(l.selectedItem)
+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+ l.selectedItemIdx = -1
+ l.setDefaultSelected()
+ return
+ }
+ item := l.items[l.selectedItemIdx]
+ rItem, ok := l.renderedItems[item.ID()]
if !ok {
- l.selectedItem = ""
+ l.selectedItemIdx = -1
l.setDefaultSelected()
return
}
start, end := l.viewPosition()
- // item bigger or equal to the viewport do nothing
if rItem.start <= start && rItem.end >= end {
return
}
- // if we are moving by item we want to move the offset so that the
- // whole item is visible not just portions of it
if l.movingByItem {
if rItem.start >= start && rItem.end <= end {
return
}
defer func() { l.movingByItem = false }()
} else {
- // item already in view do nothing
if rItem.start >= start && rItem.start <= end {
return
}
@@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() {
if l.direction == DirectionForward {
l.offset = rItem.start
} else {
- l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+ l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
}
return
}
- renderedLines := lipgloss.Height(l.rendered) - 1
+ renderedLines := l.renderedHeight - 1
- // If item is above the viewport, make it the first item
if rItem.start < start {
if l.direction == DirectionForward {
l.offset = rItem.start
@@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() {
l.offset = max(0, renderedLines-rItem.start-l.height+1)
}
} else if rItem.end > end {
- // If item is below the viewport, make it the last item
if l.direction == DirectionForward {
l.offset = max(0, rItem.end-l.height+1)
} else {
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
}
func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
- rItem, ok := l.renderedItems.Get(l.selectedItem)
+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+ return nil
+ }
+ item := l.items[l.selectedItemIdx]
+ rItem, ok := l.renderedItems[item.ID()]
if !ok {
return nil
}
@@ -643,64 +787,60 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
if itemMiddle < start {
// select the first item in the viewport
// the item is most likely an item coming after this item
- inx, ok := l.indexMap.Get(rItem.id)
- if !ok {
- return nil
- }
+ inx := l.selectedItemIdx
for {
inx = l.firstSelectableItemBelow(inx)
if inx == ItemNotFound {
return nil
}
- item, ok := l.items.Get(inx)
- if !ok {
+ if inx < 0 || inx >= len(l.items) {
continue
}
- renderedItem, ok := l.renderedItems.Get(item.ID())
+
+ item := l.items[inx]
+ renderedItem, ok := l.renderedItems[item.ID()]
if !ok {
continue
}
// If the item is bigger than the viewport, select it
if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItem = renderedItem.id
+ l.selectedItemIdx = inx
return l.render()
}
// item is in the view
if renderedItem.start >= start && renderedItem.start <= end {
- l.selectedItem = renderedItem.id
+ l.selectedItemIdx = inx
return l.render()
}
}
} else if itemMiddle > end {
// select the first item in the viewport
// the item is most likely an item coming after this item
- inx, ok := l.indexMap.Get(rItem.id)
- if !ok {
- return nil
- }
+ inx := l.selectedItemIdx
for {
inx = l.firstSelectableItemAbove(inx)
if inx == ItemNotFound {
return nil
}
- item, ok := l.items.Get(inx)
- if !ok {
+ if inx < 0 || inx >= len(l.items) {
continue
}
- renderedItem, ok := l.renderedItems.Get(item.ID())
+
+ item := l.items[inx]
+ renderedItem, ok := l.renderedItems[item.ID()]
if !ok {
continue
}
// If the item is bigger than the viewport, select it
if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItem = renderedItem.id
+ l.selectedItemIdx = inx
return l.render()
}
// item is in the view
if renderedItem.end >= start && renderedItem.end <= end {
- l.selectedItem = renderedItem.id
+ l.selectedItemIdx = inx
return l.render()
}
}
@@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
func (l *list[T]) selectFirstItem() {
inx := l.firstSelectableItemBelow(-1)
if inx != ItemNotFound {
- item, ok := l.items.Get(inx)
- if ok {
- l.selectedItem = item.ID()
- }
+ l.selectedItemIdx = inx
}
}
func (l *list[T]) selectLastItem() {
- inx := l.firstSelectableItemAbove(l.items.Len())
+ inx := l.firstSelectableItemAbove(len(l.items))
if inx != ItemNotFound {
- item, ok := l.items.Get(inx)
- if ok {
- l.selectedItem = item.ID()
- }
+ l.selectedItemIdx = inx
}
}
func (l *list[T]) firstSelectableItemAbove(inx int) int {
for i := inx - 1; i >= 0; i-- {
- item, ok := l.items.Get(i)
- if !ok {
+ if i < 0 || i >= len(l.items) {
continue
}
+
+ item := l.items[i]
if _, ok := any(item).(layout.Focusable); ok {
return i
}
}
if inx == 0 && l.wrap {
- return l.firstSelectableItemAbove(l.items.Len())
+ return l.firstSelectableItemAbove(len(l.items))
}
return ItemNotFound
}
func (l *list[T]) firstSelectableItemBelow(inx int) int {
- itemsLen := l.items.Len()
+ itemsLen := len(l.items)
for i := inx + 1; i < itemsLen; i++ {
- item, ok := l.items.Get(i)
- if !ok {
+ if i < 0 || i >= len(l.items) {
continue
}
+
+ item := l.items[i]
if _, ok := any(item).(layout.Focusable); ok {
return i
}
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
}
func (l *list[T]) focusSelectedItem() tea.Cmd {
- if l.selectedItem == "" || !l.focused {
+ if l.selectedItemIdx < 0 || !l.focused {
return nil
}
- var cmds []tea.Cmd
- for _, item := range slices.Collect(l.items.Seq()) {
- if f, ok := any(item).(layout.Focusable); ok {
- if item.ID() == l.selectedItem && !f.IsFocused() {
- cmds = append(cmds, f.Focus())
- l.renderedItems.Del(item.ID())
- } else if item.ID() != l.selectedItem && f.IsFocused() {
- cmds = append(cmds, f.Blur())
- l.renderedItems.Del(item.ID())
- }
+ // Pre-allocate with expected capacity
+ cmds := make([]tea.Cmd, 0, 2)
+
+ // Blur the previously selected item if it's different
+ if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
+ prevItem := l.items[l.prevSelectedItemIdx]
+ if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
+ cmds = append(cmds, f.Blur())
+ // Mark cache as needing update, but don't delete yet
+ // This allows the render to potentially reuse it
+ delete(l.renderedItems, prevItem.ID())
}
}
+
+ // Focus the currently selected item
+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+ item := l.items[l.selectedItemIdx]
+ if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
+ cmds = append(cmds, f.Focus())
+ // Mark for re-render
+ delete(l.renderedItems, item.ID())
+ }
+ }
+
+ l.prevSelectedItemIdx = l.selectedItemIdx
return tea.Batch(cmds...)
}
func (l *list[T]) blurSelectedItem() tea.Cmd {
- if l.selectedItem == "" || l.focused {
+ if l.selectedItemIdx < 0 || l.focused {
return nil
}
- var cmds []tea.Cmd
- for _, item := range slices.Collect(l.items.Seq()) {
- if f, ok := any(item).(layout.Focusable); ok {
- if item.ID() == l.selectedItem && f.IsFocused() {
- cmds = append(cmds, f.Blur())
- l.renderedItems.Del(item.ID())
- }
+
+ // Blur the currently selected item
+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+ item := l.items[l.selectedItemIdx]
+ if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
+ delete(l.renderedItems, item.ID())
+ return f.Blur()
}
}
- return tea.Batch(cmds...)
+
+ return nil
}
// renderFragment holds updated rendered view fragments
@@ -806,10 +956,15 @@ type renderFragment struct {
// returns the last index and the rendered content so far
// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
- var fragments []renderFragment
+ // Pre-allocate fragments with expected capacity
+ itemsLen := len(l.items)
+ expectedFragments := itemsLen - startInx
+ if limitHeight && l.height > 0 {
+ expectedFragments = min(expectedFragments, l.height)
+ }
+ fragments := make([]renderFragment, 0, expectedFragments)
currentContentHeight := lipgloss.Height(rendered) - 1
- itemsLen := l.items.Len()
finalIndex := itemsLen
// first pass: accumulate all fragments to render until the height limit is
@@ -826,19 +981,20 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
inx = (itemsLen - 1) - i
}
- item, ok := l.items.Get(inx)
- if !ok {
+ if inx < 0 || inx >= len(l.items) {
continue
}
+ item := l.items[inx]
+
var rItem renderedItem
- if cache, ok := l.renderedItems.Get(item.ID()); ok {
+ if cache, ok := l.renderedItems[item.ID()]; ok {
rItem = cache
} else {
rItem = l.renderItem(item)
rItem.start = currentContentHeight
rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems.Set(item.ID(), rItem)
+ l.renderedItems[item.ID()] = rItem
}
gap := l.gap + 1
@@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
// second pass: build rendered string efficiently
var b strings.Builder
+
+ // Pre-size the builder to reduce allocations
+ estimatedSize := len(rendered)
+ for _, f := range fragments {
+ estimatedSize += len(f.view) + f.gap
+ }
+ b.Grow(estimatedSize)
+
if l.direction == DirectionForward {
b.WriteString(rendered)
- for _, f := range fragments {
+ for i := range fragments {
+ f := &fragments[i]
b.WriteString(f.view)
- for range f.gap {
- b.WriteByte('\n')
+ // Optimized gap writing using pre-allocated buffer
+ if f.gap > 0 {
+ if f.gap <= maxGapSize {
+ b.WriteString(newlineBuffer[:f.gap])
+ } else {
+ b.WriteString(strings.Repeat("\n", f.gap))
+ }
}
}
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
// iterate backwards as fragments are in reversed order
for i := len(fragments) - 1; i >= 0; i-- {
- f := fragments[i]
+ f := &fragments[i]
b.WriteString(f.view)
- for range f.gap {
- b.WriteByte('\n')
+ // Optimized gap writing using pre-allocated buffer
+ if f.gap > 0 {
+ if f.gap <= maxGapSize {
+ b.WriteString(newlineBuffer[:f.gap])
+ } else {
+ b.WriteString(strings.Repeat("\n", f.gap))
+ }
}
}
b.WriteString(rendered)
@@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
func (l *list[T]) renderItem(item Item) renderedItem {
view := item.View()
return renderedItem{
- id: item.ID(),
view: view,
height: lipgloss.Height(view),
}
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
// AppendItem implements List.
func (l *list[T]) AppendItem(item T) tea.Cmd {
- var cmds []tea.Cmd
+ // Pre-allocate with expected capacity
+ cmds := make([]tea.Cmd, 0, 4)
cmd := item.Init()
if cmd != nil {
cmds = append(cmds, cmd)
}
- l.items.Append(item)
- l.indexMap = csync.NewMap[string, int]()
- for inx, item := range slices.Collect(l.items.Seq()) {
- l.indexMap.Set(item.ID(), inx)
- }
+ newIndex := len(l.items)
+ l.items = append(l.items, item)
+ l.indexMap[item.ID()] = newIndex
+
if l.width > 0 && l.height > 0 {
cmd = item.SetSize(l.width, l.height)
if cmd != nil {
@@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
cmds = append(cmds, cmd)
}
} else {
- newItem, ok := l.renderedItems.Get(item.ID())
+ newItem, ok := l.renderedItems[item.ID()]
if ok {
newLines := newItem.height
- if l.items.Len() > 1 {
+ if len(l.items) > 1 {
newLines += l.gap
}
- l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
}
}
}
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
// DeleteItem implements List.
func (l *list[T]) DeleteItem(id string) tea.Cmd {
- inx, ok := l.indexMap.Get(id)
+ inx, ok := l.indexMap[id]
if !ok {
return nil
}
- l.items.Delete(inx)
- l.renderedItems.Del(id)
- for inx, item := range slices.Collect(l.items.Seq()) {
- l.indexMap.Set(item.ID(), inx)
+ l.items = append(l.items[:inx], l.items[inx+1:]...)
+ delete(l.renderedItems, id)
+ delete(l.indexMap, id)
+
+ // Only update indices for items after the deleted one
+ itemsLen := len(l.items)
+ for i := inx; i < itemsLen; i++ {
+ if i >= 0 && i < len(l.items) {
+ item := l.items[i]
+ l.indexMap[item.ID()] = i
+ }
}
- if l.selectedItem == id {
+ // Adjust selectedItemIdx if the deleted item was selected or before it
+ if l.selectedItemIdx == inx {
+ // Deleted item was selected, select the previous item if possible
if inx > 0 {
- item, ok := l.items.Get(inx - 1)
- if ok {
- l.selectedItem = item.ID()
- } else {
- l.selectedItem = ""
- }
+ l.selectedItemIdx = inx - 1
} else {
- l.selectedItem = ""
+ l.selectedItemIdx = -1
}
+ } else if l.selectedItemIdx > inx {
+ // Selected item is after the deleted one, shift index down
+ l.selectedItemIdx--
}
cmd := l.render()
if l.rendered != "" {
- renderedHeight := lipgloss.Height(l.rendered)
- if renderedHeight <= l.height {
+ if l.renderedHeight <= l.height {
l.offset = 0
} else {
- maxOffset := renderedHeight - l.height
+ maxOffset := l.renderedHeight - l.height
if l.offset > maxOffset {
l.offset = maxOffset
}
@@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) {
// GoToBottom implements List.
func (l *list[T]) GoToBottom() tea.Cmd {
l.offset = 0
- l.selectedItem = ""
+ l.selectedItemIdx = -1
l.direction = DirectionBackward
return l.render()
}
@@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
// GoToTop implements List.
func (l *list[T]) GoToTop() tea.Cmd {
l.offset = 0
- l.selectedItem = ""
+ l.selectedItemIdx = -1
l.direction = DirectionForward
return l.render()
}
@@ -28,18 +28,18 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.selectedItemIdx)
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())
+ require.Equal(t, 5, len(l.indexMap))
+ require.Equal(t, 5, len(l.items))
+ require.Equal(t, 5, len(l.renderedItems))
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())
+ item, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
@@ -58,18 +58,18 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[4].ID(), l.selectedItem)
+ assert.Equal(t, 4, l.selectedItemIdx)
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())
+ require.Equal(t, 5, len(l.indexMap))
+ require.Equal(t, 5, len(l.items))
+ require.Equal(t, 5, len(l.renderedItems))
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())
+ item, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
@@ -89,18 +89,18 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.selectedItemIdx)
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())
+ require.Equal(t, 30, len(l.indexMap))
+ require.Equal(t, 30, len(l.items))
+ require.Equal(t, 30, len(l.renderedItems))
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())
+ item, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
@@ -119,18 +119,18 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, 29, l.selectedItemIdx)
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())
+ require.Equal(t, 30, len(l.indexMap))
+ require.Equal(t, 30, len(l.items))
+ require.Equal(t, 30, len(l.renderedItems))
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())
+ item, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
@@ -152,11 +152,11 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.selectedItemIdx)
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())
+ require.Equal(t, 30, len(l.indexMap))
+ require.Equal(t, 30, len(l.items))
+ require.Equal(t, 30, len(l.renderedItems))
expectedLines := 0
for i := range 30 {
expectedLines += (i + 1) * 1
@@ -168,7 +168,7 @@ func TestList(t *testing.T) {
assert.Equal(t, 9, end)
currentPosition := 0
for i := range 30 {
- rItem, ok := l.renderedItems.Get(items[i].ID())
+ rItem, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, currentPosition, rItem.start)
assert.Equal(t, currentPosition+i, rItem.end)
@@ -190,11 +190,11 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, 29, l.selectedItemIdx)
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())
+ require.Equal(t, 30, len(l.indexMap))
+ require.Equal(t, 30, len(l.items))
+ require.Equal(t, 30, len(l.renderedItems))
expectedLines := 0
for i := range 30 {
expectedLines += (i + 1) * 1
@@ -206,7 +206,7 @@ func TestList(t *testing.T) {
assert.Equal(t, expectedLines-1, end)
currentPosition := 0
for i := range 30 {
- rItem, ok := l.renderedItems.Get(items[i].ID())
+ rItem, ok := l.renderedItems[items[i].ID()]
require.True(t, ok)
assert.Equal(t, currentPosition, rItem.start)
assert.Equal(t, currentPosition+i, rItem.end)
@@ -229,7 +229,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[10].ID(), l.selectedItem)
+ assert.Equal(t, 10, l.selectedItemIdx)
golden.RequireEqual(t, []byte(l.View()))
})
@@ -247,7 +247,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, items[10].ID(), l.selectedItem)
+ assert.Equal(t, 10, l.selectedItemIdx)
golden.RequireEqual(t, []byte(l.View()))
})
@@ -179,7 +179,7 @@ func (t *Theme) buildStyles() *Styles {
},
Cursor: textinput.CursorStyle{
Color: t.Secondary,
- Shape: tea.CursorBar,
+ Shape: tea.CursorBlock,
Blink: true,
},
},
@@ -204,7 +204,7 @@ func (t *Theme) buildStyles() *Styles {
},
Cursor: textarea.CursorStyle{
Color: t.Secondary,
- Shape: tea.CursorBar,
+ Shape: tea.CursorBlock,
Blink: true,
},
},