sessions_item.go

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