1package dialog
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/ui/list"
11 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/dustin/go-humanize"
14 "github.com/rivo/uniseg"
15 "github.com/sahilm/fuzzy"
16)
17
18// ListItem represents a selectable and searchable item in a dialog list.
19type ListItem interface {
20 list.FilterableItem
21 list.Focusable
22 list.MatchSettable
23
24 // ID returns the unique identifier of the item.
25 ID() string
26}
27
28// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
29type SessionItem struct {
30 session.Session
31 t *styles.Styles
32 sessionsMode sessionsMode
33 m fuzzy.Match
34 cache map[int]string
35 focused bool
36}
37
38var _ ListItem = &SessionItem{}
39
40// Filter returns the filterable value of the session.
41func (s *SessionItem) Filter() string {
42 return s.Title
43}
44
45// ID returns the unique identifier of the session.
46func (s *SessionItem) ID() string {
47 return s.Session.ID
48}
49
50// SetMatch sets the fuzzy match for the session item.
51func (s *SessionItem) SetMatch(m fuzzy.Match) {
52 s.cache = nil
53 s.m = m
54}
55
56// Render returns the string representation of the session item.
57func (s *SessionItem) Render(width int) string {
58 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
59 styles := ListIemStyles{
60 ItemBlurred: s.t.Dialog.NormalItem,
61 ItemFocused: s.t.Dialog.SelectedItem,
62 InfoTextBlurred: s.t.Subtle,
63 InfoTextFocused: s.t.Base,
64 }
65
66 switch s.sessionsMode {
67 case sessionsModeDeleting:
68 styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
69 styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
70 }
71 return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
72}
73
74type ListIemStyles struct {
75 ItemBlurred lipgloss.Style
76 ItemFocused lipgloss.Style
77 InfoTextBlurred lipgloss.Style
78 InfoTextFocused lipgloss.Style
79}
80
81func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
82 if cache == nil {
83 cache = make(map[int]string)
84 }
85
86 cached, ok := cache[width]
87 if ok {
88 return cached
89 }
90
91 style := t.ItemBlurred
92 if focused {
93 style = t.ItemFocused
94 }
95
96 var infoText string
97 var infoWidth int
98 lineWidth := width
99 if len(info) > 0 {
100 infoText = fmt.Sprintf(" %s ", info)
101 if focused {
102 infoText = t.InfoTextFocused.Render(infoText)
103 } else {
104 infoText = t.InfoTextBlurred.Render(infoText)
105 }
106
107 infoWidth = lipgloss.Width(infoText)
108 }
109
110 title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
111 titleWidth := lipgloss.Width(title)
112 gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
113 content := title
114 if matches := len(m.MatchedIndexes); matches > 0 {
115 var lastPos int
116 parts := make([]string, 0)
117 ranges := matchedRanges(m.MatchedIndexes)
118 for _, rng := range ranges {
119 start, stop := bytePosToVisibleCharPos(title, rng)
120 if start > lastPos {
121 parts = append(parts, title[lastPos:start])
122 }
123 // NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
124 // because we can control the underline start and stop more
125 // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
126 // which only affect the underline attribute without interfering
127 // with other style
128 parts = append(parts,
129 ansi.NewStyle().Underline(true).String(),
130 title[start:stop+1],
131 ansi.NewStyle().Underline(false).String(),
132 )
133 lastPos = stop + 1
134 }
135 if lastPos < len(title) {
136 parts = append(parts, title[lastPos:])
137 }
138
139 content = strings.Join(parts, "")
140 }
141
142 content = style.Render(content + gap + infoText)
143 cache[width] = content
144 return content
145}
146
147// SetFocused sets the focus state of the session item.
148func (s *SessionItem) SetFocused(focused bool) {
149 if s.focused != focused {
150 s.cache = nil
151 }
152 s.focused = focused
153}
154
155// sessionItems takes a slice of [session.Session]s and convert them to a slice
156// of [ListItem]s.
157func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
158 items := make([]list.FilterableItem, len(sessions))
159 for i, s := range sessions {
160 items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
161 }
162 return items
163}
164
165func matchedRanges(in []int) [][2]int {
166 if len(in) == 0 {
167 return [][2]int{}
168 }
169 current := [2]int{in[0], in[0]}
170 if len(in) == 1 {
171 return [][2]int{current}
172 }
173 var out [][2]int
174 for i := 1; i < len(in); i++ {
175 if in[i] == current[1]+1 {
176 current[1] = in[i]
177 } else {
178 out = append(out, current)
179 current = [2]int{in[i], in[i]}
180 }
181 }
182 out = append(out, current)
183 return out
184}
185
186func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
187 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
188 pos, start, stop := 0, 0, 0
189 gr := uniseg.NewGraphemes(str)
190 for byteStart > bytePos {
191 if !gr.Next() {
192 break
193 }
194 bytePos += len(gr.Str())
195 pos += max(1, gr.Width())
196 }
197 start = pos
198 for byteStop > bytePos {
199 if !gr.Next() {
200 break
201 }
202 bytePos += len(gr.Str())
203 pos += max(1, gr.Width())
204 }
205 stop = pos
206 return start, stop
207}