sessions_item.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"charm.land/lipgloss/v2"
  9	"github.com/charmbracelet/crush/internal/session"
 10	"github.com/charmbracelet/crush/internal/ui/list"
 11	"github.com/charmbracelet/crush/internal/ui/styles"
 12	"github.com/charmbracelet/x/ansi"
 13	"github.com/dustin/go-humanize"
 14	"github.com/rivo/uniseg"
 15	"github.com/sahilm/fuzzy"
 16)
 17
 18// ListItem represents a selectable and searchable item in a dialog list.
 19type ListItem interface {
 20	list.FilterableItem
 21	list.Focusable
 22	list.MatchSettable
 23
 24	// ID returns the unique identifier of the item.
 25	ID() string
 26}
 27
 28// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 29type SessionItem struct {
 30	session.Session
 31	t       *styles.Styles
 32	m       fuzzy.Match
 33	cache   map[int]string
 34	focused bool
 35}
 36
 37var _ ListItem = &SessionItem{}
 38
 39// Filter returns the filterable value of the session.
 40func (s *SessionItem) Filter() string {
 41	return s.Title
 42}
 43
 44// ID returns the unique identifier of the session.
 45func (s *SessionItem) ID() string {
 46	return s.Session.ID
 47}
 48
 49// SetMatch sets the fuzzy match for the session item.
 50func (s *SessionItem) SetMatch(m fuzzy.Match) {
 51	s.cache = nil
 52	s.m = m
 53}
 54
 55// Render returns the string representation of the session item.
 56func (s *SessionItem) Render(width int) string {
 57	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
 58	return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
 59}
 60
 61func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
 62	if cache == nil {
 63		cache = make(map[int]string)
 64	}
 65
 66	cached, ok := cache[width]
 67	if ok {
 68		return cached
 69	}
 70
 71	style := t.Dialog.NormalItem
 72	if focused {
 73		style = t.Dialog.SelectedItem
 74	}
 75
 76	var infoText string
 77	var infoLen int
 78	lineWidth := width
 79	if len(info) > 0 {
 80		infoText = fmt.Sprintf(" %s ", info)
 81		if focused {
 82			infoText = t.Base.Render(infoText)
 83		} else {
 84			infoText = t.Subtle.Render(infoText)
 85		}
 86
 87		infoLen = lipgloss.Width(infoText)
 88	}
 89
 90	title = ansi.Truncate(title, max(0, lineWidth), "")
 91	titleLen := lipgloss.Width(title)
 92	gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen))
 93	content := title
 94	if matches := len(m.MatchedIndexes); matches > 0 {
 95		var lastPos int
 96		parts := make([]string, 0)
 97		ranges := matchedRanges(m.MatchedIndexes)
 98		for _, rng := range ranges {
 99			start, stop := bytePosToVisibleCharPos(title, rng)
100			if start > lastPos {
101				parts = append(parts, title[lastPos:start])
102			}
103			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
104			// because we can control the underline start and stop more
105			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
106			// which only affect the underline attribute without interfering
107			// with other style
108			parts = append(parts,
109				ansi.NewStyle().Underline(true).String(),
110				title[start:stop+1],
111				ansi.NewStyle().Underline(false).String(),
112			)
113			lastPos = stop + 1
114		}
115		if lastPos < len(title) {
116			parts = append(parts, title[lastPos:])
117		}
118
119		content = strings.Join(parts, "")
120	}
121
122	content = style.Render(content + gap + infoText)
123	cache[width] = content
124	return content
125}
126
127// SetFocused sets the focus state of the session item.
128func (s *SessionItem) SetFocused(focused bool) {
129	if s.focused != focused {
130		s.cache = nil
131	}
132	s.focused = focused
133}
134
135// sessionItems takes a slice of [session.Session]s and convert them to a slice
136// of [ListItem]s.
137func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
138	items := make([]list.FilterableItem, len(sessions))
139	for i, s := range sessions {
140		items[i] = &SessionItem{Session: s, t: t}
141	}
142	return items
143}
144
145func matchedRanges(in []int) [][2]int {
146	if len(in) == 0 {
147		return [][2]int{}
148	}
149	current := [2]int{in[0], in[0]}
150	if len(in) == 1 {
151		return [][2]int{current}
152	}
153	var out [][2]int
154	for i := 1; i < len(in); i++ {
155		if in[i] == current[1]+1 {
156			current[1] = in[i]
157		} else {
158			out = append(out, current)
159			current = [2]int{in[i], in[i]}
160		}
161	}
162	out = append(out, current)
163	return out
164}
165
166func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
167	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
168	pos, start, stop := 0, 0, 0
169	gr := uniseg.NewGraphemes(str)
170	for byteStart > bytePos {
171		if !gr.Next() {
172			break
173		}
174		bytePos += len(gr.Str())
175		pos += max(1, gr.Width())
176	}
177	start = pos
178	for byteStop > bytePos {
179		if !gr.Next() {
180			break
181		}
182		bytePos += len(gr.Str())
183		pos += max(1, gr.Width())
184	}
185	stop = pos
186	return start, stop
187}