1package dialog
2
3import (
4 "strings"
5 "time"
6
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/session"
9 "github.com/charmbracelet/crush/internal/ui/list"
10 "github.com/charmbracelet/crush/internal/ui/styles"
11 "github.com/charmbracelet/x/ansi"
12 "github.com/dustin/go-humanize"
13 "github.com/rivo/uniseg"
14 "github.com/sahilm/fuzzy"
15)
16
17// ListItem represents a selectable and searchable item in a dialog list.
18type ListItem interface {
19 list.FilterableItem
20 list.FocusStylable
21 list.MatchSettable
22
23 // ID returns the unique identifier of the item.
24 ID() string
25}
26
27// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
28type SessionItem struct {
29 session.Session
30 t *styles.Styles
31 m fuzzy.Match
32}
33
34var _ ListItem = &SessionItem{}
35
36// Filter returns the filterable value of the session.
37func (s *SessionItem) Filter() string {
38 return s.Session.Title
39}
40
41// ID returns the unique identifier of the session.
42func (s *SessionItem) ID() string {
43 return s.Session.ID
44}
45
46// SetMatch sets the fuzzy match for the session item.
47func (s *SessionItem) SetMatch(m fuzzy.Match) {
48 s.m = m
49}
50
51// Render returns the string representation of the session item.
52func (s *SessionItem) Render(width int) string {
53 age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
54 age = s.t.Subtle.Render(age)
55 age = " " + age
56 ageLen := lipgloss.Width(age)
57 title := s.Session.Title
58 titleLen := lipgloss.Width(title)
59 title = ansi.Truncate(title, max(0, width-ageLen), "…")
60 right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
61
62 if matches := len(s.m.MatchedIndexes); matches > 0 {
63 var lastPos int
64 parts := make([]string, 0)
65 // TODO: Use [ansi.Style].Underline true/false to underline only the
66 // matched parts instead of using [lipgloss.StyleRanges] since it can
67 // be cheaper with less allocations.
68 ranges := matchedRanges(s.m.MatchedIndexes)
69 for _, rng := range ranges {
70 start, stop := bytePosToVisibleCharPos(title, rng)
71 if start > lastPos {
72 parts = append(parts, title[lastPos:start])
73 }
74 // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style]
75 // because we can control the underline start and stop more
76 // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
77 // which only affect the underline attribute without interfering
78 // with other styles.
79 parts = append(parts,
80 ansi.NewStyle().Underline(true).String(),
81 title[start:stop+1],
82 ansi.NewStyle().Underline(false).String(),
83 )
84 lastPos = stop + 1
85 }
86 if lastPos < len(title) {
87 parts = append(parts, title[lastPos:])
88 }
89 return strings.Join(parts, "") + right
90 }
91 return title + right
92}
93
94// FocusStyle returns the style to be applied when the item is focused.
95func (s *SessionItem) FocusStyle() lipgloss.Style {
96 return s.t.Dialog.SelectedItem
97}
98
99// BlurStyle returns the style to be applied when the item is blurred.
100func (s *SessionItem) BlurStyle() lipgloss.Style {
101 return s.t.Dialog.NormalItem
102}
103
104// sessionItems takes a slice of [session.Session]s and convert them to a slice
105// of [ListItem]s.
106func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
107 items := make([]list.FilterableItem, len(sessions))
108 for i, s := range sessions {
109 items[i] = &SessionItem{Session: s, t: t}
110 }
111 return items
112}
113
114func matchedRanges(in []int) [][2]int {
115 if len(in) == 0 {
116 return [][2]int{}
117 }
118 current := [2]int{in[0], in[0]}
119 if len(in) == 1 {
120 return [][2]int{current}
121 }
122 var out [][2]int
123 for i := 1; i < len(in); i++ {
124 if in[i] == current[1]+1 {
125 current[1] = in[i]
126 } else {
127 out = append(out, current)
128 current = [2]int{in[i], in[i]}
129 }
130 }
131 out = append(out, current)
132 return out
133}
134
135func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
136 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
137 pos, start, stop := 0, 0, 0
138 gr := uniseg.NewGraphemes(str)
139 for byteStart > bytePos {
140 if !gr.Next() {
141 break
142 }
143 bytePos += len(gr.Str())
144 pos += max(1, gr.Width())
145 }
146 start = pos
147 for byteStop > bytePos {
148 if !gr.Next() {
149 break
150 }
151 bytePos += len(gr.Str())
152 pos += max(1, gr.Width())
153 }
154 stop = pos
155 return start, stop
156}