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