items.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.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}