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