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