list.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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, wrap to the bottom
  153. for i := len(c.items) - 1; i > c.selectedIdx; i-- {
  154. if c.isSelectable(c.items[i]) {
  155. c.selectedIdx = i
  156. return
  157. }
  158. }
  159. }
  160. // moveDown moves the selection down, skipping non-selectable items
  161. func (c *listComponent[T]) moveDown() {
  162. if len(c.items) == 0 {
  163. return
  164. }
  165. originalIdx := c.selectedIdx
  166. // First try moving down from current position
  167. for i := c.selectedIdx + 1; i < len(c.items); i++ {
  168. if c.isSelectable(c.items[i]) {
  169. c.selectedIdx = i
  170. return
  171. }
  172. }
  173. // If no selectable item found below, wrap to the top
  174. for i := 0; i < originalIdx; i++ {
  175. if c.isSelectable(c.items[i]) {
  176. c.selectedIdx = i
  177. return
  178. }
  179. }
  180. }
  181. func (c *listComponent[T]) GetSelectedItem() (T, int) {
  182. if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
  183. return c.items[c.selectedIdx], c.selectedIdx
  184. }
  185. var zero T
  186. return zero, -1
  187. }
  188. func (c *listComponent[T]) SetItems(items []T) {
  189. c.items = items
  190. c.selectedIdx = 0
  191. // Ensure initial selection is on a selectable item
  192. if len(items) > 0 && !c.isSelectable(items[0]) {
  193. c.moveDown()
  194. }
  195. }
  196. func (c *listComponent[T]) GetItems() []T {
  197. return c.items
  198. }
  199. func (c *listComponent[T]) SetEmptyMessage(msg string) {
  200. c.fallbackMsg = msg
  201. }
  202. func (c *listComponent[T]) IsEmpty() bool {
  203. return len(c.items) == 0
  204. }
  205. func (c *listComponent[T]) SetMaxWidth(width int) {
  206. c.maxWidth = width
  207. }
  208. func (c *listComponent[T]) SetSelectedIndex(idx int) {
  209. if idx >= 0 && idx < len(c.items) {
  210. c.selectedIdx = idx
  211. }
  212. }
  213. func (c *listComponent[T]) GetMaxVisibleHeight() int {
  214. return c.maxVisibleHeight
  215. }
  216. func (c *listComponent[T]) View() string {
  217. items := c.items
  218. maxWidth := c.maxWidth
  219. if maxWidth == 0 {
  220. maxWidth = 80 // Default width if not set
  221. }
  222. if len(items) <= 0 {
  223. return c.fallbackMsg
  224. }
  225. // Calculate viewport based on actual heights
  226. startIdx, endIdx := c.calculateViewport()
  227. listItems := make([]string, 0, endIdx-startIdx)
  228. for i := startIdx; i < endIdx; i++ {
  229. item := items[i]
  230. // Special handling for HeaderItem to remove top margin on first item
  231. if i == startIdx {
  232. // Check if this is a HeaderItem
  233. if _, ok := any(item).(Item); ok {
  234. if headerItem, isHeader := any(item).(HeaderItem); isHeader {
  235. // Render header without top margin when it's first
  236. t := theme.CurrentTheme()
  237. truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
  238. headerStyle := c.baseStyle.
  239. Foreground(t.Accent()).
  240. Bold(true).
  241. MarginBottom(0).
  242. PaddingLeft(1)
  243. listItems = append(listItems, headerStyle.Render(truncatedStr))
  244. continue
  245. }
  246. }
  247. }
  248. title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
  249. listItems = append(listItems, title)
  250. }
  251. return strings.Join(listItems, "\n")
  252. }
  253. // calculateViewport determines which items to show based on available space
  254. func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
  255. items := c.items
  256. if len(items) == 0 {
  257. return 0, 0
  258. }
  259. // Calculate heights of all items
  260. itemHeights := make([]int, len(items))
  261. for i, item := range items {
  262. rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
  263. itemHeights[i] = lipgloss.Height(rendered)
  264. }
  265. // Find the range of items that fit within maxVisibleHeight
  266. // Start by trying to center the selected item
  267. start := 0
  268. end := len(items)
  269. // Calculate height from start to selected
  270. heightToSelected := 0
  271. for i := 0; i <= c.selectedIdx && i < len(items); i++ {
  272. heightToSelected += itemHeights[i]
  273. }
  274. // If selected item is beyond visible height, scroll to show it
  275. if heightToSelected > c.maxVisibleHeight {
  276. // Start from selected and work backwards to find start
  277. currentHeight := itemHeights[c.selectedIdx]
  278. start = c.selectedIdx
  279. for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
  280. currentHeight += itemHeights[i]
  281. start = i
  282. }
  283. }
  284. // Calculate end based on start
  285. currentHeight := 0
  286. for i := start; i < len(items); i++ {
  287. if currentHeight+itemHeights[i] > c.maxVisibleHeight {
  288. end = i
  289. break
  290. }
  291. currentHeight += itemHeights[i]
  292. }
  293. return start, end
  294. }
  295. func abs(x int) int {
  296. if x < 0 {
  297. return -x
  298. }
  299. return x
  300. }
  301. func max(a, b int) int {
  302. if a > b {
  303. return a
  304. }
  305. return b
  306. }
  307. func NewListComponent[T any](opts ...Option[T]) List[T] {
  308. options := &Options[T]{
  309. baseStyle: styles.NewStyle(), // Default empty style
  310. }
  311. for _, opt := range opts {
  312. opt(options)
  313. }
  314. return &listComponent[T]{
  315. fallbackMsg: options.fallbackMsg,
  316. items: options.items,
  317. maxVisibleHeight: options.maxVisibleHeight,
  318. useAlphaNumericKeys: options.useAlphaNumericKeys,
  319. selectedIdx: 0,
  320. renderItem: options.renderItem,
  321. isSelectable: options.isSelectable,
  322. baseStyle: options.baseStyle,
  323. }
  324. }
  325. // StringItem is a simple implementation of Item for string values
  326. type StringItem string
  327. func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
  328. t := theme.CurrentTheme()
  329. truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
  330. var itemStyle styles.Style
  331. if selected {
  332. itemStyle = baseStyle.
  333. Background(t.Primary()).
  334. Foreground(t.BackgroundElement()).
  335. Width(width).
  336. PaddingLeft(1)
  337. } else {
  338. itemStyle = baseStyle.
  339. Foreground(t.TextMuted()).
  340. PaddingLeft(1)
  341. }
  342. return itemStyle.Render(truncatedStr)
  343. }
  344. func (s StringItem) Selectable() bool {
  345. return true
  346. }
  347. // HeaderItem is a non-selectable header item for grouping
  348. type HeaderItem string
  349. func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
  350. t := theme.CurrentTheme()
  351. truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
  352. headerStyle := baseStyle.
  353. Foreground(t.Accent()).
  354. Bold(true).
  355. MarginTop(1).
  356. MarginBottom(0).
  357. PaddingLeft(1)
  358. return headerStyle.Render(truncatedStr)
  359. }
  360. func (h HeaderItem) Selectable() bool {
  361. return false
  362. }
  363. // Ensure StringItem and HeaderItem implement Item
  364. var _ Item = StringItem("")
  365. var _ Item = HeaderItem("")