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.Focusable
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 cache map[int]string
33 focused bool
34}
35
36var _ ListItem = &SessionItem{}
37
38// Filter returns the filterable value of the session.
39func (s *SessionItem) Filter() string {
40 return s.Title
41}
42
43// ID returns the unique identifier of the session.
44func (s *SessionItem) ID() string {
45 return s.Session.ID
46}
47
48// SetMatch sets the fuzzy match for the session item.
49func (s *SessionItem) SetMatch(m fuzzy.Match) {
50 s.cache = nil
51 s.m = m
52}
53
54// Render returns the string representation of the session item.
55func (s *SessionItem) Render(width int) string {
56 return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m)
57}
58
59func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
60 if cache == nil {
61 cache = make(map[int]string)
62 }
63
64 cached, ok := cache[width]
65 if ok {
66 return cached
67 }
68
69 style := t.Dialog.NormalItem
70 if focused {
71 style = t.Dialog.SelectedItem
72 }
73
74 width -= style.GetHorizontalFrameSize()
75
76 var age string
77 if updatedAt > 0 {
78 age = humanize.Time(time.Unix(updatedAt, 0))
79 if focused {
80 age = t.Base.Render(age)
81 } else {
82 age = t.Subtle.Render(age)
83 }
84
85 age = " " + age
86 }
87
88 var ageLen int
89 var right string
90 lineWidth := width
91 if updatedAt > 0 {
92 ageLen = lipgloss.Width(age)
93 lineWidth -= ageLen
94 }
95
96 title = ansi.Truncate(title, max(0, lineWidth), "…")
97 titleLen := lipgloss.Width(title)
98
99 if updatedAt > 0 {
100 right = lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
101 }
102
103 content := title
104 if matches := len(m.MatchedIndexes); matches > 0 {
105 var lastPos int
106 parts := make([]string, 0)
107 ranges := matchedRanges(m.MatchedIndexes)
108 for _, rng := range ranges {
109 start, stop := bytePosToVisibleCharPos(title, rng)
110 if start > lastPos {
111 parts = append(parts, title[lastPos:start])
112 }
113 // NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
114 // because we can control the underline start and stop more
115 // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
116 // which only affect the underline attribute without interfering
117 // with other style
118 parts = append(parts,
119 ansi.NewStyle().Underline(true).String(),
120 title[start:stop+1],
121 ansi.NewStyle().Underline(false).String(),
122 )
123 lastPos = stop + 1
124 }
125 if lastPos < len(title) {
126 parts = append(parts, title[lastPos:])
127 }
128
129 content = strings.Join(parts, "")
130 }
131
132 content = style.Render(content + right)
133 cache[width] = content
134 return content
135}
136
137// SetFocused sets the focus state of the session item.
138func (s *SessionItem) SetFocused(focused bool) {
139 if s.focused != focused {
140 s.cache = nil
141 }
142 s.focused = focused
143}
144
145// sessionItems takes a slice of [session.Session]s and convert them to a slice
146// of [ListItem]s.
147func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
148 items := make([]list.FilterableItem, len(sessions))
149 for i, s := range sessions {
150 items[i] = &SessionItem{Session: s, t: t}
151 }
152 return items
153}
154
155func matchedRanges(in []int) [][2]int {
156 if len(in) == 0 {
157 return [][2]int{}
158 }
159 current := [2]int{in[0], in[0]}
160 if len(in) == 1 {
161 return [][2]int{current}
162 }
163 var out [][2]int
164 for i := 1; i < len(in); i++ {
165 if in[i] == current[1]+1 {
166 current[1] = in[i]
167 } else {
168 out = append(out, current)
169 current = [2]int{in[i], in[i]}
170 }
171 }
172 out = append(out, current)
173 return out
174}
175
176func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
177 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
178 pos, start, stop := 0, 0, 0
179 gr := uniseg.NewGraphemes(str)
180 for byteStart > bytePos {
181 if !gr.Next() {
182 break
183 }
184 bytePos += len(gr.Str())
185 pos += max(1, gr.Width())
186 }
187 start = pos
188 for byteStop > bytePos {
189 if !gr.Next() {
190 break
191 }
192 bytePos += len(gr.Str())
193 pos += max(1, gr.Width())
194 }
195 stop = pos
196 return start, stop
197}