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