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}