okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f7fcae1
main
1package main 2 3import ( 4"charm.land/bubbles/v2/help" 5"charm.land/bubbles/v2/key" 6"charm.land/bubbles/v2/textinput" 7 tea"charm.land/bubbletea/v2" 8"charm.land/lipgloss/v2" 9"example.com/charm-tui-worked/internal/display" 10"fmt" 11"strings" 12) 13 14type model struct { 15list selection 16input textinput.Model 17help help.Model 18width ,height ,top int 19confirm * item // Captured target, not a later lookup through a moving cursor. 20result outcome 21} 22 23type keys struct {up ,down ,search ,accept ,back ,quit key.Binding } 24 25func binding (k ,label string ) key.Binding { 26return key .NewBinding (key .WithKeys (k ),key .WithHelp (k ,label )) 27} 28func (k keys )ShortHelp () []key.Binding { 29return []key.Binding {k .up ,k .down ,k .search ,k .accept ,k .back ,k .quit } 30} 31func (k keys )FullHelp () [][]key.Binding {return [][]key.Binding {k .ShortHelp ()} } 32 33func (m * model )keys ()keys { 34k := keys {binding ("up" ,"up" ),binding ("down" ,"down" ),binding ("/" ,"search" ),binding ("enter" ,"choose" ),binding ("esc" ,"cancel" ),binding ("q" ,"quit" )} 35if m .input .Focused () { 36k .up .SetEnabled (false ) 37k .down .SetEnabled (false ) 38k .search .SetEnabled (false ) 39k .quit .SetEnabled (false ) 40k .accept = binding ("enter" ,"browse" ) 41k .back = binding ("esc" ,"browse" ) 42 }else if m .confirm != nil { 43k .up .SetEnabled (false ) 44k .down .SetEnabled (false ) 45k .search .SetEnabled (false ) 46k .quit .SetEnabled (false ) 47k .accept = binding ("enter" ,"confirm" ) 48k .back = binding ("esc" ,"back" ) 49 }else { 50_ ,ok := m .list .current () 51k .accept .SetEnabled (ok ) 52 } 53return k 54} 55 56func newModel (items []item )* model { 57input := textinput .New () 58input .Prompt = "/ " 59input .Placeholder = "filter servers" 60input .SetVirtualCursor (true )// Avoid real-cursor coordinate translation here. 61input .SetWidth (48 ) 62return & model {list :newSelection (items ),input :input ,help :help .New (),width :60 ,height :12 } 63} 64 65func (m * model )Init () tea.Cmd {return tea .RequestBackgroundColor } 66 67func (m * model )Update (msg tea.Msg ) (tea.Model , tea.Cmd ) { 68switch msg := msg .(type ) { 69case tea.WindowSizeMsg : 70m .width = min (72 ,max (0 ,msg .Width )) 71m .height = min (12 ,max (0 ,msg .Height )) 72m .input .SetWidth (max (1 ,m .width - 3 )) 73m .help .SetWidth (m .width ) 74m .clampWindow () 75return m ,nil 76case tea.BackgroundColorMsg : 77m .input .SetStyles (textinput .DefaultStyles (msg .IsDark ())) 78m .help .Styles = help .DefaultStyles (msg .IsDark ()) 79return m ,nil 80case tea.KeyPressMsg : 81if msg .String ()== "ctrl+c" { 82m .result = outcome {Interrupted :true } 83return m ,tea .Quit 84 } 85// Modal input is consumed exactly once, even when this key closes it. 86if m .confirm != nil { 87switch msg .String () { 88case "esc" : 89m .confirm = nil 90case "enter" : 91m .result = outcome {Accepted :true ,ID :m .confirm .ID } 92return m ,tea .Quit 93 } 94return m ,nil 95 } 96k := m .keys () 97// An editor owns printable keys. 'q', '/', '?', 'j' are text here. 98if m .input .Focused () { 99if key .Matches (msg ,k .back ,k .accept ) { 100m .input .Blur () 101m .clampWindow () 102return m ,nil 103 } 104return m ,m .updateInput (msg ) 105 } 106switch { 107case key .Matches (msg ,k .quit ,k .back ): 108return m ,tea .Quit 109case key .Matches (msg ,k .search ): 110cmd := m .input .Focus () 111m .clampWindow () 112return m ,cmd 113case key .Matches (msg ,k .up ): 114m .list .move (- 1 ) 115case key .Matches (msg ,k .down ): 116m .list .move (1 ) 117case key .Matches (msg ,k .accept ): 118if row ,ok := m .list .current ();ok { 119m .confirm = & row 120 } 121 } 122m .clampWindow () 123return m ,nil 124 } 125// Paste results and cursor messages also need the child update path. 126return m ,m .updateInput (msg ) 127} 128 129func (m * model )updateInput (msg tea.Msg ) tea.Cmd { 130before := m .input .Value () 131var cmd tea.Cmd 132m .input ,cmd = m .input .Update (msg ) 133if m .input .Value ()!= before { 134m .list .filter (m .input .Value ()) 135m .clampWindow () 136 } 137return cmd // Never lose child effects, including cursor or paste commands. 138} 139 140func (m * model )bodyHeight ()int { 141// Header has exactly two clipped lines; status has one, help is measured. 142return display .BodyRows (m .height ,2 ,1 + lipgloss .Height (m .help .View (m .keys ()))) 143} 144func (m * model )clampWindow () { 145m .top ,_ = display .Window (len (m .list .visible ),m .list .cursor ,m .top ,m .bodyHeight ()) 146} 147 148func (m * model )View () tea.View { 149if m .width <= 0 || m .height <= 0 { 150return tea .NewView ("" ) 151 } 152if m .width < 20 || m .height < 7 { 153return tea .NewView (display .Line ("resize · ctrl+c cancel" ,m .width )) 154 } 155title := lipgloss .NewStyle ().Bold (true ).Render ("choose a server" ) 156if m .confirm != nil { 157// Modal panel replaces the content; it is not an overlay compositor. 158text := "use " + display .SingleLine (m .confirm .Label )+ "?" 159content := title + "\n" + display .Panel (text ,m .width )+ "\n" + m .help .View (m .keys ()) 160return tea .NewView (content ) 161 } 162height := m .bodyHeight () 163lo ,hi := display .Window (len (m .list .visible ),m .list .cursor ,m .top ,height ) 164lines := make ([]string ,0 ,hi - lo ) 165for i := lo ;i < hi ;i ++ { 166row := m .list .items [m .list .visible [i ]] 167lines = append (lines ,display .Row (row .Label ,row .Detail ,m .width ,i == m .list .cursor )) 168 } 169if len (m .list .visible )== 0 { 170lines = append (lines ,"no matches · edit the filter" ) 171 } 172content := []string { 173title ,display .Line (m .input .View (),m .width ), 174display .FillRows (lines ,m .width ,height ), 175display .Line (fmt .Sprintf ("%d matches" ,len (m .list .visible )),m .width ), 176m .help .View (m .keys ()), 177 } 178return tea .NewView (strings .Join (content ,"\n" ))// Deliberately inline. 179}