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	sessionsMode sessionsMode
 33	m            fuzzy.Match
 34	cache        map[int]string
 35	focused      bool
 36}
 37
 38var _ ListItem = &SessionItem{}
 39
 40// Filter returns the filterable value of the session.
 41func (s *SessionItem) Filter() string {
 42	return s.Title
 43}
 44
 45// ID returns the unique identifier of the session.
 46func (s *SessionItem) ID() string {
 47	return s.Session.ID
 48}
 49
 50// SetMatch sets the fuzzy match for the session item.
 51func (s *SessionItem) SetMatch(m fuzzy.Match) {
 52	s.cache = nil
 53	s.m = m
 54}
 55
 56// Render returns the string representation of the session item.
 57func (s *SessionItem) Render(width int) string {
 58	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
 59	styles := ListIemStyles{
 60		ItemBlurred:     s.t.Dialog.NormalItem,
 61		ItemFocused:     s.t.Dialog.SelectedItem,
 62		InfoTextBlurred: s.t.Subtle,
 63		InfoTextFocused: s.t.Base,
 64	}
 65
 66	switch s.sessionsMode {
 67	case sessionsModeDeleting:
 68		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
 69		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
 70	}
 71	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
 72}
 73
 74type ListIemStyles struct {
 75	ItemBlurred     lipgloss.Style
 76	ItemFocused     lipgloss.Style
 77	InfoTextBlurred lipgloss.Style
 78	InfoTextFocused lipgloss.Style
 79}
 80
 81func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
 82	if cache == nil {
 83		cache = make(map[int]string)
 84	}
 85
 86	cached, ok := cache[width]
 87	if ok {
 88		return cached
 89	}
 90
 91	style := t.ItemBlurred
 92	if focused {
 93		style = t.ItemFocused
 94	}
 95
 96	var infoText string
 97	var infoWidth int
 98	lineWidth := width
 99	if len(info) > 0 {
100		infoText = fmt.Sprintf(" %s ", info)
101		if focused {
102			infoText = t.InfoTextFocused.Render(infoText)
103		} else {
104			infoText = t.InfoTextBlurred.Render(infoText)
105		}
106
107		infoWidth = lipgloss.Width(infoText)
108	}
109
110	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
111	titleWidth := lipgloss.Width(title)
112	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
113	content := title
114	if matches := len(m.MatchedIndexes); matches > 0 {
115		var lastPos int
116		parts := make([]string, 0)
117		ranges := matchedRanges(m.MatchedIndexes)
118		for _, rng := range ranges {
119			start, stop := bytePosToVisibleCharPos(title, rng)
120			if start > lastPos {
121				parts = append(parts, title[lastPos:start])
122			}
123			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
124			// because we can control the underline start and stop more
125			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
126			// which only affect the underline attribute without interfering
127			// with other style
128			parts = append(parts,
129				ansi.NewStyle().Underline(true).String(),
130				title[start:stop+1],
131				ansi.NewStyle().Underline(false).String(),
132			)
133			lastPos = stop + 1
134		}
135		if lastPos < len(title) {
136			parts = append(parts, title[lastPos:])
137		}
138
139		content = strings.Join(parts, "")
140	}
141
142	content = style.Render(content + gap + infoText)
143	cache[width] = content
144	return content
145}
146
147// SetFocused sets the focus state of the session item.
148func (s *SessionItem) SetFocused(focused bool) {
149	if s.focused != focused {
150		s.cache = nil
151	}
152	s.focused = focused
153}
154
155// sessionItems takes a slice of [session.Session]s and convert them to a slice
156// of [ListItem]s.
157func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
158	items := make([]list.FilterableItem, len(sessions))
159	for i, s := range sessions {
160		items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
161	}
162	return items
163}
164
165func matchedRanges(in []int) [][2]int {
166	if len(in) == 0 {
167		return [][2]int{}
168	}
169	current := [2]int{in[0], in[0]}
170	if len(in) == 1 {
171		return [][2]int{current}
172	}
173	var out [][2]int
174	for i := 1; i < len(in); i++ {
175		if in[i] == current[1]+1 {
176			current[1] = in[i]
177		} else {
178			out = append(out, current)
179			current = [2]int{in[i], in[i]}
180		}
181	}
182	out = append(out, current)
183	return out
184}
185
186func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
187	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
188	pos, start, stop := 0, 0, 0
189	gr := uniseg.NewGraphemes(str)
190	for byteStart > bytePos {
191		if !gr.Next() {
192			break
193		}
194		bytePos += len(gr.Str())
195		pos += max(1, gr.Width())
196	}
197	start = pos
198	for byteStop > bytePos {
199		if !gr.Next() {
200			break
201		}
202		bytePos += len(gr.Str())
203		pos += max(1, gr.Width())
204	}
205	stop = pos
206	return start, stop
207}