2024-09-25 09:49:45 -06:00
|
|
|
package list
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
|
|
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
)
|
|
|
|
|
|
|
|
type Option struct {
|
|
|
|
Label string
|
|
|
|
Value interface{}
|
|
|
|
}
|
|
|
|
|
|
|
|
type OptionGroup struct {
|
|
|
|
Name string
|
|
|
|
Options []Option
|
|
|
|
}
|
|
|
|
|
|
|
|
type Model struct {
|
|
|
|
ID int
|
|
|
|
HeaderStyle lipgloss.Style
|
|
|
|
ItemStyle lipgloss.Style
|
|
|
|
SelectedStyle lipgloss.Style
|
|
|
|
ItemRender func(Option, bool) string
|
|
|
|
|
|
|
|
Width int
|
|
|
|
Height int
|
|
|
|
|
|
|
|
optionGroups []OptionGroup
|
|
|
|
selected int
|
|
|
|
filterInput textinput.Model
|
|
|
|
filteredIndices []filteredIndex
|
|
|
|
content viewport.Model
|
|
|
|
itemYOffsets []int
|
|
|
|
}
|
|
|
|
|
|
|
|
type filteredIndex struct {
|
|
|
|
groupIndex int
|
|
|
|
optionIndex int
|
|
|
|
}
|
|
|
|
|
|
|
|
type MsgOptionSelected struct {
|
|
|
|
ID int
|
|
|
|
Option Option
|
|
|
|
}
|
|
|
|
|
|
|
|
func New(opts []Option) Model {
|
|
|
|
return NewWithGroups([]OptionGroup{{Options: opts}})
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewWithGroups(groups []OptionGroup) Model {
|
|
|
|
ti := textinput.New()
|
|
|
|
ti.Prompt = "/"
|
|
|
|
ti.PromptStyle = lipgloss.NewStyle().Faint(true)
|
|
|
|
|
|
|
|
m := Model{
|
|
|
|
HeaderStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Padding(1, 0, 1, 1),
|
|
|
|
ItemStyle: lipgloss.NewStyle(),
|
|
|
|
SelectedStyle: lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6")),
|
|
|
|
|
|
|
|
optionGroups: groups,
|
|
|
|
selected: 0,
|
|
|
|
filterInput: ti,
|
|
|
|
filteredIndices: make([]filteredIndex, 0),
|
|
|
|
content: viewport.New(0, 0),
|
|
|
|
itemYOffsets: make([]int, 0),
|
|
|
|
}
|
|
|
|
|
|
|
|
m.filterItems()
|
2024-09-29 22:32:47 -06:00
|
|
|
m.content.SetContent(m.renderOptionsList())
|
2024-09-25 09:49:45 -06:00
|
|
|
return m
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) Focused() {
|
|
|
|
m.filterInput.Focused()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) Focus() {
|
|
|
|
m.filterInput.Focus()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) Blur() {
|
|
|
|
m.filterInput.Blur()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) filterItems() {
|
|
|
|
filterText := strings.ToLower(m.filterInput.Value())
|
|
|
|
|
|
|
|
var prevSelection *filteredIndex
|
|
|
|
if m.selected <= len(m.filteredIndices)-1 {
|
|
|
|
prevSelection = &m.filteredIndices[m.selected]
|
|
|
|
}
|
|
|
|
|
|
|
|
m.filteredIndices = make([]filteredIndex, 0)
|
|
|
|
|
|
|
|
for groupIndex, group := range m.optionGroups {
|
|
|
|
for optionIndex, option := range group.Options {
|
|
|
|
if filterText == "" ||
|
|
|
|
strings.Contains(strings.ToLower(option.Label), filterText) ||
|
|
|
|
(group.Name != "" && strings.Contains(strings.ToLower(group.Name), filterText)) {
|
|
|
|
m.filteredIndices = append(m.filteredIndices, filteredIndex{groupIndex, optionIndex})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
found := false
|
|
|
|
if len(m.filteredIndices) > 0 && prevSelection != nil {
|
|
|
|
// Preserve previous selection if possible
|
|
|
|
for i, filterIdx := range m.filteredIndices {
|
2024-09-29 22:32:47 -06:00
|
|
|
if prevSelection.groupIndex == filterIdx.groupIndex && prevSelection.optionIndex == filterIdx.optionIndex {
|
|
|
|
m.selected = i
|
|
|
|
found = true
|
|
|
|
break
|
2024-09-25 09:49:45 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
m.selected = 0
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.KeyMsg:
|
|
|
|
if m.filterInput.Focused() {
|
|
|
|
switch msg.String() {
|
|
|
|
case "esc":
|
|
|
|
m.filterInput.Blur()
|
|
|
|
m.filterInput.SetValue("")
|
|
|
|
m.filterItems()
|
|
|
|
m.refreshContent()
|
|
|
|
return *m, shared.KeyHandled(msg)
|
|
|
|
case "enter":
|
|
|
|
m.filterInput.Blur()
|
|
|
|
m.refreshContent()
|
|
|
|
break
|
|
|
|
case "up", "down":
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
|
|
|
m.filterItems()
|
|
|
|
m.refreshContent()
|
|
|
|
return *m, cmd
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
switch msg.String() {
|
|
|
|
case "up", "k":
|
|
|
|
m.moveSelection(-1)
|
|
|
|
return *m, shared.KeyHandled(msg)
|
|
|
|
case "down", "j":
|
|
|
|
m.moveSelection(1)
|
|
|
|
return *m, shared.KeyHandled(msg)
|
|
|
|
case "enter":
|
|
|
|
return *m, func() tea.Msg {
|
|
|
|
idx := m.filteredIndices[m.selected]
|
|
|
|
return MsgOptionSelected{
|
2024-09-29 22:32:47 -06:00
|
|
|
ID: m.ID,
|
2024-09-25 09:49:45 -06:00
|
|
|
Option: m.optionGroups[idx.groupIndex].Options[idx.optionIndex],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
case "/":
|
|
|
|
m.filterInput.Focus()
|
|
|
|
return *m, textinput.Blink
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
m.content, cmd = m.content.Update(msg)
|
|
|
|
return *m, cmd
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) refreshContent() {
|
2024-09-29 22:32:47 -06:00
|
|
|
m.content.SetContent(m.renderOptionsList())
|
2024-09-25 09:49:45 -06:00
|
|
|
m.ensureSelectedVisible()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) ensureSelectedVisible() {
|
|
|
|
if m.selected == 0 {
|
|
|
|
m.content.GotoTop()
|
|
|
|
} else if m.selected == len(m.filteredIndices)-1 {
|
|
|
|
m.content.GotoBottom()
|
|
|
|
} else {
|
|
|
|
tuiutil.ScrollIntoView(&m.content, m.itemYOffsets[m.selected], 0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) moveSelection(delta int) {
|
|
|
|
prev := m.selected
|
|
|
|
m.selected = min(len(m.filteredIndices)-1, max(0, m.selected+delta))
|
|
|
|
if prev != m.selected {
|
|
|
|
m.refreshContent()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) View() string {
|
|
|
|
filter := ""
|
|
|
|
if m.filterInput.Focused() {
|
|
|
|
m.filterInput.Width = m.Width
|
|
|
|
filter = m.filterInput.View()
|
|
|
|
}
|
|
|
|
|
|
|
|
contentHeight := m.Height - tuiutil.Height(filter)
|
|
|
|
m.content.Width, m.content.Height = m.Width, contentHeight
|
|
|
|
|
|
|
|
parts := []string{m.content.View()}
|
|
|
|
if filter != "" {
|
|
|
|
parts = append(parts, filter)
|
|
|
|
}
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
|
|
}
|
|
|
|
|
2024-09-29 22:32:47 -06:00
|
|
|
func (m *Model) renderOptionsList() string {
|
2024-09-25 09:49:45 -06:00
|
|
|
yOffset := 0
|
|
|
|
lastGroupIndex := -1
|
|
|
|
m.itemYOffsets = make([]int, len(m.filteredIndices))
|
|
|
|
|
2024-09-29 22:32:47 -06:00
|
|
|
var sb strings.Builder
|
2024-09-25 09:49:45 -06:00
|
|
|
for i, idx := range m.filteredIndices {
|
|
|
|
if idx.groupIndex != lastGroupIndex {
|
|
|
|
group := m.optionGroups[idx.groupIndex].Name
|
|
|
|
if group != "" {
|
|
|
|
headingStr := m.HeaderStyle.Render(group)
|
|
|
|
yOffset += tuiutil.Height(headingStr)
|
|
|
|
sb.WriteString(headingStr)
|
|
|
|
sb.WriteRune('\n')
|
|
|
|
}
|
|
|
|
lastGroupIndex = idx.groupIndex
|
|
|
|
}
|
|
|
|
|
|
|
|
m.itemYOffsets[i] = yOffset
|
|
|
|
option := m.optionGroups[idx.groupIndex].Options[idx.optionIndex]
|
|
|
|
var item string
|
|
|
|
if m.ItemRender != nil {
|
|
|
|
item = m.ItemRender(option, i == m.selected)
|
|
|
|
} else {
|
|
|
|
prefix := " "
|
|
|
|
if i == m.selected {
|
|
|
|
prefix = "> "
|
|
|
|
item = m.SelectedStyle.Render(option.Label)
|
|
|
|
} else {
|
|
|
|
item = m.ItemStyle.Render(option.Label)
|
|
|
|
}
|
|
|
|
item = fmt.Sprintf("%s%s", prefix, item)
|
|
|
|
}
|
|
|
|
sb.WriteString(item)
|
|
|
|
yOffset += tuiutil.Height(item)
|
|
|
|
if i < len(m.filteredIndices)-1 {
|
|
|
|
sb.WriteRune('\n')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return sb.String()
|
|
|
|
}
|