item.go

  1package commands
  2
  3import (
  4	tea "github.com/charmbracelet/bubbletea/v2"
  5	"github.com/charmbracelet/lipgloss/v2"
  6	"github.com/charmbracelet/x/ansi"
  7	"github.com/opencode-ai/opencode/internal/tui/layout"
  8	"github.com/opencode-ai/opencode/internal/tui/styles"
  9	"github.com/opencode-ai/opencode/internal/tui/theme"
 10	"github.com/opencode-ai/opencode/internal/tui/util"
 11	"github.com/rivo/uniseg"
 12)
 13
 14type CommandItem interface {
 15	util.Model
 16	layout.Focusable
 17	layout.Sizeable
 18}
 19
 20type commandItem struct {
 21	width        int
 22	command      Command
 23	focus        bool
 24	matchIndexes []int
 25}
 26
 27func NewCommandItem(command Command) CommandItem {
 28	return &commandItem{
 29		command:      command,
 30		matchIndexes: make([]int, 0),
 31	}
 32}
 33
 34// Init implements CommandItem.
 35func (c *commandItem) Init() tea.Cmd {
 36	return nil
 37}
 38
 39// Update implements CommandItem.
 40func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
 41	return c, nil
 42}
 43
 44// View implements CommandItem.
 45func (c *commandItem) View() tea.View {
 46	t := theme.CurrentTheme()
 47
 48	baseStyle := styles.BaseStyle()
 49	titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
 50	titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
 51
 52	if c.focus {
 53		titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
 54		titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
 55	}
 56	var ranges []lipgloss.Range
 57	truncatedTitle := ansi.Truncate(c.command.Title, c.width-2, "…")
 58	text := titleStyle.Padding(0, 1).Render(truncatedTitle)
 59	if len(c.matchIndexes) > 0 {
 60		for _, rng := range matchedRanges(c.matchIndexes) {
 61			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
 62			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
 63			// so we need to adjust it here:
 64			start, stop := bytePosToVisibleCharPos(text, rng)
 65			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, titleMatchStyle))
 66		}
 67		text = lipgloss.StyleRanges(text, ranges...)
 68	}
 69	return tea.NewView(text)
 70}
 71
 72// Blur implements CommandItem.
 73func (c *commandItem) Blur() tea.Cmd {
 74	c.focus = false
 75	return nil
 76}
 77
 78// Focus implements CommandItem.
 79func (c *commandItem) Focus() tea.Cmd {
 80	c.focus = true
 81	return nil
 82}
 83
 84// IsFocused implements CommandItem.
 85func (c *commandItem) IsFocused() bool {
 86	return c.focus
 87}
 88
 89// GetSize implements CommandItem.
 90func (c *commandItem) GetSize() (int, int) {
 91	return c.width, 2
 92}
 93
 94// SetSize implements CommandItem.
 95func (c *commandItem) SetSize(width int, height int) tea.Cmd {
 96	c.width = width
 97	return nil
 98}
 99
100func (c *commandItem) FilterValue() string {
101	return c.command.Title
102}
103
104func (c *commandItem) MatchIndexes(indexes []int) {
105	c.matchIndexes = indexes
106}
107
108func matchedRanges(in []int) [][2]int {
109	if len(in) == 0 {
110		return [][2]int{}
111	}
112	current := [2]int{in[0], in[0]}
113	if len(in) == 1 {
114		return [][2]int{current}
115	}
116	var out [][2]int
117	for i := 1; i < len(in); i++ {
118		if in[i] == current[1]+1 {
119			current[1] = in[i]
120		} else {
121			out = append(out, current)
122			current = [2]int{in[i], in[i]}
123		}
124	}
125	out = append(out, current)
126	return out
127}
128
129func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
130	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
131	pos, start, stop := 0, 0, 0
132	gr := uniseg.NewGraphemes(str)
133	for byteStart > bytePos {
134		if !gr.Next() {
135			break
136		}
137		bytePos += len(gr.Str())
138		pos += max(1, gr.Width())
139	}
140	start = pos
141	for byteStop > bytePos {
142		if !gr.Next() {
143			break
144		}
145		bytePos += len(gr.Str())
146		pos += max(1, gr.Width())
147	}
148	stop = pos
149	return start, stop
150}