okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
fedc1be
main
1package tui 2 3import ( 4"slices" 5"strings" 6 7"charm.land/bubbles/v2/textinput" 8 tea"charm.land/bubbletea/v2" 9"charm.land/lipgloss/v2" 10"github.com/charmbracelet/x/ansi" 11) 12 13// listEntry is one entry of a list screen: how it draws, what the filter 14// matches, and what its detail pane says. 15type listEntry struct { 16key string // names the row across reloads, so the cursor stays on it 17section string // rows of one section sit together under its rule 18lead string // styled state before the name, padded to the widest 19name string // what the filter underlines 20desc string // faint after the name, and searched 21tag string // styled, at the right edge 22search []string 23detail func (width int ) []string // the pane's lines; nil leaves it blank 24} 25 26type listShown struct { 27row int 28hits []int 29} 30 31// listView is the state every list screen shares: a filter that is always 32// live, the rows it leaves, the cursor, and how they draw. Keys that move 33// or type belong to it; keys that act on a row belong to the screen, which 34// asks for them first. Actions are therefore never bare letters. 35type listView struct { 36input textinput.Model 37rows []listEntry 38shown []listShown 39Cursor int 40height int 41// Empty is said when there are no rows at all. 42Empty string 43} 44 45// newFilter is a focused search field. 46func newFilter (placeholder string ) textinput.Model { 47input := newField () 48input .Placeholder = placeholder 49st := input .Styles () 50st .Focused .Placeholder ,st .Blurred .Placeholder = DefaultStyles .Faint ,DefaultStyles .Faint 51input .SetStyles (st ) 52input .Focus () 53return input 54} 55 56func newListView (placeholder string )listView { 57return listView {input :newFilter (placeholder )} 58} 59 60func (l * listView )setSize (width ,height int ) { 61l .input .SetWidth (max (1 ,width - 6 )) 62l .height = height 63} 64 65// setRows replaces the rows and filters them again, keeping the cursor on 66// its row when that is still shown. 67func (l * listView )setRows (rows []listEntry ) { 68keep := "" 69if r ,ok := l .highlighted ();ok { 70keep = r .key 71 } 72l .rows = rows 73l .refilter (keep ) 74} 75 76// refilter ranks the rows against the filter. Rows rank within their 77// section and sections by their best row; an empty filter keeps the order 78// the rows came in. 79func (l * listView )refilter (keep string ) { 80words := searchWords (l .input .Value ()) 81type scored struct { 82shown listShown 83rank matchRank 84 } 85var order []string 86groups := map [string ][]scored {} 87for i ,r := range l .rows { 88rank ,hits ,ok := matchFields (words ,append ([]string {r .name ,r .desc },r .search ... )... ) 89if ! ok { 90continue 91 } 92if _ ,seen := groups [r .section ];! seen { 93order = append (order ,r .section ) 94 } 95groups [r .section ]= append (groups [r .section ],scored {listShown {i ,hits },rank }) 96 } 97if len (words )> 0 { 98for _ ,g := range groups { 99slices .SortStableFunc (g ,func (a ,b scored )int {return b .rank .compare (a .rank ) }) 100 } 101slices .SortStableFunc (order ,func (a ,b string )int {return groups [b ][0 ].rank .compare (groups [a ][0 ].rank ) }) 102 } 103l .shown = l .shown [:0 ] 104for _ ,section := range order { 105for _ ,s := range groups [section ] { 106l .shown = append (l .shown ,s .shown ) 107 } 108 } 109l .Cursor = max (0 ,slices .IndexFunc (l .shown ,func (s listShown )bool {return l .rows [s .row ].key == keep })) 110} 111 112func (l listView )highlighted () (listEntry ,bool ) { 113if l .Cursor < 0 || l .Cursor >= len (l .shown ) { 114return listEntry {},false 115 } 116return l .rows [l .shown [l .Cursor ].row ],true 117} 118 119func (l * listView )move (by int ) { 120l .Cursor = min (max (l .Cursor + by ,0 ),max (0 ,len (l .shown )- 1 )) 121} 122 123// update takes the keys that move through the list and everything else 124// into the filter. 125func (l * listView )update (msg tea.Msg ) tea.Cmd { 126if key ,ok := msg .(tea.KeyPressMsg );ok { 127page := max (1 ,l .height / 2 ) 128if by ,moves := map [string ]int {"up" :- 1 ,"ctrl+p" :- 1 ,"down" :1 ,"ctrl+n" :1 ,"pgup" :- page ,"pgdown" :page }[key .String ()];moves { 129l .move (by ) 130return nil 131 } 132 } 133before := l .input .Value () 134var cmd tea.Cmd 135l .input ,cmd = l .input .Update (msg ) 136if l .input .Value ()!= before { 137l .refilter ("" ) 138 } 139return cmd 140} 141 142func (l listView )filterLine ()string {return promptLead ()+ l .input .View () } 143 144// frame is the screen's layout around this list and the highlighted row's 145// detail. 146func (l listView )frame (title ,right ,footer string )listFrame { 147return listFrame {title :title ,right :right ,filter :l .filterLine (),list :l .list ,pane :l .pane ,footer :footer } 148} 149 150// list draws the rows under their section rules at exactly width × height. 151func (l listView )list (width ,height int ) []string { 152if len (l .shown )== 0 { 153note := " no matches" 154if len (l .rows )== 0 { 155note = " " + l .Empty 156 } 157return scrollWindow ([]string {DefaultStyles .Faint .Render (note )},- 1 ,width ,height ) 158 } 159leadW ,nameW := 0 ,0 160for _ ,s := range l .shown { 161r := l .rows [s .row ] 162leadW = max (leadW ,ansi .StringWidth (r .lead )) 163nameW = max (nameW ,ansi .StringWidth (r .name )) 164 } 165nameW = min (nameW ,max (12 ,width / 3 )) 166 167type line struct { 168text string 169shown int // the row it draws, or -1 for a rule 170 } 171var all []line 172selectedAt := - 1 173for i ,s := range l .shown { 174if section := l .rows [s .row ].section ;section != "" && (i == 0 || l .rows [l .shown [i - 1 ].row ].section != section ) { 175run := 1 176for i + run < len (l .shown )&& l .rows [l .shown [i + run ].row ].section == section { 177run ++ 178 } 179if len (all )> 0 && height >= 10 { 180all = append (all ,line {shown :- 1 }) 181 } 182all = append (all ,line {text :sectionRule (section ,run ,width ),shown :- 1 }) 183 } 184if i == l .Cursor { 185selectedAt = len (all ) 186 } 187all = append (all ,line {shown :i }) 188 } 189return drawWindow (len (all ),func (i int )string { 190if all [i ].shown < 0 { 191return all [i ].text 192 } 193return l .row (l .shown [all [i ].shown ],all [i ].shown == l .Cursor ,width ,leadW ,nameW ) 194 },selectedAt ,width ,height ) 195} 196 197func (l listView )row (s listShown ,selected bool ,width ,leadW ,nameW int )string { 198r := l .rows [s .row ] 199bar ,nameStyle := " " ,lipgloss .NewStyle () 200if selected { 201bar ,nameStyle = selectBar (),DefaultStyles .Bold 202 } 203line := bar + " " 204if leadW > 0 { 205line += r .lead + strings .Repeat (" " ,leadW - ansi .StringWidth (r .lead )+ 2 ) 206 } 207line += markedCell (r .name ,s .hits ,nameW ,nameStyle ) 208room := width - ansi .StringWidth (line )- 3 209if r .tag != "" { 210room -= ansi .StringWidth (r .tag )+ 2 211 } 212if r .desc != "" && room >= 4 { 213line += DefaultStyles .Faint .Render (" " + ansi .Truncate (r .desc ,room ,"…" )) 214 } 215if r .tag != "" { 216line += strings .Repeat (" " ,max (2 ,width - ansi .StringWidth (line )- ansi .StringWidth (r .tag )- 1 ))+ r .tag 217 } 218if selected { 219return selectedLine (line ,width ) 220 } 221return line 222} 223 224// pane is the highlighted row's detail at exactly width × height. 225func (l listView )pane (width ,height int ) []string { 226inner := max (1 ,width - 2 ) 227r ,ok := l .highlighted () 228switch { 229case ! ok && len (l .rows )== 0 : 230return paneBox (nil ,inner ,width ,height ) 231case ! ok : 232return paneBox ([]string {"" ,DefaultStyles .Muted .Render ("nothing matches" ),DefaultStyles .Faint .Render ("Try fewer search terms" )},inner ,width ,height ) 233case r .detail == nil : 234return paneBox (nil ,inner ,width ,height ) 235 } 236return paneBox (r .detail (inner ),inner ,width ,height ) 237} 238 239// paneTitle opens a detail pane: the name in bold, a description under it, 240// and a rule. 241func paneTitle (name ,description string ,width int ) []string { 242var lines []string 243for _ ,l := range svWrap (name ,width ,2 ) { 244lines = append (lines ,DefaultStyles .Bold .Render (l )) 245 } 246for _ ,l := range svWrap (description ,width ,3 ) { 247if l != "" { 248lines = append (lines ,DefaultStyles .Muted .Render (l )) 249 } 250 } 251return append (lines ,DefaultStyles .Decor .Render (strings .Repeat ("─" ,width ))) 252} 253 254// factRows is one labelled fact of a detail pane; a long value wraps under 255// itself. 256func factRows (label ,value string ,width int ) []string { 257const labelW = 10 258var lines []string 259for i ,l := range svWrap (value ,max (1 ,width - labelW ),4 ) { 260cell := strings .Repeat (" " ,labelW ) 261if i == 0 { 262cell = DefaultStyles .Muted .Render (svCell (label ,labelW ,false )) 263 } 264lines = append (lines ,cell + l ) 265 } 266return lines 267} 268 269// paneNote is faint explanatory text at the foot of a detail pane. 270func paneNote (text string ,width int ) []string { 271var lines []string 272for _ ,l := range svWrap (text ,width ,6 ) { 273lines = append (lines ,DefaultStyles .Faint .Render (l )) 274 } 275return lines 276}