1package dialog
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7 "time"
8
9 "charm.land/bubbles/v2/textinput"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/dustin/go-humanize"
17 "github.com/rivo/uniseg"
18 "github.com/sahilm/fuzzy"
19)
20
21// sameFuzzyMatch reports whether two fuzzy.Match values are
22// observably equal. Because Match contains a slice (MatchedIndexes)
23// it is not directly comparable with ==; we compare the scalar
24// fields and then walk the indexes. The dialog list items use this
25// to skip gratuitous version bumps when SetMatch reapplies the same
26// match.
27func sameFuzzyMatch(a, b fuzzy.Match) bool {
28 return a.Str == b.Str &&
29 a.Index == b.Index &&
30 a.Score == b.Score &&
31 slices.Equal(a.MatchedIndexes, b.MatchedIndexes)
32}
33
34// ListItem represents a selectable and searchable item in a dialog list.
35type ListItem interface {
36 list.FilterableItem
37 list.Focusable
38 list.MatchSettable
39
40 // ID returns the unique identifier of the item.
41 ID() string
42}
43
44// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
45type SessionItem struct {
46 *list.Versioned
47 session.Session
48 t *styles.Styles
49 sessionsMode sessionsMode
50 m fuzzy.Match
51 cache map[int]string
52 updateTitleInput textinput.Model
53 focused bool
54}
55
56// Finished implements list.Item. Session items are render-stable
57// outside of explicit SetFocused / SetMatch calls, both of which
58// bump Version() and therefore invalidate the F6 frozen entry.
59func (s *SessionItem) Finished() bool {
60 return true
61}
62
63var _ ListItem = &SessionItem{}
64
65// Filter returns the filterable value of the session.
66func (s *SessionItem) Filter() string {
67 return s.Title
68}
69
70// ID returns the unique identifier of the session.
71func (s *SessionItem) ID() string {
72 return s.Session.ID
73}
74
75// SetMatch sets the fuzzy match for the session item.
76func (s *SessionItem) SetMatch(m fuzzy.Match) {
77 if sameFuzzyMatch(s.m, m) {
78 return
79 }
80 s.cache = nil
81 s.m = m
82 if s.Versioned != nil {
83 s.Bump()
84 }
85}
86
87// InputValue returns the updated title value
88func (s *SessionItem) InputValue() string {
89 return s.updateTitleInput.Value()
90}
91
92// HandleInput forwards input message to the update title input
93func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd {
94 var cmd tea.Cmd
95 s.updateTitleInput, cmd = s.updateTitleInput.Update(msg)
96 if s.Versioned != nil {
97 s.Bump()
98 }
99 return cmd
100}
101
102// Cursor returns the cursor of the update title input
103func (s *SessionItem) Cursor() *tea.Cursor {
104 return s.updateTitleInput.Cursor()
105}
106
107// Render returns the string representation of the session item.
108func (s *SessionItem) Render(width int) string {
109 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
110 styles := ListItemStyles{
111 ItemBlurred: s.t.Dialog.NormalItem,
112 ItemFocused: s.t.Dialog.SelectedItem,
113 InfoTextBlurred: s.t.Dialog.Sessions.InfoBlurred,
114 InfoTextFocused: s.t.Dialog.Sessions.InfoFocused,
115 }
116
117 switch s.sessionsMode {
118 case sessionsModeDeleting:
119 styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
120 styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
121 case sessionsModeUpdating:
122 styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred
123 styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused
124 if s.focused {
125 const cursorPadding = 1
126 inputWidth := max(0, width-styles.ItemFocused.GetHorizontalFrameSize()-cursorPadding)
127 s.updateTitleInput.SetWidth(inputWidth)
128 s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…")
129 return styles.ItemFocused.Render(s.updateTitleInput.View())
130 }
131 }
132
133 return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
134}
135
136type ListItemStyles struct {
137 ItemBlurred lipgloss.Style
138 ItemFocused lipgloss.Style
139 InfoTextBlurred lipgloss.Style
140 InfoTextFocused lipgloss.Style
141}
142
143func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
144 if cache == nil {
145 cache = make(map[int]string)
146 }
147
148 cached, ok := cache[width]
149 if ok {
150 return cached
151 }
152
153 style := t.ItemBlurred
154 if focused {
155 style = t.ItemFocused
156 }
157
158 var infoText string
159 var infoWidth int
160 lineWidth := width
161 if len(info) > 0 {
162 infoText = fmt.Sprintf(" %s ", info)
163 if focused {
164 infoText = t.InfoTextFocused.Render(infoText)
165 } else {
166 infoText = t.InfoTextBlurred.Render(infoText)
167 }
168
169 infoWidth = lipgloss.Width(infoText)
170 }
171
172 title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "…")
173 titleWidth := lipgloss.Width(title)
174 gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
175 content := title
176 if m != nil && len(m.MatchedIndexes) > 0 {
177 var lastPos int
178 parts := make([]string, 0)
179 ranges := matchedRanges(m.MatchedIndexes)
180 for _, rng := range ranges {
181 start, stop := bytePosToVisibleCharPos(title, rng)
182 if start > lastPos {
183 parts = append(parts, ansi.Cut(title, lastPos, start))
184 }
185 // NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
186 // because we can control the underline start and stop more
187 // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
188 // which only affect the underline attribute without interfering
189 // with other style attributes.
190 parts = append(
191 parts,
192 ansi.NewStyle().Underline(true).String(),
193 ansi.Cut(title, start, stop+1),
194 ansi.NewStyle().Underline(false).String(),
195 )
196 lastPos = stop + 1
197 }
198 if lastPos < ansi.StringWidth(title) {
199 parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
200 }
201
202 content = strings.Join(parts, "")
203 }
204
205 content = style.Render(content + gap + infoText)
206 cache[width] = content
207 return content
208}
209
210// SetFocused sets the focus state of the session item.
211func (s *SessionItem) SetFocused(focused bool) {
212 if s.focused == focused {
213 return
214 }
215 s.cache = nil
216 s.focused = focused
217 if s.Versioned != nil {
218 s.Bump()
219 }
220}
221
222// sessionItems takes a slice of [session.Session]s and convert them to a slice
223// of [ListItem]s.
224func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
225 items := make([]list.FilterableItem, len(sessions))
226 for i, s := range sessions {
227 item := &SessionItem{Versioned: list.NewVersioned(), Session: s, t: t, sessionsMode: mode}
228 if mode == sessionsModeUpdating {
229 item.updateTitleInput = textinput.New()
230 item.updateTitleInput.SetVirtualCursor(false)
231 item.updateTitleInput.Prompt = ""
232 inputStyle := t.TextInput
233 inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
234 item.updateTitleInput.SetStyles(inputStyle)
235 item.updateTitleInput.Focus()
236 }
237 items[i] = item
238 }
239 return items
240}
241
242func matchedRanges(in []int) [][2]int {
243 if len(in) == 0 {
244 return [][2]int{}
245 }
246 current := [2]int{in[0], in[0]}
247 if len(in) == 1 {
248 return [][2]int{current}
249 }
250 var out [][2]int
251 for i := 1; i < len(in); i++ {
252 if in[i] == current[1]+1 {
253 current[1] = in[i]
254 } else {
255 out = append(out, current)
256 current = [2]int{in[i], in[i]}
257 }
258 }
259 out = append(out, current)
260 return out
261}
262
263func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
264 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
265 pos, start, stop := 0, 0, 0
266 gr := uniseg.NewGraphemes(str)
267 for byteStart > bytePos {
268 if !gr.Next() {
269 break
270 }
271 bytePos += len(gr.Str())
272 pos += max(1, gr.Width())
273 }
274 start = pos
275 for byteStop > bytePos {
276 if !gr.Next() {
277 break
278 }
279 bytePos += len(gr.Str())
280 pos += max(1, gr.Width())
281 }
282 stop = pos
283 return start, stop
284}