src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/ui/styledown/styledown.go (about)

     1  // Styledown is a simple markup language for representing styled text.
     2  //
     3  // In the most basic form, Styledown markup consists of alternating text
     4  // lines and style lines, where each character in the style line specifies
     5  // the style of the character directly above it. For example:
     6  //
     7  //	foobar
     8  //	***###
     9  //	lorem
    10  //	_____
    11  //
    12  // represents two lines:
    13  //
    14  //  1. "foo" in bold plus "bar" in reverse video
    15  //  2. "lorem" in underline
    16  //
    17  // The following style characters are built-in:
    18  //
    19  //   - space for no style
    20  //   - * for bold
    21  //   - _ for underline
    22  //   - # for reverse video
    23  //
    24  // This package can be used as a Go library or via Elvish's render-styledown
    25  // command (https://elv.sh/ref/builtin.html#render-styledown).
    26  //
    27  // # Double-width characters
    28  //
    29  // Characters in text and style lines are matched up using their visual
    30  // width, as calculated by [src.elv.sh/pkg/wcwidth.OfRune]. This means that
    31  // double-width characters need to have their style character doubled:
    32  //
    33  //	好 foo
    34  //	** ###
    35  //
    36  // The two style characters must be the same.
    37  //
    38  // # Configuration stanza
    39  //
    40  // An optional configuration stanza can follow the text and style lines (the
    41  // content stanza), separated by a single newline. It can define additional
    42  // style characters like this:
    43  //
    44  //	foobar
    45  //	rrrGGG
    46  //
    47  //	r fg-red
    48  //	G inverse fg-green
    49  //
    50  // Each line consists of the style character and one or more stylings as
    51  // recognized by [src.elv.sh/pkg/ui.ParseStyling], separated by whitespaces. The
    52  // character must be a single Unicode codepoint and have a visual width of 1.
    53  //
    54  // The configuration stanza can also contain additional options, and there's
    55  // currently just one:
    56  //
    57  //   - no-eol: suppress the newline after the last line
    58  //
    59  // # Rationale
    60  //
    61  // Styledown is suitable for authoring a large chunk of styled text when the
    62  // exact width and alignment of text need to be preserved.
    63  //
    64  // For example, it can be used to manually create and edit terminal mockups. In
    65  // future it will be used in Elvish's tests for its terminal UI.
    66  package styledown
    67  
    68  import (
    69  	"fmt"
    70  	"strings"
    71  	"unicode/utf8"
    72  
    73  	"src.elv.sh/pkg/ui"
    74  	"src.elv.sh/pkg/wcwidth"
    75  )
    76  
    77  // Render renders Styledown markup. If the markup has parse errors, the error
    78  // will start with "line x", where x is a 1-based line number.
    79  func Render(s string) (ui.Text, error) {
    80  	lines := strings.Split(s, "\n")
    81  	i := 0
    82  	for ; i+1 < len(lines) && wcwidth.Of(lines[i]) == wcwidth.Of(lines[i+1]); i += 2 {
    83  	}
    84  	contentLines := i
    85  	if i < len(lines) {
    86  		if lines[i] != "" {
    87  			return nil, fmt.Errorf(
    88  				"line %d: content and configuration stanzas must be separated by a newline", 1+i)
    89  		}
    90  		i++
    91  	}
    92  	opts, stylesheet, err := parseConfig(lines[i:], i+1)
    93  	if err != nil {
    94  		return nil, err
    95  	}
    96  
    97  	var tb ui.TextBuilder
    98  	for i := 0; i < contentLines; i += 2 {
    99  		if i > 0 {
   100  			tb.WriteText(ui.T("\n"))
   101  		}
   102  		text, style := []rune(lines[i]), []rune(lines[i+1])
   103  		for len(text) > 0 {
   104  			r := text[0]
   105  			w := wcwidth.OfRune(r)
   106  			if !same(style[:w]) {
   107  				return nil, fmt.Errorf(
   108  					"line %d: inconsistent style %q for multi-width character %q",
   109  					i+2, string(style[:w]), string(r))
   110  			}
   111  			styling, ok := stylesheet[style[0]]
   112  			if !ok {
   113  				return nil, fmt.Errorf(
   114  					"line %d: unknown style %q", i+2, string(style[0]))
   115  			}
   116  			tb.WriteText(ui.T(string(r), styling))
   117  			text = text[1:]
   118  			style = style[w:]
   119  		}
   120  	}
   121  	if !opts.noEOL {
   122  		tb.WriteText(ui.T("\n"))
   123  	}
   124  	return tb.Text(), nil
   125  }
   126  
   127  type options struct {
   128  	noEOL bool
   129  }
   130  
   131  func parseConfig(lines []string, firstLineNo int) (options, map[rune]ui.Styling, error) {
   132  	var opts options
   133  	stylesheet := map[rune]ui.Styling{
   134  		' ': ui.Reset,
   135  		'*': ui.Bold,
   136  		'_': ui.Underlined,
   137  		'#': ui.Inverse,
   138  	}
   139  	for i, line := range lines {
   140  		if line == "" {
   141  			continue
   142  		}
   143  		if line == "no-eol" {
   144  			opts.noEOL = true
   145  			continue
   146  		}
   147  		// Parse a style character definition.
   148  		fields := strings.Fields(line)
   149  		if len(fields) < 2 {
   150  			return options{}, nil, fmt.Errorf(
   151  				"line %d: invalid configuration line", i+firstLineNo)
   152  		}
   153  
   154  		r, _ := utf8.DecodeRuneInString(fields[0])
   155  		if string(r) != fields[0] {
   156  			return options{}, nil, fmt.Errorf(
   157  				"line %d: style character %q not a single character", i+firstLineNo, fields[0])
   158  		}
   159  		if wcwidth.OfRune(r) != 1 {
   160  			return options{}, nil, fmt.Errorf(
   161  				"line %d: style character %q not single-width", i+firstLineNo, fields[0])
   162  		}
   163  
   164  		stylingString := strings.Join(fields[1:], " ")
   165  		styling := ui.ParseStyling(stylingString)
   166  		if styling == nil {
   167  			return options{}, nil, fmt.Errorf(
   168  				"line %d: invalid styling string %q", i+firstLineNo, stylingString)
   169  		}
   170  		stylesheet[r] = styling
   171  	}
   172  	return opts, stylesheet, nil
   173  }
   174  
   175  func same[T comparable](s []T) bool {
   176  	for i := 0; i+1 < len(s); i++ {
   177  		if s[i] != s[i+1] {
   178  			return false
   179  		}
   180  	}
   181  	return true
   182  }