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  }