feat: list and checkbox continuation (TASK-008)
482738b88e167ba821b401143dfbe85442d46506
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 14:09
parent 7c8bb9dd
feat: list and checkbox continuation (TASK-008) Enter on a list line continues the marker: bullets (-, *, +), ordered lists (N. / N)) with the number incremented, and checkboxes reset to an unchecked - [ ]. Enter on an empty item removes the marker and exits the list. Tab / Shift+Tab indent / outdent the current list item by a two-space unit (a leading tab counts as one level on outdent). New internal/editor/lists.go (parseListItem, ContinueList, IndentLine, OutdentLine); dispatch routes KeyEnter/KeyTab/KeyShiftTab; KeyShiftTab is now a structural undo group. 19 tests in lists_test.go. Also captures backlog tasks: TASK-021 (PDF/printable export), TASK-022 (track-changes/diff + Fuss-interop markup), TASK-023 (checkbox toggle key, the deferred non-AC piece of TASK-008). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
10 files changed
README.md +2 −0
@@ -44,6 +44,8 @@
| Key | Action |
| --- | --- |
| type / arrows / `Enter` / `Backspace` / `Del` | edit and move (Up/Down move by visual line) |
+| `Enter` on a list item | continue the list (`-`/`*`/`+`, `N.`/`N)`, `- [ ]` checkboxes; numbers increment); empty item exits the list |
+| `Tab` / `Shift+Tab` | indent / outdent the current list item |
| mouse click | move the cursor |
| mouse wheel | scroll the view |
| `Home` / `End` | start / end of line |
- → List-and-checkbox-continuation.md +21 −5
@@ -1,10 +1,10 @@
---
id: TASK-008
title: List and checkbox continuation
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 21:08'
labels:
- feature
- release-1
@@ -21,7 +21,23 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Enter continues the list marker (ordered numbers increment)
-- [ ] #2 Enter on an empty item exits the list (removes the marker)
-- [ ] #3 Tab / Shift+Tab indent and outdent list items
+- [x] #1 Enter continues the list marker (ordered numbers increment)
+- [x] #2 Enter on an empty item exits the list (removes the marker)
+- [x] #3 Tab / Shift+Tab indent and outdent list items
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. parseListItem(line): detect indent + bullet (-,*,+) or ordered (N. / N)) + optional [ ]/[x] checkbox; return prefix.
+2. ContinueList(): Enter on list line — empty content removes marker (exit list); else split at cursor, new line gets continued marker (ordered increments, checkbox resets to [ ]). Returns true if handled.
+3. IndentLine()/OutdentLine(): adjust leading whitespace by 2-space unit (tab counts as one level on outdent).
+4. Wire dispatch: KeyEnter -> ContinueList else InsertNewline; KeyTab -> IndentLine on list item else literal tab; KeyShiftTab -> OutdentLine on list item. editKindOf: KeyShiftTab structural+mutating.
+5. TDD: lists_test.go covers AC1 (continue, ordered increment, checkbox), AC2 (empty exit), AC3 (indent/outdent). Lint+vet+build, commit referencing TASK-008.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented in internal/editor/lists.go (parseListItem, ContinueList, IndentLine, OutdentLine); wired KeyEnter/KeyTab/KeyShiftTab in dispatch; KeyShiftTab added to editKindOf (structural). 19 tests in lists_test.go cover bullet/star/plus/ordered(./)) continuation, checkbox reset to [ ], indent preservation, mid-item split, empty-item exit, non-list plain newline, Tab indent (2-space) vs literal tab, Shift+Tab outdent (space+tab), no-op at zero indent, undo. Checkbox toggle-by-key from the description left out of scope (not an AC) — can be a follow-up. go test/vet/build/lint all clean.
+<!-- SECTION:NOTES:END -->
- → PDF-printable-export-in-the-house-style.md +47 −0
@@ -0,0 +1,47 @@
+---
+id: TASK-021
+title: PDF / printable export in the house style
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 20:49'
+updated_date: '2026-06-29 20:50'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 21000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a glint command/keybind to export the current document as a clean, printable PDF (or print-ready HTML) in the AppKit house style — matching what _shared-app-kit/markdown-doc-kit already produces for the browser.
+
+Reuse that kit as the source of truth rather than reinventing styling: it has doc.css (tokens, the seven themes — flexoki/-dark, uchu/-dark, humdrum/-dark, eink — document typography, and the @media print rules), embedded fonts (Awke, Untitled Sans, Name Mono), and pagination conventions (opening '# Title' becomes a centered cover page; every '## Section' starts a new page; '{.page-break}' forces a break; US Letter, 1in margins; print collapses to black-on-white).
+
+Approach options to decide during design:
+- Generate a self-contained HTML (doc.css + fonts + rendered markdown baked in, like the kit's 'Save HTML') and either (a) open it in the browser so the user hits Print → Save as PDF, or (b) shell out to a headless converter (Chromium --headless --print-to-pdf, wkhtmltopdf, or weasyprint/pandoc+Prince) to write a .pdf directly.
+- Keep glint dependency-light: prefer 'emit HTML + open in browser' as the zero-extra-binary path; treat direct-to-PDF (needs an external tool) as optional/detected.
+- Map glint's active theme to the kit theme where they line up (flexoki light/dark already shared).
+
+Out of scope: reimplementing the kit's interactive features (checkbox toggle, drag reorder) in glint.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
+- [ ] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
+- [ ] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
+- [ ] #4 README/help document the export command
+- [ ] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
+- [ ] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
+- [ ] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Portability constraint (user): glint is installed by people who are NOT on this machine and do NOT have the kit's bundled fonts (Awke, Untitled Sans, Name Mono — personal/licensed). So the export must NOT hard-embed those. Fonts must be user-configurable (display/body/mono config keys, reusing the doc.css --font-* tokens) with safe open/system fallbacks (e.g. Georgia / system-ui / ui-monospace) so output looks fine with zero personal assets. Only redistributable fonts may be embedded by default.
+
+Packaging: glint must be self-contained. Copy/build the required kit pieces (doc.css + the @media print rules, the render HTML skeleton, only redistributable fonts) into the glint repo (e.g. internal/export/assets/) and embed them with go:embed so they ship inside the binary. Do NOT depend on /Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit at runtime — that path doesn't exist on users' machines. Decide a vendor/sync story (a script that re-copies from the kit on update, or a one-time fork) so the embedded copy can be refreshed when the kit's house style changes; strip the licensed Awke/Untitled Sans/Name Mono @font-face blocks during that copy, leaving the --font-* tokens + fallbacks (ties to the configurable-fonts AC).
+<!-- SECTION:NOTES:END -->
- → Track-changes-diff-view-portable-change-markup-Fuss-interop.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-022
+title: Track-changes / diff view + portable change markup (Fuss interop)
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 20:57'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 22000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a diff / track-changes capability to glint, plus a markup that travels inside the .md file so the same tracked changes are usable in Fuss — the collaborative web app for markdown track changes. The on-disk format is the contract between glint (terminal) and Fuss (web): edits made in one show as reviewable changes in the other. CriticMarkup is the established standard for this ({++ins++}, {--del--}, {~~old~>new~~}, {==highlight==}, {>>comment<<}); glint already renders ==highlight==, so adopting CriticMarkup is the natural path. Scope is two related pieces that can ship independently: (1) a diff / track-changes VIEW in the editor, and (2) read/write of the portable change markup.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A diff / track-changes view renders insertions, deletions, and (optionally) replacements with distinct theme-driven styling, readable on light and dark terminals
+- [ ] #2 glint reads a portable in-file change markup and displays the encoded changes as tracked changes (not raw markup)
+- [ ] #3 glint can write that same markup so a file edited in glint round-trips into Fuss with changes intact, and vice versa
+- [ ] #4 The chosen markup is a documented, interoperable format (CriticMarkup evaluated first as the de-facto md track-changes standard) — not a glint-only invention; the decision and rationale are recorded
+- [ ] #5 Accept / reject individual changes is supported or explicitly deferred with a note
+- [ ] #6 Files without any change markup open and behave exactly as today (zero overhead, no visual noise)
+- [ ] #7 README / help document the view, the keybind(s), and the on-disk format + Fuss interop
+<!-- AC:END -->
- → Toggle-checkbox-state-with-a-key-x.md +25 −0
@@ -0,0 +1,25 @@
+---
+id: TASK-023
+title: 'Toggle checkbox state with a key (- [ ] <-> - [x])'
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 21:08'
+labels:
+ - feature
+dependencies: []
+priority: low
+ordinal: 23000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+From TASK-008 description (deferred, not an AC there): toggle the checkbox on the current list line between [ ] and [x] with a keybind, without hand-editing the brackets. Should work whether or not the cursor is on the brackets; no-op on non-checkbox lines.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A keybind toggles the current line's checkbox [ ] <-> [x]
+- [ ] #2 No-op on lines without a checkbox
+- [ ] #3 README/help document the key
+<!-- AC:END -->
internal/editor/editor.go +13 −3
@@ -495,10 +495,20 @@ e.InsertRune(r)
}
case tea.KeyEnter:
e.replaceSelection()
- e.InsertNewline()
+ if !e.ContinueList() {
+ e.InsertNewline()
+ }
case tea.KeyTab:
- e.replaceSelection()
- e.InsertRune('\t')
+ if e.onListItem() {
+ e.IndentLine()
+ } else {
+ e.replaceSelection()
+ e.InsertRune('\t')
+ }
+ case tea.KeyShiftTab:
+ if e.onListItem() {
+ e.OutdentLine()
+ }
// Deletion — a selection is removed wholesale.
case tea.KeyBackspace:
internal/editor/lists.go +185 −0
@@ -0,0 +1,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()
+}
internal/editor/lists_test.go +201 −0
@@ -0,0 +1,201 @@
+package editor
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// enter sends an Enter key through the full HandleKey path (undo-aware), the
+// same way the app drives the editor.
+func enter(e *Editor) { e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter}) }
+func tab(e *Editor) { e.HandleKey(tea.KeyMsg{Type: tea.KeyTab}) }
+func shiftTab(e *Editor) {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftTab})
+}
+
+func TestEnterContinuesBulletMarker(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- first"))
+ e.Cursor = Position{Row: 0, Col: len("- first")}
+ enter(e)
+ if got := e.Lines[1]; got != "- " {
+ t.Fatalf("new line = %q, want %q", got, "- ")
+ }
+ if e.Cursor.Row != 1 || e.Cursor.Col != 2 {
+ t.Fatalf("cursor = %+v, want row 1 col 2", e.Cursor)
+ }
+}
+
+func TestEnterContinuesStarAndPlus(t *testing.T) {
+ for _, m := range []string{"* ", "+ "} {
+ e := New()
+ e.SetContent([]byte(m + "x"))
+ e.Cursor = Position{Row: 0, Col: len(m + "x")}
+ enter(e)
+ if got := e.Lines[1]; got != m {
+ t.Fatalf("marker %q: new line = %q, want %q", m, got, m)
+ }
+ }
+}
+
+func TestEnterIncrementsOrderedNumber(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("1. first"))
+ e.Cursor = Position{Row: 0, Col: len("1. first")}
+ enter(e)
+ if got := e.Lines[1]; got != "2. " {
+ t.Fatalf("new line = %q, want %q", got, "2. ")
+ }
+}
+
+func TestEnterOrderedParenDelimiter(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("3) third"))
+ e.Cursor = Position{Row: 0, Col: len("3) third")}
+ enter(e)
+ if got := e.Lines[1]; got != "4) " {
+ t.Fatalf("new line = %q, want %q", got, "4) ")
+ }
+}
+
+func TestEnterContinuesCheckboxUnchecked(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- [x] done"))
+ e.Cursor = Position{Row: 0, Col: len("- [x] done")}
+ enter(e)
+ if got := e.Lines[1]; got != "- [ ] " {
+ t.Fatalf("new line = %q, want %q", got, "- [ ] ")
+ }
+}
+
+func TestEnterPreservesIndentation(t *testing.T) {
+ e := New()
+ e.SetContent([]byte(" - nested"))
+ e.Cursor = Position{Row: 0, Col: len(" - nested")}
+ enter(e)
+ if got := e.Lines[1]; got != " - " {
+ t.Fatalf("new line = %q, want %q", got, " - ")
+ }
+}
+
+func TestEnterSplitsContentMidItem(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- foobar"))
+ e.Cursor = Position{Row: 0, Col: len("- foo")}
+ enter(e)
+ if e.Lines[0] != "- foo" || e.Lines[1] != "- bar" {
+ t.Fatalf("lines = %q / %q, want %q / %q", e.Lines[0], e.Lines[1], "- foo", "- bar")
+ }
+}
+
+func TestEnterOnEmptyItemExitsList(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- "))
+ e.Cursor = Position{Row: 0, Col: 2}
+ enter(e)
+ if len(e.Lines) != 1 {
+ t.Fatalf("line count = %d, want 1 (no new line)", len(e.Lines))
+ }
+ if e.Lines[0] != "" {
+ t.Fatalf("line = %q, want empty (marker removed)", e.Lines[0])
+ }
+ if e.Cursor.Col != 0 {
+ t.Fatalf("cursor col = %d, want 0", e.Cursor.Col)
+ }
+}
+
+func TestEnterOnEmptyCheckboxExitsList(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- [ ] "))
+ e.Cursor = Position{Row: 0, Col: len("- [ ] ")}
+ enter(e)
+ if len(e.Lines) != 1 || e.Lines[0] != "" {
+ t.Fatalf("lines = %v, want one empty line", e.Lines)
+ }
+}
+
+func TestEnterOnNonListInsertsPlainNewline(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("hello"))
+ e.Cursor = Position{Row: 0, Col: 5}
+ enter(e)
+ if len(e.Lines) != 2 || e.Lines[1] != "" {
+ t.Fatalf("lines = %v, want plain split", e.Lines)
+ }
+}
+
+func TestTabIndentsListItem(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- item"))
+ e.Cursor = Position{Row: 0, Col: 2}
+ tab(e)
+ if e.Lines[0] != " - item" {
+ t.Fatalf("line = %q, want %q", e.Lines[0], " - item")
+ }
+ if e.Cursor.Col != 4 {
+ t.Fatalf("cursor col = %d, want 4", e.Cursor.Col)
+ }
+}
+
+func TestTabOnNonListInsertsTab(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("plain"))
+ e.Cursor = Position{Row: 0, Col: 0}
+ tab(e)
+ if e.Lines[0] != "\tplain" {
+ t.Fatalf("line = %q, want %q", e.Lines[0], "\tplain")
+ }
+}
+
+func TestShiftTabOutdentsListItem(t *testing.T) {
+ e := New()
+ e.SetContent([]byte(" - item"))
+ e.Cursor = Position{Row: 0, Col: 4}
+ shiftTab(e)
+ if e.Lines[0] != "- item" {
+ t.Fatalf("line = %q, want %q", e.Lines[0], "- item")
+ }
+ if e.Cursor.Col != 2 {
+ t.Fatalf("cursor col = %d, want 2", e.Cursor.Col)
+ }
+}
+
+func TestShiftTabOutdentTabIndent(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("\t- item"))
+ e.Cursor = Position{Row: 0, Col: 3}
+ shiftTab(e)
+ if e.Lines[0] != "- item" {
+ t.Fatalf("line = %q, want %q", e.Lines[0], "- item")
+ }
+}
+
+func TestShiftTabAtZeroIndentNoop(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- item"))
+ e.Cursor = Position{Row: 0, Col: 2}
+ shiftTab(e)
+ if e.Lines[0] != "- item" {
+ t.Fatalf("line = %q, want unchanged", e.Lines[0])
+ }
+}
+
+func TestListContinuationIsUndoable(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("- first"))
+ e.Cursor = Position{Row: 0, Col: len("- first")}
+ enter(e)
+ e.Undo()
+ if len(e.Lines) != 1 || e.Lines[0] != "- first" {
+ t.Fatalf("after undo lines = %v, want [- first]", e.Lines)
+ }
+}
+
+func TestParseListItemRejectsNonList(t *testing.T) {
+ for _, s := range []string{"", "plain text", "-nodash", "1.no space", " ", "-"} {
+ if _, ok := parseListItem(s); ok {
+ t.Errorf("parseListItem(%q) = ok, want not a list", s)
+ }
+ }
+}
internal/editor/undo.go +1 −1
@@ -131,7 +131,7 @@ }
return kindType, true
case tea.KeySpace:
return kindType, true
- case tea.KeyEnter, tea.KeyTab:
+ case tea.KeyEnter, tea.KeyTab, tea.KeyShiftTab:
return kindStructural, true
case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlU, tea.KeyCtrlK, tea.KeyCtrlW:
return kindStructural, true
main.go +3 −0
@@ -45,6 +45,9 @@ -h, --help show this help
--version print the version
EDITOR KEYS
+ Enter (on a list) continue the list marker (numbers increment, checkboxes
+ reset); Enter on an empty item exits the list
+ Tab / Shift+Tab indent / outdent the current list item
Ctrl+S save (an unnamed buffer prompts for a name)
Ctrl+P toggle the read preview
Ctrl+F fuzzy file picker