sessions_item.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"slices"
  6	"strings"
  7	"time"
  8
  9	"charm.land/bubbles/v2/textinput"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/charmbracelet/crush/internal/session"
 13	"github.com/charmbracelet/crush/internal/ui/list"
 14	"github.com/charmbracelet/crush/internal/ui/styles"
 15	"github.com/charmbracelet/x/ansi"
 16	"github.com/dustin/go-humanize"
 17	"github.com/rivo/uniseg"
 18	"github.com/sahilm/fuzzy"
 19)
 20
 21// sameFuzzyMatch reports whether two fuzzy.Match values are
 22// observably equal. Because Match contains a slice (MatchedIndexes)
 23// it is not directly comparable with ==; we compare the scalar
 24// fields and then walk the indexes. The dialog list items use this
 25// to skip gratuitous version bumps when SetMatch reapplies the same
 26// match.
 27func sameFuzzyMatch(a, b fuzzy.Match) bool {
 28	return a.Str == b.Str &&
 29		a.Index == b.Index &&
 30		a.Score == b.Score &&
 31		slices.Equal(a.MatchedIndexes, b.MatchedIndexes)
 32}
 33
 34// ListItem represents a selectable and searchable item in a dialog list.
 35type ListItem interface {
 36	list.FilterableItem
 37	list.Focusable
 38	list.MatchSettable
 39
 40	// ID returns the unique identifier of the item.
 41	ID() string
 42}
 43
 44// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 45type SessionItem struct {
 46	*list.Versioned
 47	session.Session
 48	t                *styles.Styles
 49	sessionsMode     sessionsMode
 50	m                fuzzy.Match
 51	cache            map[int]string
 52	updateTitleInput textinput.Model
 53	focused          bool
 54}
 55
 56// Finished implements list.Item. Session items are render-stable
 57// outside of explicit SetFocused / SetMatch calls, both of which
 58// bump Version() and therefore invalidate the F6 frozen entry.
 59func (s *SessionItem) Finished() bool {
 60	return true
 61}
 62
 63var _ ListItem = &SessionItem{}
 64
 65// Filter returns the filterable value of the session.
 66func (s *SessionItem) Filter() string {
 67	return s.Title
 68}
 69
 70// ID returns the unique identifier of the session.
 71func (s *SessionItem) ID() string {
 72	return s.Session.ID
 73}
 74
 75// SetMatch sets the fuzzy match for the session item.
 76func (s *SessionItem) SetMatch(m fuzzy.Match) {
 77	if sameFuzzyMatch(s.m, m) {
 78		return
 79	}
 80	s.cache = nil
 81	s.m = m
 82	if s.Versioned != nil {
 83		s.Bump()
 84	}
 85}
 86
 87// InputValue returns the updated title value
 88func (s *SessionItem) InputValue() string {
 89	return s.updateTitleInput.Value()
 90}
 91
 92// HandleInput forwards input message to the update title input
 93func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd {
 94	var cmd tea.Cmd
 95	s.updateTitleInput, cmd = s.updateTitleInput.Update(msg)
 96	return cmd
 97}
 98
 99// Cursor returns the cursor of the update title input
100func (s *SessionItem) Cursor() *tea.Cursor {
101	return s.updateTitleInput.Cursor()
102}
103
104// Render returns the string representation of the session item.
105func (s *SessionItem) Render(width int) string {
106	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
107	styles := ListItemStyles{
108		ItemBlurred:     s.t.Dialog.NormalItem,
109		ItemFocused:     s.t.Dialog.SelectedItem,
110		InfoTextBlurred: s.t.Dialog.Sessions.InfoBlurred,
111		InfoTextFocused: s.t.Dialog.Sessions.InfoFocused,
112	}
113
114	switch s.sessionsMode {
115	case sessionsModeDeleting:
116		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
117		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
118	case sessionsModeUpdating:
119		styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred
120		styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused
121		if s.focused {
122			const cursorPadding = 1
123			inputWidth := max(0, width-styles.ItemFocused.GetHorizontalFrameSize()-cursorPadding)
124			s.updateTitleInput.SetWidth(inputWidth)
125			s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…")
126			return styles.ItemFocused.Render(s.updateTitleInput.View())
127		}
128	}
129
130	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
131}
132
133type ListItemStyles struct {
134	ItemBlurred     lipgloss.Style
135	ItemFocused     lipgloss.Style
136	InfoTextBlurred lipgloss.Style
137	InfoTextFocused lipgloss.Style
138}
139
140func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
141	if cache == nil {
142		cache = make(map[int]string)
143	}
144
145	cached, ok := cache[width]
146	if ok {
147		return cached
148	}
149
150	style := t.ItemBlurred
151	if focused {
152		style = t.ItemFocused
153	}
154
155	var infoText string
156	var infoWidth int
157	lineWidth := width
158	if len(info) > 0 {
159		infoText = fmt.Sprintf(" %s ", info)
160		if focused {
161			infoText = t.InfoTextFocused.Render(infoText)
162		} else {
163			infoText = t.InfoTextBlurred.Render(infoText)
164		}
165
166		infoWidth = lipgloss.Width(infoText)
167	}
168
169	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "…")
170	titleWidth := lipgloss.Width(title)
171	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
172	content := title
173	if m != nil && len(m.MatchedIndexes) > 0 {
174		var lastPos int
175		parts := make([]string, 0)
176		ranges := matchedRanges(m.MatchedIndexes)
177		for _, rng := range ranges {
178			start, stop := bytePosToVisibleCharPos(title, rng)
179			if start > lastPos {
180				parts = append(parts, ansi.Cut(title, lastPos, start))
181			}
182			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
183			// because we can control the underline start and stop more
184			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
185			// which only affect the underline attribute without interfering
186			// with other style attributes.
187			parts = append(
188				parts,
189				ansi.NewStyle().Underline(true).String(),
190				ansi.Cut(title, start, stop+1),
191				ansi.NewStyle().Underline(false).String(),
192			)
193			lastPos = stop + 1
194		}
195		if lastPos < ansi.StringWidth(title) {
196			parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
197		}
198
199		content = strings.Join(parts, "")
200	}
201
202	content = style.Render(content + gap + infoText)
203	cache[width] = content
204	return content
205}
206
207// SetFocused sets the focus state of the session item.
208func (s *SessionItem) SetFocused(focused bool) {
209	if s.focused == focused {
210		return
211	}
212	s.cache = nil
213	s.focused = focused
214	if s.Versioned != nil {
215		s.Bump()
216	}
217}
218
219// sessionItems takes a slice of [session.Session]s and convert them to a slice
220// of [ListItem]s.
221func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
222	items := make([]list.FilterableItem, len(sessions))
223	for i, s := range sessions {
224		item := &SessionItem{Versioned: list.NewVersioned(), Session: s, t: t, sessionsMode: mode}
225		if mode == sessionsModeUpdating {
226			item.updateTitleInput = textinput.New()
227			item.updateTitleInput.SetVirtualCursor(false)
228			item.updateTitleInput.Prompt = ""
229			inputStyle := t.TextInput
230			inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
231			item.updateTitleInput.SetStyles(inputStyle)
232			item.updateTitleInput.Focus()
233		}
234		items[i] = item
235	}
236	return items
237}
238
239func matchedRanges(in []int) [][2]int {
240	if len(in) == 0 {
241		return [][2]int{}
242	}
243	current := [2]int{in[0], in[0]}
244	if len(in) == 1 {
245		return [][2]int{current}
246	}
247	var out [][2]int
248	for i := 1; i < len(in); i++ {
249		if in[i] == current[1]+1 {
250			current[1] = in[i]
251		} else {
252			out = append(out, current)
253			current = [2]int{in[i], in[i]}
254		}
255	}
256	out = append(out, current)
257	return out
258}
259
260func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
261	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
262	pos, start, stop := 0, 0, 0
263	gr := uniseg.NewGraphemes(str)
264	for byteStart > bytePos {
265		if !gr.Next() {
266			break
267		}
268		bytePos += len(gr.Str())
269		pos += max(1, gr.Width())
270	}
271	start = pos
272	for byteStop > bytePos {
273		if !gr.Next() {
274			break
275		}
276		bytePos += len(gr.Str())
277		pos += max(1, gr.Width())
278	}
279	stop = pos
280	return start, stop
281}