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) }