@@ -1,6 +1,7 @@
11package list
22
33import (
4+ "log/slog"
45 "slices"
56 "strings"
67
@@ -12,6 +13,7 @@ import (
1213 "github.com/charmbracelet/crush/internal/tui/styles"
1314 "github.com/charmbracelet/crush/internal/tui/util"
1415 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
1517)
1618
1719type Item interface {
@@ -45,6 +47,8 @@ type List[T Item] interface {
4547 DeleteItem(string) tea.Cmd
4648 PrependItem(T) tea.Cmd
4749 AppendItem(T) tea.Cmd
50+ StartSelection(col, line int)
51+ EndSelection(col, line int)
4852}
4953
5054type direction int
@@ -92,7 +96,11 @@ type list[T Item] struct {
9296
9397 rendered string
9498
95− movingByItem bool
99+ movingByItem bool
100+ selectionStartCol int
101+ selectionStartLine int
102+ selectionEndCol int
103+ selectionEndLine int
96104}
97105
98106type ListOption func(*confOptions)
@@ -170,9 +178,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
170178 keyMap: DefaultKeyMap(),
171179 focused: true,
172180 },
173− items: csync.NewSliceFrom(items),
174− indexMap: csync.NewMap[string, int](),
175− renderedItems: csync.NewMap[string, renderedItem](),
181+ items: csync.NewSliceFrom(items),
182+ indexMap: csync.NewMap[string, int](),
183+ renderedItems: csync.NewMap[string, renderedItem](),
184+ selectionStartCol: -1,
185+ selectionStartLine: -1,
186+ selectionEndLine: -1,
187+ selectionEndCol: -1,
176188 }
177189 for _, opt := range opts {
178190 opt(list.confOptions)
@@ -280,10 +292,132 @@ func (l *list[T]) View() string {
280292 if l.resize {
281293 return strings.Join(lines, "\n")
282294 }
283− return t.S().Base.
295+ view = t.S().Base.
284296 Height(l.height).
285297 Width(l.width).
286298 Render(strings.Join(lines, "\n"))
299+ if l.selectionStartCol < 0 {
300+ return view
301+ }
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)
305+
306+ selArea := uv.Rectangle{
307+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
308+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
309+ }
310+ selArea = selArea.Canon()
311+
312+ specialChars := make(map[string]bool, len(styles.AllIcons))
313+ for _, icon := range styles.AllIcons {
314+ specialChars[icon] = true
315+ }
316+
317+ isNonWhitespace := func(r rune) bool {
318+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
319+ }
320+
321+ type selectionBounds struct {
322+ startX, endX int
323+ inSelection bool
324+ }
325+ lineSelections := make([]selectionBounds, scr.Height())
326+
327+ for y := range scr.Height() {
328+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
329+
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
342+ bounds.startX = 0
343+ bounds.endX = selArea.Max.X
344+ } else {
345+ // Middle lines
346+ bounds.startX = 0
347+ bounds.endX = scr.Width()
348+ }
349+ }
350+ lineSelections[y] = bounds
351+ }
352+
353+ type lineBounds struct {
354+ start, end int
355+ }
356+ lineTextBounds := make([]lineBounds, scr.Height())
357+
358+ // First pass: find text bounds for lines that have selections
359+ for y := range scr.Height() {
360+ bounds := lineBounds{start: -1, end: -1}
361+
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)
366+ if cell == nil {
367+ continue
368+ }
369+
370+ cellStr := cell.String()
371+ if len(cellStr) == 0 {
372+ continue
373+ }
374+
375+ char := rune(cellStr[0])
376+ isSpecial := specialChars[cellStr]
377+
378+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
379+ if bounds.start == -1 {
380+ bounds.start = x
381+ }
382+ bounds.end = x + 1 // Position after last character
383+ }
384+ }
385+ }
386+ lineTextBounds[y] = bounds
387+ }
388+
389+ // Second pass: apply selection highlighting
390+ for y := range scr.Height() {
391+ selBounds := lineSelections[y]
392+ if !selBounds.inSelection {
393+ continue
394+ }
395+
396+ textBounds := lineTextBounds[y]
397+ if textBounds.start < 0 {
398+ continue // No text on this line
399+ }
400+
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)
404+
405+ for x := scanStart; x < scanEnd; x++ {
406+ cell := scr.CellAt(x, y)
407+ if cell == nil {
408+ continue
409+ }
410+
411+ cellStr := cell.String()
412+ if len(cellStr) > 0 && !specialChars[cellStr] {
413+ cell = cell.Clone()
414+ cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White)
415+ scr.SetCell(x, y, cell)
416+ }
417+ }
418+ }
419+
420+ return scr.Render()
287421}
288422
289423func (l *list[T]) viewPosition() (int, int) {
@@ -1022,3 +1156,19 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
10221156 }
10231157 return tea.Sequence(cmds...)
10241158}
1159+
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)
1167+}
1168+
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)
1174+}