@@ -13,6 +13,9 @@ import (
13 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
17+ "github.com/charmbracelet/x/ansi"
18+ "github.com/rivo/uniseg"
18type Item interface {
21type Item interface {
@@ -46,6 +49,14 @@ type List[T Item] interface {
46 DeleteItem(string) tea.Cmd
49 DeleteItem(string) tea.Cmd
47 PrependItem(T) tea.Cmd
50 PrependItem(T) tea.Cmd
48 AppendItem(T) tea.Cmd
51 AppendItem(T) tea.Cmd
52+ StartSelection(col, line int)
53+ EndSelection(col, line int)
56+ SelectWord(col, line int)
57+ SelectParagraph(col, line int)
58+ GetSelectedText(paddingLeft int) string
51type direction int
62type direction int
@@ -94,7 +105,13 @@ type list[T Item] struct {
94 renderMu sync.Mutex
105 renderMu sync.Mutex
95 rendered string
106 rendered string
97− movingByItem bool
108+ movingByItem bool
109+ selectionStartCol int
110+ selectionStartLine int
112+ selectionEndLine int
114+ selectionActive bool
100type ListOption func(*confOptions)
117type ListOption func(*confOptions)
@@ -172,9 +189,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
172 keyMap: DefaultKeyMap(),
189 keyMap: DefaultKeyMap(),
173 focused: true,
190 focused: true,
175− items: csync.NewSliceFrom(items),
192+ items: csync.NewSliceFrom(items),
176− indexMap: csync.NewMap[string, int](),
193+ indexMap: csync.NewMap[string, int](),
177− renderedItems: csync.NewMap[string, renderedItem](),
194+ renderedItems: csync.NewMap[string, renderedItem](),
195+ selectionStartCol: -1,
196+ selectionStartLine: -1,
197+ selectionEndLine: -1,
198+ selectionEndCol: -1,
179 for _, opt := range opts {
200 for _, opt := range opts {
180 opt(list.confOptions)
201 opt(list.confOptions)
@@ -266,6 +287,157 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
266 return l, cmd
287 return l, cmd
290+// selectionView renders the highlighted selection in the view and returns it
291+// as a string. If textOnly is true, it won't render any styles.
292+func (l *list[T]) selectionView(view string, textOnly bool) string {
293+ t := styles.CurrentTheme()
294+ area := uv.Rect(0, 0, l.width, l.height)
295+ scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
296+ uv.NewStyledString(view).Draw(scr, area)
298+ selArea := uv.Rectangle{
299+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
300+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
302+ selArea = selArea.Canon()
304+ specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
305+ for _, icon := range styles.SelectionIgnoreIcons {
306+ specialChars[icon] = true
309+ isNonWhitespace := func(r rune) bool {
310+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
313+ type selectionBounds struct {
317+ lineSelections := make([]selectionBounds, scr.Height())
319+ for y := range scr.Height() {
320+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
322+ if y >= selArea.Min.Y && y <= selArea.Max.Y {
323+ bounds.inSelection = true
324+ if selArea.Min.Y == selArea.Max.Y {
325+ // Single line selection
326+ bounds.startX = selArea.Min.X
327+ bounds.endX = selArea.Max.X
328+ } else if y == selArea.Min.Y {
329+ // First line of multi-line selection
330+ bounds.startX = selArea.Min.X
331+ bounds.endX = scr.Width()
332+ } else if y == selArea.Max.Y {
333+ // Last line of multi-line selection
335+ bounds.endX = selArea.Max.X
339+ bounds.endX = scr.Width()
342+ lineSelections[y] = bounds
345+ type lineBounds struct {
348+ lineTextBounds := make([]lineBounds, scr.Height())
350+ // First pass: find text bounds for lines that have selections
351+ for y := range scr.Height() {
352+ bounds := lineBounds{start: -1, end: -1}
354+ // Only process lines that might have selections
355+ if lineSelections[y].inSelection {
356+ for x := range scr.Width() {
357+ cell := scr.CellAt(x, y)
362+ cellStr := cell.String()
363+ if len(cellStr) == 0 {
367+ char := rune(cellStr[0])
368+ isSpecial := specialChars[cellStr]
370+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
371+ if bounds.start == -1 {
374+ bounds.end = x + 1 // Position after last character
378+ lineTextBounds[y] = bounds
381+ var selectedText strings.Builder
383+ // Second pass: apply selection highlighting
384+ for y := range scr.Height() {
385+ selBounds := lineSelections[y]
386+ if !selBounds.inSelection {
390+ textBounds := lineTextBounds[y]
391+ if textBounds.start < 0 {
393+ // We don't want to get rid of all empty lines in text-only mode
394+ selectedText.WriteByte('\n')
397+ continue // No text on this line
400+ // Only scan within the intersection of text bounds and selection bounds
401+ scanStart := max(textBounds.start, selBounds.startX)
402+ scanEnd := min(textBounds.end, selBounds.endX)
404+ for x := scanStart; x < scanEnd; x++ {
405+ cell := scr.CellAt(x, y)
410+ cellStr := cell.String()
411+ if len(cellStr) > 0 && !specialChars[cellStr] {
413+ // Collect selected text without styles
414+ selectedText.WriteString(cell.String())
418+ // Text selection styling, which is a Lip Gloss style. We must
419+ // extract the values to use in a UV style, below.
420+ ts := t.TextSelection
423+ cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
424+ scr.SetCell(x, y, cell)
429+ // Make sure we add a newline after each line of selected text
430+ selectedText.WriteByte('\n')
435+ return strings.TrimSpace(selectedText.String())
269// View implements List.
441// View implements List.
270func (l *list[T]) View() string {
442func (l *list[T]) View() string {
271 if l.height <= 0 || l.width <= 0 {
443 if l.height <= 0 || l.width <= 0 {
@@ -282,10 +454,16 @@ func (l *list[T]) View() string {
282 if l.resize {
454 if l.resize {
283 return strings.Join(lines, "\n")
455 return strings.Join(lines, "\n")
285− return t.S().Base.
457+ view = t.S().Base.
286 Height(l.height).
458 Height(l.height).
287 Width(l.width).
459 Width(l.width).
288 Render(strings.Join(lines, "\n"))
460 Render(strings.Join(lines, "\n"))
462+ if !l.hasSelection() {
466+ return l.selectionView(view, false)
291func (l *list[T]) viewPosition() (int, int) {
469func (l *list[T]) viewPosition() (int, int) {
@@ -817,21 +995,67 @@ func (l *list[T]) decrementOffset(n int) {
818// MoveDown implements List.
996// MoveDown implements List.
819func (l *list[T]) MoveDown(n int) tea.Cmd {
997func (l *list[T]) MoveDown(n int) tea.Cmd {
998+ oldOffset := l.offset
820 if l.direction == DirectionForward {
999 if l.direction == DirectionForward {
821 l.incrementOffset(n)
1000 l.incrementOffset(n)
822 } else {
1001 } else {
823 l.decrementOffset(n)
1002 l.decrementOffset(n)
1005+ if oldOffset == l.offset {
1006+ // no change in offset, so no need to change selection
1009+ // if we are not actively selecting move the whole selection down
1010+ if l.hasSelection() && !l.selectionActive {
1011+ if l.selectionStartLine < l.selectionEndLine {
1012+ l.selectionStartLine -= n
1013+ l.selectionEndLine -= n
1015+ l.selectionStartLine -= n
1016+ l.selectionEndLine -= n
1019+ if l.selectionActive {
1020+ if l.selectionStartLine < l.selectionEndLine {
1021+ l.selectionStartLine -= n
1023+ l.selectionEndLine -= n
825 return l.changeSelectionWhenScrolling()
1026 return l.changeSelectionWhenScrolling()
828// MoveUp implements List.
1029// MoveUp implements List.
829func (l *list[T]) MoveUp(n int) tea.Cmd {
1030func (l *list[T]) MoveUp(n int) tea.Cmd {
1031+ oldOffset := l.offset
830 if l.direction == DirectionForward {
1032 if l.direction == DirectionForward {
831 l.decrementOffset(n)
1033 l.decrementOffset(n)
832 } else {
1034 } else {
833 l.incrementOffset(n)
1035 l.incrementOffset(n)
1038+ if oldOffset == l.offset {
1039+ // no change in offset, so no need to change selection
1043+ if l.hasSelection() && !l.selectionActive {
1044+ if l.selectionStartLine > l.selectionEndLine {
1045+ l.selectionStartLine += n
1046+ l.selectionEndLine += n
1048+ l.selectionStartLine += n
1049+ l.selectionEndLine += n
1052+ if l.selectionActive {
1053+ if l.selectionStartLine > l.selectionEndLine {
1054+ l.selectionStartLine += n
1056+ l.selectionEndLine += n
835 return l.changeSelectionWhenScrolling()
1059 return l.changeSelectionWhenScrolling()
@@ -1029,3 +1253,172 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1030 return tea.Sequence(cmds...)
1254 return tea.Sequence(cmds...)
1257+func (l *list[T]) hasSelection() bool {
1258+ return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1261+// StartSelection implements List.
1262+func (l *list[T]) StartSelection(col, line int) {
1263+ l.selectionStartCol = col
1264+ l.selectionStartLine = line
1265+ l.selectionEndCol = col
1266+ l.selectionEndLine = line
1267+ l.selectionActive = true
1270+// EndSelection implements List.
1271+func (l *list[T]) EndSelection(col, line int) {
1272+ if !l.selectionActive {
1275+ l.selectionEndCol = col
1276+ l.selectionEndLine = line
1279+func (l *list[T]) SelectionStop() {
1280+ l.selectionActive = false
1283+func (l *list[T]) SelectionClear() {
1284+ l.selectionStartCol = -1
1285+ l.selectionStartLine = -1
1286+ l.selectionEndCol = -1
1287+ l.selectionEndLine = -1
1288+ l.selectionActive = false
1291+func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1292+ lines := strings.Split(l.rendered, "\n")
1293+ for i, l := range lines {
1294+ lines[i] = ansi.Strip(l)
1297+ if l.direction == DirectionBackward && len(lines) > l.height {
1298+ line = ((len(lines) - 1) - l.height) + line + 1
1302+ if l.direction == DirectionBackward {
1309+ currentLine := lines[line]
1310+ gr := uniseg.NewGraphemes(currentLine)
1314+ if gr.IsWordBoundary() && upTo > 0 {
1315+ startCol = col - upTo + 1
1316+ } else if gr.IsWordBoundary() && upTo < 0 {
1317+ endCol = col - upTo + 1
1320+ if upTo == 0 && gr.Str() == " " {
1325+ if startCol == -1 {
1331+func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1332+ lines := strings.Split(l.rendered, "\n")
1333+ for i, l := range lines {
1334+ lines[i] = ansi.Strip(l)
1335+ for _, icon := range styles.SelectionIgnoreIcons {
1336+ lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1339+ if l.direction == DirectionBackward && len(lines) > l.height {
1340+ line = (len(lines) - 1) - l.height + line + 1
1343+ if strings.TrimSpace(lines[line]) == "" {
1348+ if l.direction == DirectionBackward {
1355+ // Ensure line is within bounds
1356+ if line < 0 || line >= len(lines) {
1360+ // Find start of paragraph (search backwards for empty line or start of text)
1362+ for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1366+ // Find end of paragraph (search forwards for empty line or end of text)
1368+ for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1372+ // revert the line numbers if we are in backward direction
1373+ if l.direction == DirectionBackward && len(lines) > l.height {
1374+ startLine = startLine - (len(lines) - 1) + l.height - 1
1375+ endLine = endLine - (len(lines) - 1) + l.height - 1
1378+ if l.direction == DirectionBackward {
1379+ startLine += l.offset
1380+ endLine += l.offset
1382+ startLine -= l.offset
1383+ endLine -= l.offset
1386+ return startLine, endLine, true
1389+// SelectWord selects the word at the given position.
1390+func (l *list[T]) SelectWord(col, line int) {
1391+ startCol, endCol := l.findWordBoundaries(col, line)
1392+ l.selectionStartCol = startCol
1393+ l.selectionStartLine = line
1394+ l.selectionEndCol = endCol
1395+ l.selectionEndLine = line
1396+ l.selectionActive = false // Not actively selecting, just selected
1399+// SelectParagraph selects the paragraph at the given position.
1400+func (l *list[T]) SelectParagraph(col, line int) {
1401+ startLine, endLine, found := l.findParagraphBoundaries(line)
1405+ l.selectionStartCol = 0
1406+ l.selectionStartLine = startLine
1407+ l.selectionEndCol = l.width - 1
1408+ l.selectionEndLine = endLine
1409+ l.selectionActive = false // Not actively selecting, just selected
1412+// HasSelection returns whether there is an active selection.
1413+func (l *list[T]) HasSelection() bool {
1414+ return l.hasSelection()
1417+// GetSelectedText returns the currently selected text.
1418+func (l *list[T]) GetSelectedText(paddingLeft int) string {
1419+ if !l.hasSelection() {
1423+ return l.selectionView(l.View(), true)