| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- package list
- import (
- "image/color"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/google/uuid"
- "github.com/rivo/uniseg"
- )
- type Indexable interface {
- SetIndex(int)
- }
- type CompletionItem[T any] interface {
- FilterableItem
- layout.Focusable
- layout.Sizeable
- HasMatchIndexes
- Value() T
- Text() string
- }
- type completionItemCmp[T any] struct {
- width int
- id string
- text string
- value T
- focus bool
- matchIndexes []int
- bgColor color.Color
- shortcut string
- }
- type options struct {
- id string
- text string
- bgColor color.Color
- matchIndexes []int
- shortcut string
- }
- type CompletionItemOption func(*options)
- func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
- return func(cmp *options) {
- cmp.bgColor = c
- }
- }
- func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
- return func(cmp *options) {
- cmp.matchIndexes = indexes
- }
- }
- func WithCompletionShortcut(shortcut string) CompletionItemOption {
- return func(cmp *options) {
- cmp.shortcut = shortcut
- }
- }
- func WithCompletionID(id string) CompletionItemOption {
- return func(cmp *options) {
- cmp.id = id
- }
- }
- func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
- c := &completionItemCmp[T]{
- text: text,
- value: value,
- }
- o := &options{}
- for _, opt := range opts {
- opt(o)
- }
- if o.id == "" {
- o.id = uuid.NewString()
- }
- c.id = o.id
- c.bgColor = o.bgColor
- c.matchIndexes = o.matchIndexes
- c.shortcut = o.shortcut
- return c
- }
- // Init implements CommandItem.
- func (c *completionItemCmp[T]) Init() tea.Cmd {
- return nil
- }
- // Update implements CommandItem.
- func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
- return c, nil
- }
- // View implements CommandItem.
- func (c *completionItemCmp[T]) View() string {
- t := styles.CurrentTheme()
- itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
- innerWidth := c.width - 2 // Account for padding
- if c.shortcut != "" {
- innerWidth -= lipgloss.Width(c.shortcut)
- }
- titleStyle := t.S().Text.Width(innerWidth)
- titleMatchStyle := t.S().Text.Underline(true)
- if c.bgColor != nil {
- titleStyle = titleStyle.Background(c.bgColor)
- titleMatchStyle = titleMatchStyle.Background(c.bgColor)
- itemStyle = itemStyle.Background(c.bgColor)
- }
- if c.focus {
- titleStyle = t.S().TextSelected.Width(innerWidth)
- titleMatchStyle = t.S().TextSelected.Underline(true)
- itemStyle = itemStyle.Background(t.Primary)
- }
- var truncatedTitle string
- if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
- // Smart truncation: ensure the last matching part is visible
- truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
- } else {
- // No matches, use regular truncation
- truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
- }
- text := titleStyle.Render(truncatedTitle)
- if len(c.matchIndexes) > 0 {
- var ranges []lipgloss.Range
- for _, rng := range matchedRanges(c.matchIndexes) {
- // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
- // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
- // so we need to adjust it here:
- start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
- ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
- }
- text = lipgloss.StyleRanges(text, ranges...)
- }
- parts := []string{text}
- if c.shortcut != "" {
- // Add the shortcut at the end
- shortcutStyle := t.S().Muted
- if c.focus {
- shortcutStyle = t.S().TextSelected
- }
- parts = append(parts, shortcutStyle.Render(c.shortcut))
- }
- item := itemStyle.Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- parts...,
- ),
- )
- return item
- }
- // Blur implements CommandItem.
- func (c *completionItemCmp[T]) Blur() tea.Cmd {
- c.focus = false
- return nil
- }
- // Focus implements CommandItem.
- func (c *completionItemCmp[T]) Focus() tea.Cmd {
- c.focus = true
- return nil
- }
- // GetSize implements CommandItem.
- func (c *completionItemCmp[T]) GetSize() (int, int) {
- return c.width, 1
- }
- // IsFocused implements CommandItem.
- func (c *completionItemCmp[T]) IsFocused() bool {
- return c.focus
- }
- // SetSize implements CommandItem.
- func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
- c.width = width
- return nil
- }
- func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
- c.matchIndexes = indexes
- }
- func (c *completionItemCmp[T]) FilterValue() string {
- return c.text
- }
- func (c *completionItemCmp[T]) Value() T {
- return c.value
- }
- // smartTruncate implements fzf-style truncation that ensures the last matching part is visible
- func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
- if width <= 0 {
- return ""
- }
- textLen := ansi.StringWidth(text)
- if textLen <= width {
- return text
- }
- if len(matchIndexes) == 0 {
- return ansi.Truncate(text, width, "…")
- }
- // Find the last match position
- lastMatchPos := matchIndexes[len(matchIndexes)-1]
- // Convert byte position to visual width position
- lastMatchVisualPos := 0
- bytePos := 0
- gr := uniseg.NewGraphemes(text)
- for bytePos < lastMatchPos && gr.Next() {
- bytePos += len(gr.Str())
- lastMatchVisualPos += max(1, gr.Width())
- }
- // Calculate how much space we need for the ellipsis
- ellipsisWidth := 1 // "…" character width
- availableWidth := width - ellipsisWidth
- // If the last match is within the available width, truncate from the end
- if lastMatchVisualPos < availableWidth {
- return ansi.Truncate(text, width, "…")
- }
- // Calculate the start position to ensure the last match is visible
- // We want to show some context before the last match if possible
- startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
- // Convert visual position back to byte position
- startBytePos := 0
- currentVisualPos := 0
- gr = uniseg.NewGraphemes(text)
- for currentVisualPos < startVisualPos && gr.Next() {
- startBytePos += len(gr.Str())
- currentVisualPos += max(1, gr.Width())
- }
- // Extract the substring starting from startBytePos
- truncatedText := text[startBytePos:]
- // Truncate to fit width with ellipsis
- truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
- truncatedText = "…" + truncatedText
- return truncatedText
- }
- func matchedRanges(in []int) [][2]int {
- if len(in) == 0 {
- return [][2]int{}
- }
- current := [2]int{in[0], in[0]}
- if len(in) == 1 {
- return [][2]int{current}
- }
- var out [][2]int
- for i := 1; i < len(in); i++ {
- if in[i] == current[1]+1 {
- current[1] = in[i]
- } else {
- out = append(out, current)
- current = [2]int{in[i], in[i]}
- }
- }
- out = append(out, current)
- return out
- }
- func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
- bytePos, byteStart, byteStop := 0, rng[0], rng[1]
- pos, start, stop := 0, 0, 0
- gr := uniseg.NewGraphemes(str)
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- start = pos
- for byteStop > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- stop = pos
- return start, stop
- }
- // ID implements CompletionItem.
- func (c *completionItemCmp[T]) ID() string {
- return c.id
- }
- func (c *completionItemCmp[T]) Text() string {
- return c.text
- }
- type ItemSection interface {
- Item
- layout.Sizeable
- Indexable
- SetInfo(info string)
- }
- type itemSectionModel struct {
- width int
- title string
- inx int
- id string
- info string
- }
- // ID implements ItemSection.
- func (m *itemSectionModel) ID() string {
- return m.id
- }
- func NewItemSection(title string) ItemSection {
- return &itemSectionModel{
- title: title,
- inx: -1,
- id: uuid.NewString(),
- }
- }
- func (m *itemSectionModel) Init() tea.Cmd {
- return nil
- }
- func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
- return m, nil
- }
- func (m *itemSectionModel) View() string {
- t := styles.CurrentTheme()
- title := ansi.Truncate(m.title, m.width-2, "…")
- style := t.S().Base.Padding(1, 1, 0, 1)
- if m.inx == 0 {
- style = style.Padding(0, 1, 0, 1)
- }
- title = t.S().Muted.Render(title)
- section := ""
- if m.info != "" {
- section = core.SectionWithInfo(title, m.width-2, m.info)
- } else {
- section = core.Section(title, m.width-2)
- }
- return style.Render(section)
- }
- func (m *itemSectionModel) GetSize() (int, int) {
- return m.width, 1
- }
- func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
- m.width = width
- return nil
- }
- func (m *itemSectionModel) IsSectionHeader() bool {
- return true
- }
- func (m *itemSectionModel) SetInfo(info string) {
- m.info = info
- }
- func (m *itemSectionModel) SetIndex(inx int) {
- m.inx = inx
- }
|