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