@@ -1,6 +1,7 @@
1package list
1package list
@@ -12,6 +13,7 @@ import (
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
17type Item interface {
19type Item interface {
@@ -45,6 +47,8 @@ type List[T Item] interface {
45 DeleteItem(string) tea.Cmd
47 DeleteItem(string) tea.Cmd
46 PrependItem(T) tea.Cmd
48 PrependItem(T) tea.Cmd
47 AppendItem(T) tea.Cmd
49 AppendItem(T) tea.Cmd
50+ StartSelection(col, line int)
51+ EndSelection(col, line int)
50type direction int
54type direction int
@@ -92,7 +96,11 @@ type list[T Item] struct {
93 rendered string
97 rendered string
95− movingByItem bool
99+ movingByItem bool
100+ selectionStartCol int
101+ selectionStartLine int
103+ selectionEndLine int
98type ListOption func(*confOptions)
106type ListOption func(*confOptions)
@@ -170,9 +178,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
170 keyMap: DefaultKeyMap(),
178 keyMap: DefaultKeyMap(),
171 focused: true,
179 focused: true,
173− items: csync.NewSliceFrom(items),
181+ items: csync.NewSliceFrom(items),
174− indexMap: csync.NewMap[string, int](),
182+ indexMap: csync.NewMap[string, int](),
175− renderedItems: csync.NewMap[string, renderedItem](),
183+ renderedItems: csync.NewMap[string, renderedItem](),
184+ selectionStartCol: -1,
185+ selectionStartLine: -1,
186+ selectionEndLine: -1,
187+ selectionEndCol: -1,
177 for _, opt := range opts {
189 for _, opt := range opts {
178 opt(list.confOptions)
190 opt(list.confOptions)
@@ -280,10 +292,132 @@ func (l *list[T]) View() string {
280 if l.resize {
292 if l.resize {
281 return strings.Join(lines, "\n")
293 return strings.Join(lines, "\n")
283− return t.S().Base.
295+ view = t.S().Base.
284 Height(l.height).
296 Height(l.height).
285 Width(l.width).
297 Width(l.width).
286 Render(strings.Join(lines, "\n"))
298 Render(strings.Join(lines, "\n"))
299+ if l.selectionStartCol < 0 {
302+ area := uv.Rect(0, 0, l.width, l.height)
303+ scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
304+ uv.NewStyledString(view).Draw(scr, area)
306+ selArea := uv.Rectangle{
307+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
308+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
310+ selArea = selArea.Canon()
312+ specialChars := make(map[string]bool, len(styles.AllIcons))
313+ for _, icon := range styles.AllIcons {
314+ specialChars[icon] = true
317+ isNonWhitespace := func(r rune) bool {
318+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
321+ type selectionBounds struct {
325+ lineSelections := make([]selectionBounds, scr.Height())
327+ for y := range scr.Height() {
328+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
330+ if y >= selArea.Min.Y && y <= selArea.Max.Y {
331+ bounds.inSelection = true
332+ if selArea.Min.Y == selArea.Max.Y {
333+ // Single line selection
334+ bounds.startX = selArea.Min.X
335+ bounds.endX = selArea.Max.X
336+ } else if y == selArea.Min.Y {
337+ // First line of multi-line selection
338+ bounds.startX = selArea.Min.X
339+ bounds.endX = scr.Width()
340+ } else if y == selArea.Max.Y {
341+ // Last line of multi-line selection
343+ bounds.endX = selArea.Max.X
347+ bounds.endX = scr.Width()
350+ lineSelections[y] = bounds
353+ type lineBounds struct {
356+ lineTextBounds := make([]lineBounds, scr.Height())
358+ // First pass: find text bounds for lines that have selections
359+ for y := range scr.Height() {
360+ bounds := lineBounds{start: -1, end: -1}
362+ // Only process lines that might have selections
363+ if lineSelections[y].inSelection {
364+ for x := range scr.Width() {
365+ cell := scr.CellAt(x, y)
370+ cellStr := cell.String()
371+ if len(cellStr) == 0 {
375+ char := rune(cellStr[0])
376+ isSpecial := specialChars[cellStr]
378+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
379+ if bounds.start == -1 {
382+ bounds.end = x + 1 // Position after last character
386+ lineTextBounds[y] = bounds
389+ // Second pass: apply selection highlighting
390+ for y := range scr.Height() {
391+ selBounds := lineSelections[y]
392+ if !selBounds.inSelection {
396+ textBounds := lineTextBounds[y]
397+ if textBounds.start < 0 {
398+ continue // No text on this line
401+ // Only scan within the intersection of text bounds and selection bounds
402+ scanStart := max(textBounds.start, selBounds.startX)
403+ scanEnd := min(textBounds.end, selBounds.endX)
405+ for x := scanStart; x < scanEnd; x++ {
406+ cell := scr.CellAt(x, y)
411+ cellStr := cell.String()
412+ if len(cellStr) > 0 && !specialChars[cellStr] {
414+ cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White)
415+ scr.SetCell(x, y, cell)
289func (l *list[T]) viewPosition() (int, int) {
423func (l *list[T]) viewPosition() (int, int) {
@@ -1022,3 +1156,19 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1023 return tea.Sequence(cmds...)
1157 return tea.Sequence(cmds...)
1160+// StartSelection implements List.
1161+func (l *list[T]) StartSelection(col, line int) {
1162+ l.selectionStartCol = col
1163+ l.selectionStartLine = line
1164+ l.selectionEndCol = col
1165+ l.selectionEndLine = line
1166+ slog.Info("Position", "col", col, "line", line)
1169+// EndSelection implements List.
1170+func (l *list[T]) EndSelection(col, line int) {
1171+ l.selectionEndCol = col
1172+ l.selectionEndLine = line
1173+ slog.Info("Position", "col", col, "line", line)