▍ humdrum codex / glint v1.0.2
license AGPL-3.0
6.1 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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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
	boxStart int    // rune index of '[' when checkbox; otherwise 0
	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] != ' '
			it.boxStart = i
			i += 4
		case i+3 == len(rs):
			it.checkbox = true
			it.checked = rs[i+1] != ' '
			it.boxStart = i
			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
}

// ToggleCheckbox flips the checkbox on the cursor's line between [ ] and [x]
// (TASK-023). The cursor's column on the line doesn't matter. Returns false
// (no-op) on lines without a checkbox.
func (e *Editor) ToggleCheckbox() bool {
	it, ok := parseListItem(e.Lines[e.Cursor.Row])
	if !ok || !it.checkbox {
		return false
	}
	rs := []rune(e.Lines[e.Cursor.Row])
	if it.checked {
		rs[it.boxStart+1] = ' '
	} else {
		rs[it.boxStart+1] = 'x'
	}
	e.Lines[e.Cursor.Row] = string(rs)
	e.Dirty = true
	return true
}

// OnCheckboxBracket reports whether the cursor sits on the "[ ]"/"[x]" glyph of
// a checkbox list line — used to toggle on a mouse click of the box (TASK-023).
func (e *Editor) OnCheckboxBracket() bool {
	it, ok := parseListItem(e.Lines[e.Cursor.Row])
	if !ok || !it.checkbox {
		return false
	}
	return e.Cursor.Col >= it.boxStart && e.Cursor.Col <= it.boxStart+2
}

// 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()
}