6 tea "charm.land/bubbletea/v2"
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/tui/components/core"
9 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/util"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/google/uuid"
14 "github.com/rivo/uniseg"
17type Indexable interface {
21type CompletionItem[T any] interface {
30type completionItemCmp[T any] struct {
49type CompletionItemOption func(*options)
51func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
52 return func(cmp *options) {
57func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
58 return func(cmp *options) {
59 cmp.matchIndexes = indexes
63func WithCompletionShortcut(shortcut string) CompletionItemOption {
64 return func(cmp *options) {
65 cmp.shortcut = shortcut
69func WithCompletionID(id string) CompletionItemOption {
70 return func(cmp *options) {
75func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
76 c := &completionItemCmp[T]{
82 for _, opt := range opts {
86 o.id = uuid.NewString()
90 c.matchIndexes = o.matchIndexes
91 c.shortcut = o.shortcut
95// Init implements CommandItem.
96func (c *completionItemCmp[T]) Init() tea.Cmd {
100// Update implements CommandItem.
101func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
105// View implements CommandItem.
106func (c *completionItemCmp[T]) View() string {
107 t := styles.CurrentTheme()
109 itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
110 innerWidth := c.width - 2 // Account for padding
112 if c.shortcut != "" {
113 innerWidth -= lipgloss.Width(c.shortcut)
116 titleStyle := t.S().Text.Width(innerWidth)
117 titleMatchStyle := t.S().Text.Underline(true)
118 if c.bgColor != nil {
119 titleStyle = titleStyle.Background(c.bgColor)
120 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
121 itemStyle = itemStyle.Background(c.bgColor)
125 titleStyle = t.S().TextSelected.Width(innerWidth)
126 titleMatchStyle = t.S().TextSelected.Underline(true)
127 itemStyle = itemStyle.Background(t.Primary)
130 var truncatedTitle string
132 if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
133 // Smart truncation: ensure the last matching part is visible
134 truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
136 // No matches, use regular truncation
137 truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
140 text := titleStyle.Render(truncatedTitle)
141 if len(c.matchIndexes) > 0 {
142 var ranges []lipgloss.Range
143 for _, rng := range matchedRanges(c.matchIndexes) {
144 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
145 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
146 // so we need to adjust it here:
147 start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
148 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
150 text = lipgloss.StyleRanges(text, ranges...)
152 parts := []string{text}
153 if c.shortcut != "" {
154 // Add the shortcut at the end
155 shortcutStyle := t.S().Muted
157 shortcutStyle = t.S().TextSelected
159 parts = append(parts, shortcutStyle.Render(c.shortcut))
161 item := itemStyle.Render(
162 lipgloss.JoinHorizontal(
170// Blur implements CommandItem.
171func (c *completionItemCmp[T]) Blur() tea.Cmd {
176// Focus implements CommandItem.
177func (c *completionItemCmp[T]) Focus() tea.Cmd {
182// GetSize implements CommandItem.
183func (c *completionItemCmp[T]) GetSize() (int, int) {
187// IsFocused implements CommandItem.
188func (c *completionItemCmp[T]) IsFocused() bool {
192// SetSize implements CommandItem.
193func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
198func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
199 c.matchIndexes = indexes
202func (c *completionItemCmp[T]) FilterValue() string {
206func (c *completionItemCmp[T]) Value() T {
210// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
211func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
216 textLen := ansi.StringWidth(text)
217 if textLen <= width {
221 if len(matchIndexes) == 0 {
222 return ansi.Truncate(text, width, "…")
225 // Find the last match position
226 lastMatchPos := matchIndexes[len(matchIndexes)-1]
228 // Convert byte position to visual width position
229 lastMatchVisualPos := 0
231 gr := uniseg.NewGraphemes(text)
232 for bytePos < lastMatchPos && gr.Next() {
233 bytePos += len(gr.Str())
234 lastMatchVisualPos += max(1, gr.Width())
237 // Calculate how much space we need for the ellipsis
238 ellipsisWidth := 1 // "…" character width
239 availableWidth := width - ellipsisWidth
241 // If the last match is within the available width, truncate from the end
242 if lastMatchVisualPos < availableWidth {
243 return ansi.Truncate(text, width, "…")
246 // Calculate the start position to ensure the last match is visible
247 // We want to show some context before the last match if possible
248 startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
250 // Convert visual position back to byte position
252 currentVisualPos := 0
253 gr = uniseg.NewGraphemes(text)
254 for currentVisualPos < startVisualPos && gr.Next() {
255 startBytePos += len(gr.Str())
256 currentVisualPos += max(1, gr.Width())
259 // Extract the substring starting from startBytePos
260 truncatedText := text[startBytePos:]
262 // Truncate to fit width with ellipsis
263 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
264 truncatedText = "…" + truncatedText
268func matchedRanges(in []int) [][2]int {
272 current := [2]int{in[0], in[0]}
274 return [][2]int{current}
277 for i := 1; i < len(in); i++ {
278 if in[i] == current[1]+1 {
281 out = append(out, current)
282 current = [2]int{in[i], in[i]}
285 out = append(out, current)
289func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
290 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
291 pos, start, stop := 0, 0, 0
292 gr := uniseg.NewGraphemes(str)
293 for byteStart > bytePos {
297 bytePos += len(gr.Str())
298 pos += max(1, gr.Width())
301 for byteStop > bytePos {
305 bytePos += len(gr.Str())
306 pos += max(1, gr.Width())
312// ID implements CompletionItem.
313func (c *completionItemCmp[T]) ID() string {
317func (c *completionItemCmp[T]) Text() string {
321type ItemSection interface {
327type itemSectionModel struct {
335// ID implements ItemSection.
336func (m *itemSectionModel) ID() string {
340func NewItemSection(title string) ItemSection {
341 return &itemSectionModel{
344 id: uuid.NewString(),
348func (m *itemSectionModel) Init() tea.Cmd {
352func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
356func (m *itemSectionModel) View() string {
357 t := styles.CurrentTheme()
358 title := ansi.Truncate(m.title, m.width-2, "…")
359 style := t.S().Base.Padding(1, 1, 0, 1)
361 style = style.Padding(0, 1, 0, 1)
363 title = t.S().Muted.Render(title)
366 section = core.SectionWithInfo(title, m.width-2, m.info)
368 section = core.Section(title, m.width-2)
371 return style.Render(section)
374func (m *itemSectionModel) GetSize() (int, int) {
378func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
383func (m *itemSectionModel) IsSectionHeader() bool {
387func (m *itemSectionModel) SetInfo(info string) {
391func (m *itemSectionModel) SetIndex(inx int) {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list