item.go

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