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(parts,
188				ansi.NewStyle().Underline(true).String(),
189				ansi.Cut(title, start, stop+1),
190				ansi.NewStyle().Underline(false).String(),
191			)
192			lastPos = stop + 1
193		}
194		if lastPos < ansi.StringWidth(title) {
195			parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
196		}
197
198		content = strings.Join(parts, "")
199	}
200
201	content = style.Render(content + gap + infoText)
202	cache[width] = content
203	return content
204}
205
206// SetFocused sets the focus state of the session item.
207func (s *SessionItem) SetFocused(focused bool) {
208	if s.focused == focused {
209		return
210	}
211	s.cache = nil
212	s.focused = focused
213	if s.Versioned != nil {
214		s.Bump()
215	}
216}
217
218// sessionItems takes a slice of [session.Session]s and convert them to a slice
219// of [ListItem]s.
220func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
221	items := make([]list.FilterableItem, len(sessions))
222	for i, s := range sessions {
223		item := &SessionItem{Versioned: list.NewVersioned(), Session: s, t: t, sessionsMode: mode}
224		if mode == sessionsModeUpdating {
225			item.updateTitleInput = textinput.New()
226			item.updateTitleInput.SetVirtualCursor(false)
227			item.updateTitleInput.Prompt = ""
228			inputStyle := t.TextInput
229			inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
230			item.updateTitleInput.SetStyles(inputStyle)
231			item.updateTitleInput.Focus()
232		}
233		items[i] = item
234	}
235	return items
236}
237
238func matchedRanges(in []int) [][2]int {
239	if len(in) == 0 {
240		return [][2]int{}
241	}
242	current := [2]int{in[0], in[0]}
243	if len(in) == 1 {
244		return [][2]int{current}
245	}
246	var out [][2]int
247	for i := 1; i < len(in); i++ {
248		if in[i] == current[1]+1 {
249			current[1] = in[i]
250		} else {
251			out = append(out, current)
252			current = [2]int{in[i], in[i]}
253		}
254	}
255	out = append(out, current)
256	return out
257}
258
259func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
260	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
261	pos, start, stop := 0, 0, 0
262	gr := uniseg.NewGraphemes(str)
263	for byteStart > bytePos {
264		if !gr.Next() {
265			break
266		}
267		bytePos += len(gr.Str())
268		pos += max(1, gr.Width())
269	}
270	start = pos
271	for byteStop > bytePos {
272		if !gr.Next() {
273			break
274		}
275		bytePos += len(gr.Str())
276		pos += max(1, gr.Width())
277	}
278	stop = pos
279	return start, stop
280}