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