okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
2ecf203
main
1package tui 2 3import ( 4"cmp" 5"fmt" 6"slices" 7"strings" 8 9"charm.land/lipgloss/v2" 10"github.com/charmbracelet/x/ansi" 11) 12 13// setDoc takes a document and its rows, keeping the cursor on the row it was on. 14func (m * PageViewModel )setDoc (doc * PageDocument ) { 15m .Doc = doc 16var rows []listEntry 17if doc != nil { 18rows = make ([]listEntry ,len (doc .Rows )) 19for i ,row := range doc .Rows { 20rows [i ]= pageEntry (row ) 21 } 22 } 23m .setRows (rows ) 24} 25 26// focusRow moves the cursor to row id when the list shows it. 27func (m * PageViewModel )focusRow (id string ) { 28if i := slices .IndexFunc (m .shown ,func (s listShown )bool {return m .rows [s .row ].key == id });i >= 0 { 29m .Cursor = i 30 } 31} 32 33// pageEntry is how a row reads in the list: its tone dot in front, its text, 34// its quiet #id at the edge, grouped under its badge. 35func pageEntry (row PageRow )listEntry { 36tag := "" 37if id := rowID (row );id != "" { 38tag = DefaultStyles .Faint .Render (id ) 39 } 40return listEntry { 41key :row .ID , 42section :row .Badge , 43lead :toneGlyph (row .Tone ), 44name :row .Text , 45tag :tag , 46search : []string {row .ID ,row .Badge }, 47detail :func (width int ) []string {return pageDetail (row ,width ) }, 48 } 49} 50 51// toneGlyph is the dot a row wears for its tone; plain rows get a quiet one. 52func toneGlyph (tone PageTone )string { 53switch tone { 54case ToneWarning ,ToneActive : 55return toneStyle (tone ).Render ("●" ) 56 } 57return DefaultStyles .Faint .Render ("·" ) 58} 59 60// pageDetail is a row's pane: its text over its badge and id, then its 61// detail paragraphs wrapped to the pane. 62func pageDetail (row PageRow ,width int ) []string { 63meta := row .Badge 64if id := rowID (row );id != "" { 65meta += " · " + id 66 } 67lines := paneTitle (row .Text ,meta ,width ) 68for paragraph := range strings .SplitSeq (row .Detail ,"\n\n" ) { 69if paragraph = strings .TrimSpace (paragraph );paragraph != "" { 70lines = append (lines ,"" ) 71lines = append (lines ,strings .Split (ansi .Wrap (paragraph ,width ," -" ),"\n" )... ) 72 } 73 } 74return lines 75} 76 77// hasDetail reports whether any row has a detail, the only case the pane shows. 78func (m PageViewModel )hasDetail ()bool { 79return m .Doc != nil && slices .ContainsFunc (m .Doc .Rows ,func (r PageRow )bool {return r .Detail != "" }) 80} 81 82// emptyText is what the list says when it has no rows. 83func (m PageViewModel )emptyText ()string { 84switch { 85case m .Doc != nil : 86return m .Doc .Empty 87case m .Error != "" : 88return strings .TrimPrefix (m .Command ,"/" )+ " did not load" 89 } 90return "loading " + strings .TrimPrefix (m .Command ,"/" )+ "…" 91} 92 93// toneStyle is the color a badge wears for its tone. 94func toneStyle (tone PageTone ) lipgloss.Style { 95switch tone { 96case ToneActive : 97return DefaultStyles .Success 98case ToneWarning : 99return DefaultStyles .Warning 100case ToneMuted : 101return DefaultStyles .Faint 102 } 103return DefaultStyles .Muted 104} 105 106// rowID is the quiet #id a row shows, unless its id is its text. 107func rowID (row PageRow )string { 108if row .ID != row .Text { 109return "#" + row .ID 110 } 111return "" 112} 113 114// pageNotice picks the wording for a finished action: the daemon's message 115// when it sent one, else the action's label. 116func pageNotice (msg pageActionExecutedMsg )string { 117notice := fmt .Sprintf ("%s completed" ,msg .Action .Label ) 118if msg .Message != "" { 119return msg .Message 120 } 121return notice 122} 123 124// View draws the rows beside the highlighted row's detail; a prompt or a 125// question takes the footer, and the filter steps aside while one is open. 126func (m PageViewModel )View ()string { 127width := cmp .Or (m .Width ,80 ) 128lv := m .listView 129lv .Empty = m .emptyText () 130right := "" 131if m .Doc != nil { 132right = DefaultStyles .Faint .Render (m .Doc .Summary ) 133 } 134f := lv .frame (brand ("albedo" )+ " " + DefaultStyles .Muted .Render (m .Command ),right ,m .footer (width )) 135if m .Mode != modeBrowse { 136f .filter = "" 137 } 138if ! m .hasDetail () { 139f .pane = nil 140 } 141return f .view (m .Width ,m .Height ) 142} 143 144// prompt is the line above the keys where an action asks for what it needs, 145// blank while browsing or asking a question. 146func (m PageViewModel )prompt ()string { 147if m .Mode == modeActions { 148labels := make ([]string ,len (m .Doc .Actions )) 149for i ,action := range m .Doc .Actions { 150labels [i ]= action .Label 151if i == m .ActionIndex { 152labels [i ]= "[" + action .Label + "]" 153 } 154 } 155return " Actions: " + strings .Join (labels ," · " ) 156 } 157act := m .CurrentAction 158if act == nil { 159return "" 160 } 161target := "" 162if row := m .currentRow ();act .Row && row != nil { 163if id := rowID (* row );id != "" { 164target = " " + id 165 } 166 } 167switch m .Mode { 168case modeChoice : 169var options []string 170for i ,opt := range m .fieldChoices () { 171label := " " + opt + " " 172if i == m .ChoiceIndex { 173label = selectedLine (label ,0 ) 174 } 175options = append (options ,label ) 176 } 177return " " + DefaultStyles .Prompt .Render (act .Label + target )+ " " + promptLead ()+ strings .Join (options ,"" ) 178case modeText : 179field := cmp .Or (m .currentField ().Label ,act .Label ) 180return " " + DefaultStyles .Prompt .Render (act .Label + target + " · " + field )+ " " + promptLead ()+ m .TextInput .View () 181 } 182return "" 183} 184 185// footer is the prompt or question above the keys and one status: a failure, 186// a notice, or work in flight. 187func (m PageViewModel )footer (width int )string { 188if m .Mode == modeConfirm { 189return m .confirm .footer (width ,m .Error ) 190 } 191status ,urgent := m .status () 192line := footerLine (width ,m .hints (),status ,urgent ) 193if prompt := m .prompt ();prompt != "" { 194return prompt + "\n" + line 195 } 196return line 197} 198 199// status is the footer's state beside the keys; an urgent one takes a row of 200// its own when the keys do not leave room for it. 201func (m PageViewModel )status () (string ,bool ) { 202switch { 203case m .Error != "" : 204return DefaultStyles .Error .Render (m .Error ),true 205case m .Busy : 206return DefaultStyles .Busy .Render ("working…" ),true 207case m .Notice != "" : 208return DefaultStyles .Warning .Render (m .Notice ),true 209 } 210return "" ,false 211} 212 213// hints is the keys for the mode the page is in, most important first. 214func (m PageViewModel )hints () []hint { 215switch { 216case m .Busy : 217return []hint {{"esc" ,"back" }} 218case m .Doc == nil : 219return []hint {{"ctrl+r" ,"retry" }, {"esc" ,"back" }} 220case m .Mode == modeText : 221return []hint {{"enter" ,"save" }, {"esc" ,"cancel" }} 222case m .Mode == modeChoice : 223return []hint {{"←→" ,"choose" }, {"enter" ,"apply" }, {"esc" ,"cancel" }} 224case m .Mode == modeActions : 225return []hint {{"↑↓" ,"action" }, {"enter" ,"choose" }, {"esc" ,"cancel" }} 226 } 227return m .browseHints () 228} 229 230// browseHints lists the row's chords with their labels, then the menu, 231// refresh, and esc last. 232func (m PageViewModel )browseHints () []hint { 233var hints []hint 234if len (m .rows )> 1 { 235hints = append (hints ,hint {"↑↓" ,"move" }) 236 } 237for _ ,act := range m .Doc .Actions { 238if act .Key != "" && (! act .Row || m .currentRow ()!= nil ) { 239hints = append (hints ,hint {act .Key ,act .Label }) 240 } 241 } 242if len (m .Doc .Actions )> 0 { 243hints = append (hints ,hint {"ctrl+a" ,"actions" }) 244 } 245return append (hints ,hint {"ctrl+r" ,"refresh" },hint {"esc" ,"back" }) 246} 247 248// fitHints is " " and the hints in room columns. A screen lists its hints 249// most important first and esc last. What the screen already says goes 250// first: moving, then the least important hints one at a time from the end, 251// always keeping esc, and only when three labelled hints no longer fit, 252// every label but the keys. Empty when not even the keys fit. 253func fitHints (hints []hint ,room int )string { 254keysOnly := make ([]hint ,len (hints )) 255for i ,h := range hints { 256keysOnly [i ]= hint {key :h .key } 257if h .key == "" { 258keysOnly [i ].does = h .does 259 } 260 } 261still := slices .DeleteFunc (slices .Clone (hints ),func (h hint )bool {return h .key == "↑↓" }) 262candidates := [][]hint {hints ,still } 263leaving := slices .IndexFunc (still ,func (h hint )bool {return h .key == "esc" }) 264if leaving >= 0 { 265staying := slices .Delete (slices .Clone (still ),leaving ,leaving + 1 ) 266for n := len (staying )- 1 ;n >= min (2 ,len (staying ));n -- { 267candidates = append (candidates ,append (slices .Clone (staying [:n ]),still [leaving ])) 268 } 269 } 270candidates = append (candidates ,keysOnly ) 271for _ ,candidate := range candidates { 272if line := " " + keyHints (candidate ... );len (candidate )> 0 && ansi .StringWidth (line )<= room { 273return line 274 } 275 } 276return "" 277}