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	if s.Versioned != nil {
 97		s.Bump()
 98	}
 99	return cmd
100}
101
102// Cursor returns the cursor of the update title input
103func (s *SessionItem) Cursor() *tea.Cursor {
104	return s.updateTitleInput.Cursor()
105}
106
107// Render returns the string representation of the session item.
108func (s *SessionItem) Render(width int) string {
109	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
110	styles := ListItemStyles{
111		ItemBlurred:     s.t.Dialog.NormalItem,
112		ItemFocused:     s.t.Dialog.SelectedItem,
113		InfoTextBlurred: s.t.Dialog.Sessions.InfoBlurred,
114		InfoTextFocused: s.t.Dialog.Sessions.InfoFocused,
115	}
116
117	switch s.sessionsMode {
118	case sessionsModeDeleting:
119		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
120		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
121	case sessionsModeUpdating:
122		styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred
123		styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused
124		if s.focused {
125			const cursorPadding = 1
126			inputWidth := max(0, width-styles.ItemFocused.GetHorizontalFrameSize()-cursorPadding)
127			s.updateTitleInput.SetWidth(inputWidth)
128			s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…")
129			return styles.ItemFocused.Render(s.updateTitleInput.View())
130		}
131	}
132
133	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
134}
135
136type ListItemStyles struct {
137	ItemBlurred     lipgloss.Style
138	ItemFocused     lipgloss.Style
139	InfoTextBlurred lipgloss.Style
140	InfoTextFocused lipgloss.Style
141}
142
143func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
144	if cache == nil {
145		cache = make(map[int]string)
146	}
147
148	cached, ok := cache[width]
149	if ok {
150		return cached
151	}
152
153	style := t.ItemBlurred
154	if focused {
155		style = t.ItemFocused
156	}
157
158	var infoText string
159	var infoWidth int
160	lineWidth := width
161	if len(info) > 0 {
162		infoText = fmt.Sprintf(" %s ", info)
163		if focused {
164			infoText = t.InfoTextFocused.Render(infoText)
165		} else {
166			infoText = t.InfoTextBlurred.Render(infoText)
167		}
168
169		infoWidth = lipgloss.Width(infoText)
170	}
171
172	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "…")
173	titleWidth := lipgloss.Width(title)
174	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
175	content := title
176	if m != nil && len(m.MatchedIndexes) > 0 {
177		var lastPos int
178		parts := make([]string, 0)
179		ranges := matchedRanges(m.MatchedIndexes)
180		for _, rng := range ranges {
181			start, stop := bytePosToVisibleCharPos(title, rng)
182			if start > lastPos {
183				parts = append(parts, ansi.Cut(title, lastPos, start))
184			}
185			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
186			// because we can control the underline start and stop more
187			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
188			// which only affect the underline attribute without interfering
189			// with other style attributes.
190			parts = append(
191				parts,
192				ansi.NewStyle().Underline(true).String(),
193				ansi.Cut(title, start, stop+1),
194				ansi.NewStyle().Underline(false).String(),
195			)
196			lastPos = stop + 1
197		}
198		if lastPos < ansi.StringWidth(title) {
199			parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
200		}
201
202		content = strings.Join(parts, "")
203	}
204
205	content = style.Render(content + gap + infoText)
206	cache[width] = content
207	return content
208}
209
210// SetFocused sets the focus state of the session item.
211func (s *SessionItem) SetFocused(focused bool) {
212	if s.focused == focused {
213		return
214	}
215	s.cache = nil
216	s.focused = focused
217	if s.Versioned != nil {
218		s.Bump()
219	}
220}
221
222// sessionItems takes a slice of [session.Session]s and convert them to a slice
223// of [ListItem]s.
224func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
225	items := make([]list.FilterableItem, len(sessions))
226	for i, s := range sessions {
227		item := &SessionItem{Versioned: list.NewVersioned(), Session: s, t: t, sessionsMode: mode}
228		if mode == sessionsModeUpdating {
229			item.updateTitleInput = textinput.New()
230			item.updateTitleInput.SetVirtualCursor(false)
231			item.updateTitleInput.Prompt = ""
232			inputStyle := t.TextInput
233			inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
234			item.updateTitleInput.SetStyles(inputStyle)
235			item.updateTitleInput.Focus()
236		}
237		items[i] = item
238	}
239	return items
240}
241
242func matchedRanges(in []int) [][2]int {
243	if len(in) == 0 {
244		return [][2]int{}
245	}
246	current := [2]int{in[0], in[0]}
247	if len(in) == 1 {
248		return [][2]int{current}
249	}
250	var out [][2]int
251	for i := 1; i < len(in); i++ {
252		if in[i] == current[1]+1 {
253			current[1] = in[i]
254		} else {
255			out = append(out, current)
256			current = [2]int{in[i], in[i]}
257		}
258	}
259	out = append(out, current)
260	return out
261}
262
263func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
264	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
265	pos, start, stop := 0, 0, 0
266	gr := uniseg.NewGraphemes(str)
267	for byteStart > bytePos {
268		if !gr.Next() {
269			break
270		}
271		bytePos += len(gr.Str())
272		pos += max(1, gr.Width())
273	}
274	start = pos
275	for byteStop > bytePos {
276		if !gr.Next() {
277			break
278		}
279		bytePos += len(gr.Str())
280		pos += max(1, gr.Width())
281	}
282	stop = pos
283	return start, stop
284}