sessions_item.go

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