src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/md/tty.go (about) 1 package md 2 3 import ( 4 "fmt" 5 "regexp" 6 "strings" 7 8 "src.elv.sh/pkg/ui" 9 "src.elv.sh/pkg/wcwidth" 10 ) 11 12 // TTYCodec renders Markdown in a terminal. 13 // 14 // The rendered text uses the following style: 15 // 16 // - Adjacent blocks are always separated with one blank line. 17 // 18 // - Thematic breaks are rendered as "────" (four U+2500 "box drawing light 19 // horizontal"). 20 // 21 // - Headings are rendered like "# Heading" in bold, with the same number of 22 // hashes as in Markdown 23 // 24 // - Code blocks are indented two spaces. The HighlightCodeBlock callback can 25 // be supplied to highlight the content of the code block. 26 // 27 // - HTML blocks are ignored. 28 // 29 // - Paragraphs are always reflowed to fit the given width. 30 // 31 // - Blockquotes start with "│ " (U+2502 "box drawing light vertical", then a 32 // space) on each line. 33 // 34 // - Bullet list items start with "• " (U+2022 "bullet", then a space) on the 35 // first line. Continuation lines are indented two spaces. 36 // 37 // - Ordered list items start with "X. " (where X is a number) on the first 38 // line. Continuation lines are indented three spaces. 39 // 40 // - Code spans are underlined. 41 // 42 // - Emphasis makes the text italic. (Some terminal emulators turn italic text 43 // into inverse text, which is not ideal but fine.) 44 // 45 // - Strong emphasis makes the text bold. 46 // 47 // - Links are rendered with their text content underlined. If the link is 48 // absolute (starts with scheme:), the destination is rendered like " 49 // (https://example.com)" after the text content. 50 // 51 // Relative link destinations are not shown by default, since they are 52 // usually not useful in a terminal. If the ConvertRelativeLink callback is 53 // non-nil, it is called for each relative links and non-empty return values 54 // are shown. 55 // 56 // The link description is ignored for now since Elvish's Markdown sources 57 // never use them. 58 // 59 // - Images are rendered like "Image: alt text (https://example.com/a.png)". 60 // 61 // - Autolinks have their text content rendered. 62 // 63 // - Raw HTML is mostly ignored, except that text between <kbd> and </kbd> 64 // becomes inverse video. 65 // 66 // - Hard line breaks are respected. 67 // 68 // The structure of the implementation closely mirrors [FmtCodec] in a lot of 69 // places, without the complexity of handling all edge cases correctly, but with 70 // the slight complexity of handling styles. 71 type TTYCodec struct { 72 Width int 73 // If non-nil, will be called to highlight the content of code blocks. 74 HighlightCodeBlock func(info, code string) ui.Text 75 // If non-nil, will be called for each relative link destination. 76 ConvertRelativeLink func(dest string) string 77 78 buf ui.TextBuilder 79 80 // Current active container blocks. The punct field is not used; the 81 // TTYCodec uses fixed punctuations for each type. 82 containers stack[*fmtContainer] 83 // Value of op.Type of the last Do call. 84 lastOpType OpType 85 } 86 87 // Text returns the rendering result as a [ui.Text]. 88 func (c *TTYCodec) Text() ui.Text { return c.buf.Text() } 89 90 // String returns the rendering result as a string with ANSI escape sequences. 91 func (c *TTYCodec) String() string { return c.buf.Text().String() } 92 93 // Do processes an Op. 94 func (c *TTYCodec) Do(op Op) { 95 defer func() { 96 c.lastOpType = op.Type 97 }() 98 if !c.buf.Empty() && op.Type != OpHTMLBlock && needNewStanza(op.Type, c.lastOpType) { 99 c.writeLine("") 100 } 101 102 switch op.Type { 103 case OpThematicBreak: 104 c.writeLine("────") 105 case OpHeading: 106 c.startLine() 107 c.writeStyled(ui.T(strings.Repeat("#", op.Number)+" ", ui.Bold)) 108 c.doInlineContent(op.Content, true) 109 c.finishLine() 110 case OpCodeBlock: 111 if c.HighlightCodeBlock != nil { 112 t := c.HighlightCodeBlock(op.Info, strings.Join(op.Lines, "\n")+"\n") 113 lines := t.SplitByRune('\n') 114 for i, line := range lines { 115 if i == len(lines)-1 && len(line) == 0 { 116 // If t ends in a newline, the newline terminates the 117 // element before it; don't write an empty line for it. 118 break 119 } 120 c.startLine() 121 c.write(" ") 122 c.writeStyled(line) 123 c.finishLine() 124 } 125 } else { 126 for _, line := range op.Lines { 127 c.writeLine(" " + line) 128 } 129 } 130 case OpHTMLBlock: 131 // Do nothing 132 case OpParagraph: 133 c.startLine() 134 c.doInlineContent(op.Content, false) 135 c.finishLine() 136 case OpBlockquoteStart: 137 c.containers.push(&fmtContainer{typ: fmtBlockquote, marker: "│ "}) 138 case OpBlockquoteEnd: 139 c.containers.pop() 140 case OpListItemStart: 141 if ct := c.containers.peek(); ct.typ == fmtBulletItem { 142 ct.marker = "• " 143 } else { 144 ct.marker = fmt.Sprintf("%d. ", ct.number) 145 } 146 case OpListItemEnd: 147 ct := c.containers.peek() 148 ct.marker = "" 149 ct.number++ 150 case OpBulletListStart: 151 c.containers.push(&fmtContainer{typ: fmtBulletItem}) 152 case OpBulletListEnd: 153 c.containers.pop() 154 case OpOrderedListStart: 155 c.containers.push(&fmtContainer{typ: fmtOrderedItem, number: op.Number}) 156 case OpOrderedListEnd: 157 c.containers.pop() 158 } 159 } 160 161 var absoluteDest = regexp.MustCompile(`^` + scheme + `:`) 162 163 func (c *TTYCodec) doInlineContent(ops []InlineOp, heading bool) { 164 var stylings stack[ui.Styling] 165 if heading { 166 stylings.push(ui.Bold) 167 } 168 169 var ( 170 write func(string) 171 hardLineBreak func() 172 ) 173 if heading || c.Width == 0 { 174 write = func(s string) { 175 c.writeStyled(ui.T(s, stylings...)) 176 } 177 // When writing heading, ignore hard line break. 178 // 179 // When writing paragraph without reflowing, a hard line break will be 180 // followed by an OpNewline, which will result in a line break. 181 hardLineBreak = func() {} 182 } else { 183 maxWidth := c.Width 184 for _, ct := range c.containers { 185 maxWidth -= wcwidth.Of(ct.marker) 186 } 187 // The reflowing algorithm below is very similar to 188 // [FmtCodec.writeSegmentsParagraphReflow], except that the step to 189 // build spans and the step to arrange spans on lines are combined, and 190 // the span is a ui.Text rather than a strings.Builder. 191 currentLineWidth := 0 192 var currentSpan ui.Text 193 var prefixSpace ui.Text 194 writeSpan := func(t ui.Text) { 195 if len(t) == 0 { 196 return 197 } 198 w := wcwidthOfText(t) 199 if currentLineWidth == 0 { 200 c.writeStyled(t) 201 currentLineWidth = w 202 } else if currentLineWidth+1+w <= maxWidth { 203 c.writeStyled(prefixSpace) 204 c.writeStyled(t) 205 currentLineWidth += w + 1 206 } else { 207 c.finishLine() 208 c.startLine() 209 c.writeStyled(t) 210 currentLineWidth = w 211 } 212 } 213 write = func(s string) { 214 parts := whitespaceRunRegexp.Split(s, -1) 215 currentSpan = append(currentSpan, ui.T(parts[0], stylings...)...) 216 if len(parts) > 1 { 217 writeSpan(currentSpan) 218 prefixSpace = ui.T(" ", stylings...) 219 for _, s := range parts[1 : len(parts)-1] { 220 writeSpan(ui.T(s, stylings...)) 221 } 222 currentSpan = ui.T(parts[len(parts)-1], stylings...) 223 } 224 } 225 hardLineBreak = func() { 226 writeSpan(currentSpan) 227 currentSpan = nil 228 currentLineWidth = 0 229 c.finishLine() 230 c.startLine() 231 } 232 defer func() { 233 writeSpan(currentSpan) 234 }() 235 } 236 writeLinkDest := func(dest string) { 237 show := absoluteDest.MatchString(dest) 238 if !show && c.ConvertRelativeLink != nil { 239 dest = c.ConvertRelativeLink(dest) 240 show = dest != "" 241 } 242 if show { 243 write(" (") 244 write(dest) 245 write(")") 246 } 247 } 248 249 for _, op := range ops { 250 switch op.Type { 251 case OpText: 252 write(op.Text) 253 case OpRawHTML: 254 switch op.Text { 255 case "<kbd>": 256 stylings.push(ui.Inverse) 257 case "</kbd>": 258 stylings.pop() 259 } 260 case OpNewLine: 261 if heading || c.Width > 0 { 262 write(" ") 263 } else { 264 c.finishLine() 265 c.startLine() 266 } 267 case OpCodeSpan: 268 stylings.push(ui.Underlined) 269 write(op.Text) 270 stylings.pop() 271 case OpEmphasisStart: 272 stylings.push(ui.Italic) 273 case OpEmphasisEnd: 274 stylings.pop() 275 case OpStrongEmphasisStart: 276 stylings.push(ui.Bold) 277 case OpStrongEmphasisEnd: 278 stylings.pop() 279 case OpLinkStart: 280 stylings.push(ui.Underlined) 281 case OpLinkEnd: 282 stylings.pop() 283 writeLinkDest(op.Dest) 284 case OpImage: 285 write("Image: ") 286 write(op.Alt) 287 writeLinkDest(op.Dest) 288 case OpAutolink: 289 write(op.Text) 290 case OpHardLineBreak: 291 hardLineBreak() 292 } 293 } 294 } 295 296 func wcwidthOfText(t ui.Text) int { 297 w := 0 298 for _, seg := range t { 299 w += wcwidth.Of(seg.Text) 300 } 301 return w 302 } 303 304 func (c *TTYCodec) startLine() { startLine(c, c.containers) } 305 func (c *TTYCodec) writeLine(s string) { writeLine(c, c.containers, s) } 306 func (c *TTYCodec) finishLine() { c.write("\n") } 307 func (c *TTYCodec) write(s string) { c.writeStyled(ui.T(s)) } 308 func (c *TTYCodec) writeStyled(t ui.Text) { c.buf.WriteText(t) }