list.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. package list
  2. import (
  3. "strings"
  4. "github.com/charmbracelet/bubbles/v2/key"
  5. tea "github.com/charmbracelet/bubbletea/v2"
  6. "github.com/charmbracelet/lipgloss/v2"
  7. "github.com/muesli/reflow/truncate"
  8. "github.com/sst/opencode/internal/styles"
  9. "github.com/sst/opencode/internal/theme"
  10. )
  11. // Item interface that all list items must implement
  12. type Item interface {
  13. Render(selected bool, width int, baseStyle styles.Style) string
  14. Selectable() bool
  15. }
  16. // RenderFunc defines how to render an item in the list
  17. type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
  18. // SelectableFunc defines whether an item is selectable
  19. type SelectableFunc[T any] func(item T) bool
  20. // Options holds configuration for the list component
  21. type Options[T any] struct {
  22. items []T
  23. maxVisibleHeight int
  24. fallbackMsg string
  25. useAlphaNumericKeys bool
  26. renderItem RenderFunc[T]
  27. isSelectable SelectableFunc[T]
  28. baseStyle styles.Style
  29. }
  30. // Option is a function that configures the list component
  31. type Option[T any] func(*Options[T])
  32. // WithItems sets the initial items for the list
  33. func WithItems[T any](items []T) Option[T] {
  34. return func(o *Options[T]) {
  35. o.items = items
  36. }
  37. }
  38. // WithMaxVisibleHeight sets the maximum visible height in lines
  39. func WithMaxVisibleHeight[T any](height int) Option[T] {
  40. return func(o *Options[T]) {
  41. o.maxVisibleHeight = height
  42. }
  43. }
  44. // WithFallbackMessage sets the message to show when the list is empty
  45. func WithFallbackMessage[T any](msg string) Option[T] {
  46. return func(o *Options[T]) {
  47. o.fallbackMsg = msg
  48. }
  49. }
  50. // WithAlphaNumericKeys enables j/k navigation keys
  51. func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
  52. return func(o *Options[T]) {
  53. o.useAlphaNumericKeys = enabled
  54. }
  55. }
  56. // WithRenderFunc sets the function to render items
  57. func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
  58. return func(o *Options[T]) {
  59. o.renderItem = fn
  60. }
  61. }
  62. // WithSelectableFunc sets the function to determine if items are selectable
  63. func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
  64. return func(o *Options[T]) {
  65. o.isSelectable = fn
  66. }
  67. }
  68. // WithStyle sets the base style that gets passed to render functions
  69. func WithStyle[T any](style styles.Style) Option[T] {
  70. return func(o *Options[T]) {
  71. o.baseStyle = style
  72. }
  73. }
  74. type List[T any] interface {
  75. tea.Model
  76. tea.ViewModel
  77. SetMaxWidth(maxWidth int)
  78. GetSelectedItem() (item T, idx int)
  79. SetItems(items []T)
  80. GetItems() []T
  81. SetSelectedIndex(idx int)
  82. SetEmptyMessage(msg string)
  83. IsEmpty() bool
  84. GetMaxVisibleHeight() int
  85. }
  86. type listComponent[T any] struct {
  87. fallbackMsg string
  88. items []T
  89. selectedIdx int
  90. maxWidth int
  91. maxVisibleHeight int
  92. useAlphaNumericKeys bool
  93. width int
  94. height int
  95. renderItem RenderFunc[T]
  96. isSelectable SelectableFunc[T]
  97. baseStyle styles.Style
  98. }
  99. type listKeyMap struct {
  100. Up key.Binding
  101. Down key.Binding
  102. UpAlpha key.Binding
  103. DownAlpha key.Binding
  104. }
  105. var simpleListKeys = listKeyMap{
  106. Up: key.NewBinding(
  107. key.WithKeys("up", "ctrl+p"),
  108. key.WithHelp("↑", "previous list item"),
  109. ),
  110. Down: key.NewBinding(
  111. key.WithKeys("down", "ctrl+n"),
  112. key.WithHelp("↓", "next list item"),
  113. ),
  114. UpAlpha: key.NewBinding(
  115. key.WithKeys("k"),
  116. key.WithHelp("k", "previous list item"),
  117. ),
  118. DownAlpha: key.NewBinding(
  119. key.WithKeys("j"),
  120. key.WithHelp("j", "next list item"),
  121. ),
  122. }
  123. func (c *listComponent[T]) Init() tea.Cmd {
  124. return nil
  125. }
  126. func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  127. switch msg := msg.(type) {
  128. case tea.KeyMsg:
  129. switch {
  130. case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
  131. c.moveUp()
  132. return c, nil
  133. case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
  134. c.moveDown()
  135. return c, nil
  136. }
  137. }
  138. return c, nil
  139. }
  140. // moveUp moves the selection up, skipping non-selectable items
  141. func (c *listComponent[T]) moveUp() {
  142. if len(c.items) == 0 {
  143. return
  144. }
  145. // Find the previous selectable item
  146. for i := c.selectedIdx - 1; i >= 0; i-- {
  147. if c.isSelectable(c.items[i]) {
  148. c.selectedIdx = i
  149. return
  150. }
  151. }
  152. // If no selectable item found above, stay at current position
  153. }
  154. // moveDown moves the selection down, skipping non-selectable items
  155. func (c *listComponent[T]) moveDown() {
  156. if len(c.items) == 0 {
  157. return
  158. }
  159. originalIdx := c.selectedIdx
  160. for {
  161. if c.selectedIdx < len(c.items)-1 {
  162. c.selectedIdx++
  163. } else {
  164. break
  165. }
  166. if c.isSelectable(c.items[c.selectedIdx]) {
  167. return
  168. }
  169. // Prevent infinite loop
  170. if c.selectedIdx == originalIdx {
  171. break
  172. }
  173. }
  174. }
  175. func (c *listComponent[T]) GetSelectedItem() (T, int) {
  176. if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
  177. return c.items[c.selectedIdx], c.selectedIdx
  178. }
  179. var zero T
  180. return zero, -1
  181. }
  182. func (c *listComponent[T]) SetItems(items []T) {
  183. c.items = items
  184. c.selectedIdx = 0
  185. // Ensure initial selection is on a selectable item
  186. if len(items) > 0 && !c.isSelectable(items[0]) {
  187. c.moveDown()
  188. }
  189. }
  190. func (c *listComponent[T]) GetItems() []T {
  191. return c.items
  192. }
  193. func (c *listComponent[T]) SetEmptyMessage(msg string) {
  194. c.fallbackMsg = msg
  195. }
  196. func (c *listComponent[T]) IsEmpty() bool {
  197. return len(c.items) == 0
  198. }
  199. func (c *listComponent[T]) SetMaxWidth(width int) {
  200. c.maxWidth = width
  201. }
  202. func (c *listComponent[T]) SetSelectedIndex(idx int) {
  203. if idx >= 0 && idx < len(c.items) {
  204. c.selectedIdx = idx
  205. }
  206. }
  207. func (c *listComponent[T]) GetMaxVisibleHeight() int {
  208. return c.maxVisibleHeight
  209. }
  210. func (c *listComponent[T]) View() string {
  211. items := c.items
  212. maxWidth := c.maxWidth
  213. if maxWidth == 0 {
  214. maxWidth = 80 // Default width if not set
  215. }
  216. if len(items) <= 0 {
  217. return c.fallbackMsg
  218. }
  219. // Calculate viewport based on actual heights
  220. startIdx, endIdx := c.calculateViewport()
  221. listItems := make([]string, 0, endIdx-startIdx)
  222. for i := startIdx; i < endIdx; i++ {
  223. item := items[i]
  224. // Special handling for HeaderItem to remove top margin on first item
  225. if i == startIdx {
  226. // Check if this is a HeaderItem
  227. if _, ok := any(item).(Item); ok {
  228. if headerItem, isHeader := any(item).(HeaderItem); isHeader {
  229. // Render header without top margin when it's first
  230. t := theme.CurrentTheme()
  231. truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
  232. headerStyle := c.baseStyle.
  233. Foreground(t.Accent()).
  234. Bold(true).
  235. MarginBottom(0).
  236. PaddingLeft(1)
  237. listItems = append(listItems, headerStyle.Render(truncatedStr))
  238. continue
  239. }
  240. }
  241. }
  242. title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
  243. listItems = append(listItems, title)
  244. }
  245. return strings.Join(listItems, "\n")
  246. }
  247. // calculateViewport determines which items to show based on available space
  248. func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
  249. items := c.items
  250. if len(items) == 0 {
  251. return 0, 0
  252. }
  253. // Calculate heights of all items
  254. itemHeights := make([]int, len(items))
  255. for i, item := range items {
  256. rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
  257. itemHeights[i] = lipgloss.Height(rendered)
  258. }
  259. // Find the range of items that fit within maxVisibleHeight
  260. // Start by trying to center the selected item
  261. start := 0
  262. end := len(items)
  263. // Calculate height from start to selected
  264. heightToSelected := 0
  265. for i := 0; i <= c.selectedIdx && i < len(items); i++ {
  266. heightToSelected += itemHeights[i]
  267. }
  268. // If selected item is beyond visible height, scroll to show it
  269. if heightToSelected > c.maxVisibleHeight {
  270. // Start from selected and work backwards to find start
  271. currentHeight := itemHeights[c.selectedIdx]
  272. start = c.selectedIdx
  273. for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
  274. currentHeight += itemHeights[i]
  275. start = i
  276. }
  277. }
  278. // Calculate end based on start
  279. currentHeight := 0
  280. for i := start; i < len(items); i++ {
  281. if currentHeight+itemHeights[i] > c.maxVisibleHeight {
  282. end = i
  283. break
  284. }
  285. currentHeight += itemHeights[i]
  286. }
  287. return start, end
  288. }
  289. func abs(x int) int {
  290. if x < 0 {
  291. return -x
  292. }
  293. return x
  294. }
  295. func max(a, b int) int {
  296. if a > b {
  297. return a
  298. }
  299. return b
  300. }
  301. func NewListComponent[T any](opts ...Option[T]) List[T] {
  302. options := &Options[T]{
  303. baseStyle: styles.NewStyle(), // Default empty style
  304. }
  305. for _, opt := range opts {
  306. opt(options)
  307. }
  308. return &listComponent[T]{
  309. fallbackMsg: options.fallbackMsg,
  310. items: options.items,
  311. maxVisibleHeight: options.maxVisibleHeight,
  312. useAlphaNumericKeys: options.useAlphaNumericKeys,
  313. selectedIdx: 0,
  314. renderItem: options.renderItem,
  315. isSelectable: options.isSelectable,
  316. baseStyle: options.baseStyle,
  317. }
  318. }
  319. // StringItem is a simple implementation of Item for string values
  320. type StringItem string
  321. func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
  322. t := theme.CurrentTheme()
  323. truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
  324. var itemStyle styles.Style
  325. if selected {
  326. itemStyle = baseStyle.
  327. Background(t.Primary()).
  328. Foreground(t.BackgroundElement()).
  329. Width(width).
  330. PaddingLeft(1)
  331. } else {
  332. itemStyle = baseStyle.
  333. Foreground(t.TextMuted()).
  334. PaddingLeft(1)
  335. }
  336. return itemStyle.Render(truncatedStr)
  337. }
  338. func (s StringItem) Selectable() bool {
  339. return true
  340. }
  341. // HeaderItem is a non-selectable header item for grouping
  342. type HeaderItem string
  343. func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
  344. t := theme.CurrentTheme()
  345. truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
  346. headerStyle := baseStyle.
  347. Foreground(t.Accent()).
  348. Bold(true).
  349. MarginTop(1).
  350. MarginBottom(0).
  351. PaddingLeft(1)
  352. return headerStyle.Render(truncatedStr)
  353. }
  354. func (h HeaderItem) Selectable() bool {
  355. return false
  356. }
  357. // Ensure StringItem and HeaderItem implement Item
  358. var _ Item = StringItem("")
  359. var _ Item = HeaderItem("")