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