okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
1697fee
main
1package tui 2 3import ( 4"slices" 5"strings" 6 7"charm.land/bubbles/v2/textinput" 8 tea"charm.land/bubbletea/v2" 9"github.com/charmbracelet/x/ansi" 10) 11 12type PickerItem struct { 13ID string 14Label string 15Detail string 16// Note is what a detail pane says about the item, where a list has one. 17Note string 18} 19 20type PickerSelectMsg struct { 21ID string 22} 23 24type PickerCancelMsg struct {} 25 26type PickerModel struct { 27Title string 28Items []PickerItem 29Filtered []PickerItem 30SearchInput textinput.Model 31Cursor int 32Width int 33Height int 34WithSearch bool 35} 36 37func NewPickerModel (title string ,items []PickerItem ,withSearch bool ,initialSelection string )PickerModel { 38ti := newTextInput () 39ti .Placeholder = "" 40ti .Focus () 41ti .Prompt = "" 42 43m := PickerModel { 44Title :title , 45Items :items , 46WithSearch :withSearch , 47SearchInput :ti , 48 } 49m .applyFilter () 50 51if initialSelection != "" { 52if i := slices .IndexFunc (m .Filtered ,func (it PickerItem )bool {return it .ID == initialSelection });i >= 0 { 53m .Cursor = i 54 } 55 } 56 57return m 58} 59 60// Highlighted returns the item under the cursor. 61func (m PickerModel )Highlighted () (PickerItem ,bool ) { 62if m .Cursor >= 0 && m .Cursor < len (m .Filtered ) { 63return m .Filtered [m .Cursor ],true 64 } 65return PickerItem {},false 66} 67 68func (m * PickerModel )SetSize (width ,height int ) { 69m .Width = width 70m .Height = height 71m .SearchInput .SetWidth (max (1 ,width - 8 )) 72} 73 74// applyFilter keeps the items whose id, label and detail contain every word 75// of the search, in the order they were listed. 76func (m * PickerModel )applyFilter () { 77item ,ok := m .Highlighted () 78old := "" 79if ok { 80old = item .ID 81 } 82tokens := strings .Fields (strings .ToLower (m .SearchInput .Value ())) 83m .Filtered = nil 84for _ ,item := range m .Items { 85haystack := strings .ToLower (item .ID + " " + item .Label + " " + item .Detail ) 86if ! slices .ContainsFunc (tokens ,func (token string )bool {return ! strings .Contains (haystack ,token ) }) { 87m .Filtered = append (m .Filtered ,item ) 88 } 89 } 90m .Cursor = max (0 ,slices .IndexFunc (m .Filtered ,func (item PickerItem )bool {return item .ID == old })) 91} 92 93// selectableRows is a bottom-anchored list window with the selected row on 94// the selection surface. 95func selectableRows (lines []string ,selected ,height ,limit ,width int )string { 96if height <= 0 { 97height = 24 98 } 99if limit <= 0 { 100limit = height 101 } 102available := max (1 ,min (limit ,height - 8 )) 103first := min (max (0 ,selected - available + 1 ),max (0 ,len (lines )- available )) 104if len (lines )== 0 { 105return DefaultStyles .Faint .Render ("no matches" ) 106 } 107var b strings.Builder 108bar := selectBar ()+ " " 109for i := first ;i < min (len (lines ),first + available );i ++ { 110lead := " " 111if i == selected { 112lead = bar 113 } 114line := lead + lines [i ] 115if width > 0 { 116line = ansi .Truncate (line ,width ,"…" ) 117 } 118if i == selected { 119line = selectedLine (line ,width ) 120 } 121if i > first { 122b .WriteByte ('\n' ) 123 } 124b .WriteString (line ) 125 } 126return b .String () 127} 128 129func pickerRow (item PickerItem )string { 130line := item .Label 131if item .Detail != "" { 132line += DefaultStyles .Faint .Render (" " + item .Detail ) 133 } 134return line 135} 136 137func (m PickerModel )Init () tea.Cmd { 138if m .WithSearch { 139return textinput .Blink 140 } 141return nil 142} 143 144func (m PickerModel )Update (msg tea.Msg ) (PickerModel , tea.Cmd ) { 145var cmd tea.Cmd 146 147switch msg := msg .(type ) { 148case tea.KeyPressMsg : 149switch msg .String () { 150case "esc" ,"ctrl+c" ,"ctrl+d" : 151return m ,func () tea.Msg {return PickerCancelMsg {} } 152case "enter" : 153if item ,ok := m .Highlighted ();ok { 154return m ,func () tea.Msg {return PickerSelectMsg {ID :item .ID } } 155 } 156return m ,nil 157case "up" ,"ctrl+p" : 158if m .Cursor > 0 { 159m .Cursor -- 160 } 161return m ,nil 162case "down" ,"ctrl+n" : 163if m .Cursor < len (m .Filtered )- 1 { 164m .Cursor ++ 165 } 166return m ,nil 167 } 168 } 169 170if m .WithSearch { 171var tiCmd tea.Cmd 172m .SearchInput ,tiCmd = m .SearchInput .Update (msg ) 173m .applyFilter () 174cmd = tea .Batch (cmd ,tiCmd ) 175 } 176 177return m ,cmd 178} 179 180func (m PickerModel )View ()string { 181var b strings.Builder 182if m .Title != "" { 183b .WriteString (m .Title ) 184b .WriteByte ('\n' ) 185 } 186lines := make ([]string ,len (m .Filtered )) 187for i ,item := range m .Filtered { 188lines [i ]= pickerRow (item ) 189 } 190b .WriteString (selectableRows (lines ,m .Cursor ,m .Height ,m .Height ,m .Width )) 191b .WriteByte ('\n' ) 192if m .WithSearch { 193b .WriteString (promptLead ()) 194b .WriteString (m .SearchInput .View ()) 195 } 196b .WriteByte ('\n' ) 197b .WriteString (keyHints (hint {"↑↓" ,"select" },hint {"enter" ,"choose" },hint {"esc" ,"cancel" })) 198return b .String () 199}