github.com/xyproto/orbiton/v2@v2.65.12-0.20240516144430-e10a419274ec/markdown.go (about) 1 package main 2 3 import ( 4 "strconv" 5 "strings" 6 "unicode" 7 8 "github.com/xyproto/vt100" 9 ) 10 11 // ToggleCheckboxCurrentLine will attempt to toggle the Markdown checkbox on the current line of the editor. 12 // Returns true if toggled. 13 func (e *Editor) ToggleCheckboxCurrentLine() bool { 14 checkboxPrefixes := []string{"- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]"} 15 // Toggle Markdown checkboxes 16 if line := e.CurrentLine(); hasAnyPrefixWord(strings.TrimSpace(line), checkboxPrefixes) { 17 if strings.Contains(line, "[ ]") { 18 e.SetLine(e.DataY(), strings.Replace(line, "[ ]", "[x]", 1)) 19 e.redraw = true 20 } else if strings.Contains(line, "[x]") { 21 e.SetLine(e.DataY(), strings.Replace(line, "[x]", "[ ]", 1)) 22 e.redraw = true 23 } else if strings.Contains(line, "[X]") { 24 e.SetLine(e.DataY(), strings.Replace(line, "[X]", "[ ]", 1)) 25 e.redraw = true 26 } 27 e.redrawCursor = e.redraw 28 return true 29 } 30 return false 31 } 32 33 // quotedWordReplace will replace quoted words with a highlighted version 34 // line is the uncolored string 35 // quote is the quote string (like "`" or "**") 36 // regular is the color of the regular text 37 // quoted is the color of the highlighted quoted text (including the quotes) 38 func quotedWordReplace(line string, quote rune, regular, quoted vt100.AttributeColor) string { 39 // Now do backtick replacements 40 if strings.ContainsRune(line, quote) && runeCount(line, quote)%2 == 0 { 41 inQuote := false 42 s := make([]rune, 0, len(line)*2) 43 // Start by setting the color to the regular one 44 s = append(s, []rune(regular.String())...) 45 var prevR, nextR rune 46 runes := []rune(line) 47 for i, r := range runes { 48 // Look for quotes, but also handle **`asdf`** and __`asdf`__ 49 if r == quote && prevR != '*' && nextR != '*' && prevR != '_' && nextR != '_' { 50 inQuote = !inQuote 51 if inQuote { 52 s = append(s, []rune(vt100.Stop())...) 53 s = append(s, []rune(quoted.String())...) 54 s = append(s, r) 55 continue 56 } 57 s = append(s, r) 58 s = append(s, []rune(vt100.Stop())...) 59 s = append(s, []rune(regular.String())...) 60 continue 61 } 62 s = append(s, r) 63 prevR = r // the previous r, for the next round 64 nextR = r // default value, in case the next rune can not be fetched 65 if (i + 2) < len(runes) { // + 2 since it must look 1 head for the next round 66 nextR = []rune(line)[i+2] 67 } 68 } 69 // End by turning the color off 70 s = append(s, []rune(vt100.Stop())...) 71 return string(s) 72 } 73 // Return the same line, but colored, if the quotes are not balanced 74 return regular.Get(line) 75 } 76 77 func style(line, marker string, textColor, styleColor vt100.AttributeColor) string { 78 n := strings.Count(line, marker) 79 if n < 2 { 80 // There must be at least two found markers 81 return line 82 } 83 if n%2 != 0 { 84 // The markers must be found in pairs 85 return line 86 } 87 // Split the line up in parts, then combine the parts, with colors 88 parts := strings.Split(line, marker) 89 lastIndex := len(parts) - 1 90 result := "" 91 for i, part := range parts { 92 switch { 93 case i == lastIndex: 94 // Last case 95 result += part 96 case i%2 == 0: 97 // Even case that is not the last case 98 if len(part) == 0 { 99 result += marker 100 } else { 101 result += part + vt100.Stop() + styleColor.String() + marker 102 } 103 default: 104 // Odd case that is not the last case 105 if len(part) == 0 { 106 result += marker 107 } else { 108 result += part + marker + vt100.Stop() + textColor.String() 109 } 110 } 111 } 112 return result 113 } 114 115 func emphasis(line string, textColor, italicsColor, boldColor, strikeColor vt100.AttributeColor) string { 116 result := line 117 if !withinBackticks(line, "~~") { 118 result = style(result, "~~", textColor, strikeColor) 119 } 120 if !withinBackticks(line, "**") { 121 result = style(result, "**", textColor, boldColor) 122 } 123 if !withinBackticks(line, "__") { 124 result = style(result, "__", textColor, boldColor) 125 } 126 if !strings.Contains(line, "**") && !withinBackticks(line, "*") { 127 result = style(result, "*", textColor, italicsColor) 128 } 129 if !strings.Contains(line, "__") && !withinBackticks(line, "_") { 130 result = style(result, "_", textColor, italicsColor) 131 } 132 return result 133 } 134 135 // isListItem checks if the given line is likely to be a Markdown list item 136 func isListItem(line string) bool { 137 trimmedLine := strings.TrimSpace(line) 138 fields := strings.Fields(trimmedLine) 139 if len(fields) == 0 { 140 return false 141 } 142 firstWord := fields[0] 143 144 // Check if this is a regular list item 145 switch firstWord { 146 case "*", "-", "+": 147 return true 148 } 149 150 // Check if this is a numbered list item 151 if strings.HasSuffix(firstWord, ".") { 152 if _, err := strconv.Atoi(firstWord[:len(firstWord)-1]); err == nil { // success 153 return true 154 } 155 } 156 157 return false 158 } 159 160 // markdownHighlight returns a VT100 colored line, a bool that is true if it worked out and a bool that is true if it's the start or stop of a block quote 161 func (e *Editor) markdownHighlight(line string, inCodeBlock bool, listItemRecord []bool, inListItem *bool) (string, bool, bool) { 162 dataPos := 0 163 for i, r := range line { 164 if unicode.IsSpace(r) { 165 dataPos = i + 1 166 } else { 167 break 168 } 169 } 170 171 // First position of non-space on line is now dataPos 172 leadingSpace := line[:dataPos] 173 174 // Get the rest of the line that isn't whitespace 175 rest := line[dataPos:] 176 177 // Starting or ending a code block 178 if strings.HasPrefix(rest, "~~~") || strings.HasPrefix(rest, "```") { // TODO: fix syntax highlighting when this comment is removed ` 179 return e.CodeBlockColor.Get(line), true, true 180 } 181 182 if inCodeBlock { 183 return e.CodeBlockColor.Get(line), true, false 184 } 185 186 // N is the number of lines to highlight with the same color for each numbered point or bullet point in a list 187 N := 3 188 prevNisListItem := false 189 for i := len(listItemRecord) - 1; i > (len(listItemRecord) - N); i-- { 190 if i >= 0 && listItemRecord[i] { 191 prevNisListItem = true 192 } 193 } 194 195 if leadingSpace == " " && !strings.HasPrefix(rest, "*") && !strings.HasPrefix(rest, "-") && !prevNisListItem { 196 // Four leading spaces means a quoted line 197 // Also assume it's not a quote if it starts with "*" or "-" 198 return e.CodeColor.Get(line), true, false 199 } 200 201 // An image (or a link to a single image) on a single line 202 if (strings.HasPrefix(rest, "[!") || strings.HasPrefix(rest, "!")) && strings.HasSuffix(rest, ")") { 203 return e.ImageColor.Get(line), true, false 204 } 205 206 // A link on a single line 207 if strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, ")") && strings.Count(rest, "[") == 1 { 208 return e.LinkColor.Get(line), true, false 209 } 210 211 // A line with HTML tags that may link to an image, or just be an "a href" link 212 if strings.HasPrefix(rest, "<") && strings.HasSuffix(rest, ">") { 213 if strings.Contains(rest, "<img ") && strings.Contains(rest, "://") { 214 // string includes "<img" and "://" 215 return e.ImageColor.Get(line), true, false 216 } 217 if strings.Contains(rest, "<a ") && strings.Contains(rest, "://") { 218 // string includes "<a" and "://" 219 return e.LinkColor.Get(line), true, false 220 } 221 if strings.Count(rest, "<") == strings.Count(rest, ">") { 222 // A list with HTML tags, matched evenly? 223 return e.LinkColor.Get(line), true, false 224 } 225 // Maybe HTML tags. Maybe matched unevenly. 226 return e.QuoteColor.Get(line), true, false 227 } 228 229 // A header line 230 if strings.HasPrefix(rest, "---") || strings.HasPrefix(rest, "===") { 231 return e.HeaderTextColor.Get(line), true, false 232 } 233 234 // HTML comments 235 if strings.HasPrefix(rest, "<!--") || strings.HasPrefix(rest, "-->") { 236 return e.CommentColor.Get(line), true, false 237 } 238 239 // A line with just a quote mark 240 if strings.TrimSpace(rest) == ">" { 241 return e.QuoteColor.Get(line), true, false 242 } 243 244 // A quote with something that follows 245 if pos := strings.Index(rest, "> "); pos >= 0 && pos < 5 { 246 words := strings.Fields(rest) 247 if len(words) >= 2 { 248 return e.QuoteColor.Get(words[0]) + " " + e.QuoteTextColor.Get(strings.Join(words[1:], " ")), true, false 249 } 250 } 251 252 // HTML 253 if strings.HasPrefix(rest, "<") || strings.HasPrefix(rest, ">") { 254 return e.HTMLColor.Get(line), true, false 255 } 256 257 // Table 258 if strings.HasPrefix(rest, "|") || strings.HasSuffix(rest, "|") { 259 if strings.HasPrefix(line, "|-") { 260 return e.TableColor.String() + line + e.TableBackground.String(), true, false 261 } 262 return strings.ReplaceAll(line, "|", e.TableColor.String()+"|"+e.TableBackground.String()), true, false 263 } 264 265 // Split the rest of the line into words 266 words := strings.Fields(rest) 267 if len(words) == 0 { 268 *inListItem = false 269 // Nothing to do here 270 return "", false, false 271 } 272 273 // Color differently depending on the leading word 274 firstWord := words[0] 275 lastWord := words[len(words)-1] 276 277 // A list item that is a link on a single line, possibly with some text after the link 278 if bracketPos := strings.Index(rest, "["); (firstWord == "-" || firstWord == "*") && bracketPos < 4 && strings.Count(rest, "](") >= 1 && strings.Count(rest, ")") >= 1 && strings.Index(rest, "]") != bracketPos+2 { 279 // First comes the leading space and rest[:bracketPos], then comes "[" and then.... 280 twoParts := strings.SplitN(rest[bracketPos+1:], "](", 2) 281 if len(twoParts) == 2 { 282 lastParts := strings.SplitN(twoParts[1], ")", 2) 283 if len(lastParts) == 2 { 284 bulletColor := e.ListBulletColor 285 labelColor := e.CodeColor 286 linkColor := e.CommentColor 287 bracketColor := e.Foreground 288 // Then comes twoParts[0] and "](" and twoParts[1] 289 return leadingSpace + bulletColor.Get(rest[:bracketPos]) + bracketColor.Get("[") + labelColor.Get(twoParts[0]) + bracketColor.Get("]") + e.CommentColor.Get("(") + linkColor.Get(lastParts[0]) + e.CommentColor.Get(")") + e.ListTextColor.Get(lastParts[1]), true, false 290 } 291 } 292 } 293 294 if consistsOf(firstWord, '#', []rune{'.', ' '}) { 295 if strings.HasSuffix(lastWord, "#") && strings.Contains(rest, " ") { 296 centerLen := len(rest) - (len(firstWord) + len(lastWord)) 297 if centerLen > 0 { 298 centerText := rest[len(firstWord) : len(rest)-len(lastWord)] 299 return leadingSpace + e.HeaderBulletColor.Get(firstWord) + e.HeaderTextColor.Get(centerText) + e.HeaderBulletColor.Get(lastWord), true, false 300 } 301 return leadingSpace + e.HeaderBulletColor.Get(rest), true, false 302 } else if len(words) > 1 { 303 return leadingSpace + e.HeaderBulletColor.Get(firstWord) + " " + e.HeaderTextColor.Get(emphasis(quotedWordReplace(line[dataPos+len(firstWord)+1:], '`', e.HeaderTextColor, e.CodeColor), e.HeaderTextColor, e.ItalicsColor, e.BoldColor, e.StrikeColor)), true, false // TODO: ` 304 } 305 return leadingSpace + e.HeaderTextColor.Get(rest), true, false 306 } 307 308 if isListItem(line) { 309 if strings.HasPrefix(rest, "- [ ] ") || strings.HasPrefix(rest, "- [x] ") || strings.HasPrefix(rest, "- [X] ") { 310 return leadingSpace + e.ListBulletColor.Get(rest[:1]) + " " + e.CheckboxColor.Get(rest[2:3]) + e.XColor.Get(rest[3:4]) + e.CheckboxColor.Get(rest[4:5]) + " " + emphasis(quotedWordReplace(line[dataPos+6:], '`', e.ListTextColor, e.ListCodeColor), e.ListTextColor, e.ItalicsColor, e.BoldColor, e.StrikeColor), true, false 311 } 312 if len(words) > 1 { 313 return leadingSpace + e.ListBulletColor.Get(firstWord) + " " + emphasis(quotedWordReplace(line[dataPos+len(firstWord)+1:], '`', e.ListTextColor, e.ListCodeColor), e.ListTextColor, e.ItalicsColor, e.BoldColor, e.StrikeColor), true, false 314 } 315 return leadingSpace + e.ListTextColor.Get(rest), true, false 316 } 317 318 // Leading hash without a space afterwards? 319 if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "# ") { 320 return e.MenuArrowColor.Get(line), true, false 321 } 322 323 if prevNisListItem { 324 *inListItem = true 325 } 326 327 // A completely regular line of text that is also the continuation of a list item 328 if *inListItem { 329 return emphasis(quotedWordReplace(line, '`', e.ListTextColor, e.ListCodeColor), e.ListTextColor, e.ItalicsColor, e.BoldColor, e.StrikeColor), true, false 330 } 331 332 // A completely regular line of text 333 return emphasis(quotedWordReplace(line, '`', e.MarkdownTextColor, e.CodeColor), e.MarkdownTextColor, e.ItalicsColor, e.BoldColor, e.StrikeColor), true, false 334 }