okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4e62e9e
main
1package tui 2 3import ( 4"math" 5"slices" 6"strings" 7 8"github.com/charmbracelet/x/ansi" 9"github.com/rivo/uniseg" 10) 11 12// Row marks are zero-width APC strings that widths and ansi.Strip skip. 13// They tell a copy what a row is beyond its text. AppModel.View removes them 14// before Bubble Tea renders; they must never reach the terminal. 15const ( 16// markChrome labels the transcript rather than being part of it: who 17// speaks, a turn's signoff, tool glances. 18markChrome = "\x1b_albedo:chrome\x1b\\" 19// markWrap continues the row above, which broke at a space, and 20// markSplit continues it mid-word. 21markWrap = "\x1b_albedo:wrap\x1b\\" 22markSplit = "\x1b_albedo:split\x1b\\" 23) 24 25// chrome marks every row of s. 26func chrome (s string )string { 27return markChrome + strings .ReplaceAll (s ,"\n" ,"\n" + markChrome ) 28} 29 30// wrapJoiner is what goes between a row and the row above it continues. 31func wrapJoiner (line string ) (string ,bool ) { 32if strings .Contains (line ,markWrap ) { 33return " " ,true 34 } 35return "" ,strings .Contains (line ,markSplit ) 36} 37 38// markChunks marks the rows line wrapped into as continuing the one before, 39// and gives them the line's chrome. 40func markChunks (line string ,chunks []string ) []string { 41carried := "" 42if strings .Contains (line ,markChrome ) { 43carried = markChrome 44 } 45for i := 1 ;i < len (chunks );i ++ { 46mark := markSplit 47if strings .HasSuffix (ansi .Strip (chunks [i - 1 ])," " ) { 48mark = markWrap 49 } 50chunks [i ]= carried + mark + chunks [i ] 51 } 52return chunks 53} 54 55// markWraps marks the rows of wrapped, markdown rendered to a width, that 56// continue the row above. flat is the same markdown unwrapped: a run of 57// wrapped rows that spells one of its lines, in order, is that line. A row 58// that spells none, like a table's, stays a line of its own. 59func markWraps (wrapped ,flat string )string { 60rows := strings .Split (wrapped ,"\n" ) 61var lines []string 62for line := range strings .SplitSeq (flat ,"\n" ) { 63lines = append (lines ,rowText (line )) 64 } 65// rest is what the line being spelled has left, after a space when spaced 66next ,rest ,spaced := 0 ,"" ,false 67for i ,row := range rows { 68text := rowText (row ) 69if cont := strings .TrimLeft (text ," " );rest != "" && cont != "" && strings .HasPrefix (rest ,cont ) { 70mark := markSplit 71if spaced { 72mark = markWrap 73 } 74rows [i ]= mark + row 75rest ,spaced = spell (rest ,cont ) 76continue 77 } 78rest = "" 79// rows that spell nothing hold the search back, so it looks ahead 80// past the flat lines they stand for 81for k := next ;k < min (len (lines ),next + 16 );k ++ { 82if lines [k ]== text || text != "" && strings .HasPrefix (lines [k ],text ) { 83next = k + 1 84rest ,spaced = spell (lines [k ],text ) 85break 86 } 87 } 88 } 89return strings .Join (rows ,"\n" ) 90} 91 92// spell is what line has left after text, and whether spaces came first. 93func spell (line ,text string ) (string ,bool ) { 94after := line [len (text ):] 95rest := strings .TrimLeft (after ," " ) 96return rest ,len (rest )< len (after ) 97} 98 99// rowText is what a row says: its plain text without quote bars or padding. 100func rowText (line string )string { 101_ ,text ,_ := selectedRange (line ,0 ,math .MaxInt ,quoteColumns (line )) 102return strings .TrimRight (text ," " ) 103} 104 105type Point struct { 106Row int 107Col int 108} 109type Selection struct { 110Anchor Point 111Head Point 112// Gutter is the decoration at the start of every row, which neither 113// the highlight nor the copied text includes. 114Gutter int 115} 116 117func (s Selection )IsEmpty ()bool {return s .Anchor == s .Head } 118func (s Selection )Normalized () (Point ,Point ) { 119if s .Anchor .Row < s .Head .Row || (s .Anchor .Row == s .Head .Row && s .Anchor .Col <= s .Head .Col ) { 120return s .Anchor ,s .Head 121 } 122return s .Head ,s .Anchor 123} 124 125// selectedRange splits line around the columns from to to. Columns in skip 126// are left out of the selection. 127func selectedRange (line string ,from ,to int ,skip map [int ]bool ) (before ,selected ,after string ) { 128plain := ansi .Strip (line ) 129var chosen ,prefix ,suffix strings.Builder 130col := 0 131for len (plain )> 0 { 132cluster ,rest ,_ ,_ := uniseg .FirstGraphemeClusterInString (plain ,- 1 ) 133width := ansi .StringWidth (cluster ) 134switch { 135case col + width > from && col < to : 136if ! skip [col ] { 137chosen .WriteString (cluster ) 138 } 139case col < from : 140prefix .WriteString (cluster ) 141default : 142suffix .WriteString (cluster ) 143 } 144plain = rest 145col += width 146 } 147return prefix .String (),chosen .String (),suffix .String () 148} 149 150// selBounds is the column range of sel on one row of lines. 151func selBounds (line string ,sel Selection ,start ,end Point ,row int ) (int ,int ) { 152from ,to := sel .Gutter ,ansi .StringWidth (line ) 153if row == start .Row { 154from = max (from ,start .Col ) 155 } 156if row == end .Row { 157to = end .Col 158 } 159return from ,to 160} 161 162// SelectedText is the text under sel: what the rows say, without what the 163// transcript draws around it. Code block frames and quote bars are left out, 164// and so are chrome rows unless nothing else is selected; wrapped rows join 165// back into their lines. 166func SelectedText (lines []string ,sel Selection )string { 167if sel .IsEmpty ()|| len (lines )== 0 { 168return "" 169 } 170start ,end := sel .Normalized () 171type piece struct { 172text string 173joiner string 174chrome ,joins bool 175 } 176var picked []piece 177onlyChrome := true 178for row := max (0 ,start .Row );row <= end .Row && row < len (lines );row ++ { 179line := lines [row ] 180if strings .Contains (line ,codeFence (fenceOpen )) { 181continue 182 } 183// a code block's closing frame also spaces the block from what 184// follows, so it copies as a blank row 185if strings .Contains (line ,codeFence (fenceClose )) { 186picked = append (picked ,piece {}) 187continue 188 } 189from ,to := selBounds (line ,sel ,start ,end ,row ) 190_ ,text ,_ := selectedRange (line ,from ,to ,quoteColumns (line )) 191if act ,ok := actionOf (line );ok && act .verb == verbOpen { 192// the toggle is a control, not part of what the row says 193text = strings .TrimPrefix (strings .TrimPrefix (text ,toggleClosed ),toggleOpen ) 194 } 195// rows are padded out to the viewport's width 196p := piece {text :strings .TrimRight (text ," " ),chrome :strings .Contains (line ,markChrome )} 197if row > start .Row { 198p .joiner ,p .joins = wrapJoiner (line ) 199 } 200onlyChrome = onlyChrome && (p .chrome || p .text == "" ) 201picked = append (picked ,p ) 202 } 203var rows []string 204// joinable says the last row kept is the row above; dropped says chrome 205// was left out since the last text, so a blank row after it is spare 206joinable ,dropped := false ,false 207for _ ,p := range picked { 208switch { 209case p .chrome && ! onlyChrome : 210joinable ,dropped = false ,true 211case p .joins && joinable : 212rows [len (rows )- 1 ]+= p .joiner + strings .TrimLeft (p .text ," " ) 213case p .text == "" && dropped && len (rows )> 0 && rows [len (rows )- 1 ]== "" : 214joinable = false 215default : 216rows = append (rows ,p .text ) 217joinable ,dropped = true ,dropped && p .text == "" 218 } 219 } 220// and the viewport's height, so blank rows at either end are padding too 221for len (rows )> 0 && rows [0 ]== "" { 222rows = rows [1 :] 223 } 224for len (rows )> 0 && rows [len (rows )- 1 ]== "" { 225rows = rows [:len (rows )- 1 ] 226 } 227return strings .Join (rows ,"\n" ) 228} 229 230// quoteColumns are the columns of line under quote bars. 231func quoteColumns (line string )map [int ]bool { 232bar := quoteBar () 233skip := map [int ]bool {} 234for at := 0 ; ;at += len (bar ) { 235i := strings .Index (line [at :],bar ) 236if i < 0 { 237return skip 238 } 239at += i 240col := ansi .StringWidth (line [:at ]) 241skip [col ],skip [col + 1 ]= true ,true 242 } 243} 244 245func HighlightSelection (lines []string ,sel Selection ) []string { 246if sel .IsEmpty () { 247return lines 248 } 249start ,end := sel .Normalized () 250out := slices .Clone (lines ) 251for row := max (0 ,start .Row );row <= end .Row && row < len (out );row ++ { 252from ,to := selBounds (out [row ],sel ,start ,end ,row ) 253before ,text ,after := selectedRange (out [row ],from ,to ,nil ) 254if text != "" { 255out [row ]= before + DefaultStyles .Cursor .Render (text )+ after 256 } 257 } 258return out 259}