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 }