โ– humdrum codex / glint v1.0.2
license AGPL-3.0
5.2 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
package editor

import (
	"strconv"
	"strings"
)

// List and checkbox continuation (TASK-008). Enter on a list line continues the
// marker (ordered numbers increment, checkboxes reset to unchecked); Enter on an
// empty item removes the marker and exits the list. Tab / Shift+Tab indent and
// outdent the current list item by a fixed whitespace unit.

// listIndentUnit is one nesting level of leading whitespace. Two spaces aligns a
// nested item under the content of a "- " parent and renders as a sublist in
// CommonMark.
const listIndentUnit = "  "

// listItem is a parsed list line: its leading whitespace, marker, and the full
// prefix (everything up to the start of the content).
type listItem struct {
	indent   string // leading spaces/tabs
	marker   string // "-", "*", "+", or the ordered number text ("1")
	delim    string // "" for bullets; "." or ")" for ordered
	ordered  bool
	checkbox bool
	checked  bool
	prefix   string // indent + marker + delim + space (+ "[ ] ")
}

func isDigit(r rune) bool { return r >= '0' && r <= '9' }

// parseListItem recognizes a markdown list line. ok is false for anything that
// isn't a bullet ("- ", "* ", "+ ") or ordered ("N. ", "N) ") item; a marker
// must be followed by a space (so "-" and "1.no space" are plain text).
func parseListItem(line string) (listItem, bool) {
	rs := []rune(line)
	i := 0
	for i < len(rs) && (rs[i] == ' ' || rs[i] == '\t') {
		i++
	}
	var it listItem
	it.indent = string(rs[:i])
	if i >= len(rs) {
		return listItem{}, false
	}

	switch {
	case (rs[i] == '-' || rs[i] == '*' || rs[i] == '+') && i+1 < len(rs) && rs[i+1] == ' ':
		it.marker = string(rs[i])
		i += 2
	case isDigit(rs[i]):
		j := i
		for j < len(rs) && isDigit(rs[j]) {
			j++
		}
		if j < len(rs) && (rs[j] == '.' || rs[j] == ')') && j+1 < len(rs) && rs[j+1] == ' ' {
			it.marker = string(rs[i:j])
			it.delim = string(rs[j])
			it.ordered = true
			i = j + 2
		} else {
			return listItem{}, false
		}
	default:
		return listItem{}, false
	}

	// Optional checkbox: "[ ]" / "[x]" / "[X]" followed by a space, or ending the
	// line (an empty checkbox item "- [ ]").
	if i+2 < len(rs) && rs[i] == '[' && (rs[i+1] == ' ' || rs[i+1] == 'x' || rs[i+1] == 'X') && rs[i+2] == ']' {
		switch {
		case i+3 < len(rs) && rs[i+3] == ' ':
			it.checkbox = true
			it.checked = rs[i+1] != ' '
			i += 4
		case i+3 == len(rs):
			it.checkbox = true
			it.checked = rs[i+1] != ' '
			i += 3
		}
	}

	it.prefix = string(rs[:i])
	return it, true
}

// continuationPrefix is the marker a new item below it inherits: same indent and
// bullet, the next number for ordered lists, and an unchecked box for checkboxes.
func (it listItem) continuationPrefix() string {
	var b strings.Builder
	b.WriteString(it.indent)
	if it.ordered {
		n, _ := strconv.Atoi(it.marker)
		b.WriteString(strconv.Itoa(n + 1))
		b.WriteString(it.delim)
		b.WriteByte(' ')
	} else {
		b.WriteString(it.marker)
		b.WriteByte(' ')
	}
	if it.checkbox {
		b.WriteString("[ ] ")
	}
	return b.String()
}

// onListItem reports whether the cursor's line is a list item.
func (e *Editor) onListItem() bool {
	_, ok := parseListItem(e.Lines[e.Cursor.Row])
	return ok
}

// ContinueList handles Enter on a list line. With empty content it removes the
// marker (exits the list) and creates no new line; otherwise it splits at the
// cursor and prefixes the new line with the continued marker. Returns false when
// the line isn't a list item or the cursor sits inside the marker, so the caller
// falls back to a plain newline.
func (e *Editor) ContinueList() bool {
	line := e.Lines[e.Cursor.Row]
	it, ok := parseListItem(line)
	if !ok {
		return false
	}
	rs := []rune(line)
	prefixLen := len([]rune(it.prefix))
	if e.Cursor.Col < prefixLen {
		return false // inside the marker โ€” let Enter split normally
	}

	if len(rs) == prefixLen {
		// Empty item: drop the marker and stay on the (now blank) line.
		e.Lines[e.Cursor.Row] = ""
		e.Cursor.Col = 0
		e.Dirty = true
		e.setGoal()
		e.followCursor()
		return true
	}

	col := clamp(e.Cursor.Col, 0, len(rs))
	left, right := string(rs[:col]), string(rs[col:])
	next := it.continuationPrefix()
	e.Lines[e.Cursor.Row] = left
	rest := append([]string{next + right}, e.Lines[e.Cursor.Row+1:]...)
	e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
	e.Cursor.Row++
	e.Cursor.Col = len([]rune(next))
	e.Dirty = true
	e.setGoal()
	e.followCursor()
	return true
}

// IndentLine prepends one indent unit to the current line (Tab on a list item).
func (e *Editor) IndentLine() {
	e.Lines[e.Cursor.Row] = listIndentUnit + e.Lines[e.Cursor.Row]
	e.Cursor.Col += len([]rune(listIndentUnit))
	e.Dirty = true
	e.setGoal()
	e.followCursor()
}

// OutdentLine removes one indent unit of leading whitespace (Shift+Tab): a tab,
// or up to listIndentUnit spaces. No-op when there's no leading whitespace.
func (e *Editor) OutdentLine() {
	rs := []rune(e.Lines[e.Cursor.Row])
	removed := 0
	if len(rs) > 0 && rs[0] == '\t' {
		removed = 1
	} else {
		for removed < len(listIndentUnit) && removed < len(rs) && rs[removed] == ' ' {
			removed++
		}
	}
	if removed == 0 {
		return
	}
	e.Lines[e.Cursor.Row] = string(rs[removed:])
	if e.Cursor.Col -= removed; e.Cursor.Col < 0 {
		e.Cursor.Col = 0
	}
	e.Dirty = true
	e.setGoal()
	e.followCursor()
}