feat: editing polish — auto-close pairs, smart Home, go-to-line, cursor memory, paste-URL link (TASK-012)
8f0914b6892c556119a986e4bdb443163c0b6a23
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 17:06
parent 1dc94dd2
feat: editing polish — auto-close pairs, smart Home, go-to-line, cursor memory, paste-URL link (TASK-012) - editor: auto-close ( [ ` with step-over; smart Home toggles first-non-blank/col 0; GotoLine + SetCursor (clamped) - app: Ctrl+L go-to-line prompt; per-file cursor memory restored on reopen; paste a URL over a selection -> [sel](url) - docs: help.go EDITING section + Ctrl+L, README keys - TDD throughout (internal/editor/polish_test.go, internal/app/polish_test.go) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
7 files changed
README.md +4 −2
@@ -48,9 +48,10 @@ | `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 |
+| `Home` / `End` | smart Home (first non-blank, then column 0) / end of line |
+| type `(` `[` `` ` `` with no selection | auto-closes the pair with the cursor inside; type the closer over it to step past |
| `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
-| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard) |
+| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard); pasting a URL over a selection makes a `[selection](url)` link |
| `Alt+s` / `Alt+i` / `Alt+c` / `Alt+k` | wrap the selection: **bold** `**` · _italic_ `_` · `code` `` ` `` · link `[sel]()` (toggles off if already wrapped; no selection inserts the empty pair) |
| type `*` `_` `` ` `` `[` `(` `{` `<` `"` `'` with a selection | surround the selection with that punctuation (repeat to nest, e.g. `*`→`**` for bold) |
| `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
@@ -61,6 +62,7 @@ | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+P` | toggle the Glamour read preview |
| `Ctrl+F` | fuzzy file picker (with live preview) |
| `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
+| `Ctrl+L` | go to line (type a number, `Enter` to jump, `Esc` to cancel) |
| `Ctrl+D` | today's daily note |
| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
| `Ctrl+B` | new note in the inbox |
- → Editing-polish-bundle.md +25 −5
@@ -1,10 +1,10 @@
---
id: TASK-012
title: Editing polish bundle
-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-30 00:11'
labels:
- feature
- release-1
@@ -21,7 +21,27 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Auto-close pairs for [] () and backticks (with step-over)
-- [ ] #2 Smart Home (first non-blank, then col 0)
-- [ ] #3 Go-to-line; per-file cursor memory; paste-URL-over-selection
+- [x] #1 Auto-close pairs for [] () and backticks (with step-over)
+- [x] #2 Smart Home (first non-blank, then col 0)
+- [x] #3 Go-to-line; per-file cursor memory; paste-URL-over-selection
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+Editor-level (TDD in internal/editor):
+- AC1 auto-close: dispatch KeyRunes, no selection + single open rune (,[,backtick -> insertPair(open,close,1). Closing rune )/]/backtick with same char right of cursor -> step over (MoveRight). New autoClose(r) helper + TypeRune wrapper.
+- AC2 smart Home: MoveHome -> toggle first-non-blank <-> col 0.
+App-level (TDD in internal/app):
+- AC3 go-to-line: ModeGotoLine + Ctrl+L prompt, editor.GotoLine(n) clamp; gotoBar; Esc closes.
+- AC3 per-file cursor memory: App cursorMem map[path]Position; Load saves leaving file, restores on return; editor.SetCursor clamps.
+- AC3 paste-URL-over-selection: paste() -> if HasSelection && isURL(clip) -> [sel](url).
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Editor (TDD, internal/editor/polish_test.go): auto-close pairs () [] backtick via typeRune in dispatch (no-selection single rune), insertPair reused; closer steps over a matching char to the right. Smart Home toggles first-non-blank <-> col 0. Added GotoLine + SetCursor (clamped).
+App (TDD, internal/app/polish_test.go): Ctrl+L -> ModeGotoLine prompt (digits-only, Enter jumps, Esc cancels); per-file cursor memory map restored on Load; pasteText wraps a URL pasted over a selection as [sel](url), else verbatim.
+Docs: help.go EDITING section + Ctrl+L; README keys. Full suite + vet green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +97 −4
@@ -7,6 +7,7 @@ "fmt"
"math"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -34,6 +35,7 @@ ModePreview
ModeSaveAs
ModeFind
ModeHelp
+ ModeGotoLine
)
// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -63,9 +65,11 @@ theme theme.Theme
editor *editor.Editor
preview *preview.Model
picker *picker.Model
- saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
- findInput textinput.Model // one-line in-document find prompt (TASK-007)
- helpView viewport.Model // scrollable keybind overlay (TASK-011)
+ saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
+ findInput textinput.Model // one-line in-document find prompt (TASK-007)
+ gotoInput textinput.Model // one-line go-to-line prompt (TASK-012)
+ helpView viewport.Model // scrollable keybind overlay (TASK-011)
+ cursorMem map[string]editor.Position // per-file cursor memory, this session (TASK-012)
path string
pickerRoot string // directory the current picker is browsing
saveDir string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -88,6 +92,9 @@ ti.Placeholder = "name…"
fi := textinput.New()
fi.Prompt = "find › "
fi.Placeholder = "text…"
+ gi := textinput.New()
+ gi.Prompt = "go to line › "
+ gi.Placeholder = "number…"
hv := viewport.New(0, 0)
hv.SetContent(help.Text)
a := &App{
@@ -97,7 +104,9 @@ theme: th,
editor: ed,
saveInput: ti,
findInput: fi,
+ gotoInput: gi,
helpView: hv,
+ cursorMem: map[string]editor.Position{},
}
a.preview = preview.New(a.glamourStyle())
a.preview.SetColors(previewColors(th))
@@ -121,13 +130,25 @@ data, err := os.ReadFile(path)
if err != nil {
return err
}
+ a.rememberCursor() // stash the outgoing file's position before switching
a.editor.SetContent(data)
a.path = path
+ if pos, ok := a.cursorMem[path]; ok {
+ a.editor.SetCursor(pos) // restore where we left this file
+ }
a.mode = ModeEditor
a.status = path
return nil
}
+// rememberCursor records the current file's cursor so reopening it this session
+// returns to the same spot (TASK-012).
+func (a *App) rememberCursor() {
+ if a.path != "" {
+ a.cursorMem[a.path] = a.editor.Cursor
+ }
+}
+
// Start picks the initial view: an explicit path, today's daily note, or the
// picker when neither is given.
func (a *App) Start(path string, daily bool) error {
@@ -266,6 +287,8 @@ case tea.KeyCtrlV:
return a.paste()
case tea.KeyCtrlG:
return a.openFind()
+ case tea.KeyCtrlL:
+ return a.openGoto()
case tea.KeyCtrlUnderscore: // Ctrl+/ toggles the help overlay
return a.toggleHelp()
case tea.KeyEsc:
@@ -290,6 +313,8 @@ a.saveInput, cmd = a.saveInput.Update(msg)
return a, cmd
case ModeFind:
return a.handleFindKey(msg)
+ case ModeGotoLine:
+ return a.handleGotoKey(msg)
case ModeHelp:
var cmd tea.Cmd
a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll
@@ -376,6 +401,42 @@ a.status = "Find — type to search, Enter/↓ next, Shift+Tab/↑ prev, Esc to close"
return a, nil
}
+// openGoto opens the go-to-line prompt over the current editor buffer (Ctrl+L).
+func (a *App) openGoto() (tea.Model, tea.Cmd) {
+ if a.mode != ModeEditor {
+ return a, nil
+ }
+ a.gotoInput.SetValue("")
+ a.gotoInput.Focus()
+ a.mode = ModeGotoLine
+ a.status = "Go to line — type a number, Enter to jump, Esc to cancel"
+ return a, nil
+}
+
+// handleGotoKey drives the go-to-line prompt: Enter jumps to the typed line,
+// other keys edit the (digits-only) query live.
+func (a *App) handleGotoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if msg.Type == tea.KeyEnter {
+ if n, err := strconv.Atoi(strings.TrimSpace(a.gotoInput.Value())); err == nil {
+ a.editor.GotoLine(n)
+ }
+ a.mode = ModeEditor
+ a.status = ""
+ return a, nil
+ }
+ // Only accept digits so the prompt stays a line number.
+ if msg.Type == tea.KeyRunes {
+ for _, r := range msg.Runes {
+ if r < '0' || r > '9' {
+ return a, nil
+ }
+ }
+ }
+ var cmd tea.Cmd
+ a.gotoInput, cmd = a.gotoInput.Update(msg)
+ return a, cmd
+}
+
// handleFindKey drives the find bar: Enter/Down cycle to the next match,
// Shift+Tab/Up to the previous, and any other key edits the query live.
func (a *App) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -493,9 +554,30 @@ text, err := clipboard.ReadAll()
if err != nil || text == "" {
return a, nil
}
+ a.pasteText(text)
+ return a, nil
+}
+
+// pasteText inserts text at the cursor. Pasting a bare URL over a selection wraps
+// it as a markdown link [selection](url); otherwise it replaces the selection (or
+// inserts at the cursor) verbatim (TASK-012).
+func (a *App) pasteText(text string) {
a.editor.PushUndo()
+ if a.editor.HasSelection() && isURL(text) {
+ sel := a.editor.SelectedText()
+ a.editor.DeleteSelection()
+ a.editor.InsertText("[" + sel + "](" + text + ")")
+ return
+ }
a.editor.InsertText(text)
- return a, nil
+}
+
+// isURL reports whether text is a single http(s) URL (no embedded whitespace).
+func isURL(text string) bool {
+ if !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") {
+ return false
+ }
+ return !strings.ContainsAny(text, " \t\n")
}
// currentDir is the "same directory" for Ctrl+N: the picker root in the picker,
@@ -708,6 +790,8 @@ case ModeSaveAs:
bottom = a.saveBar()
case ModeFind:
bottom = a.findBar()
+ case ModeGotoLine:
+ bottom = a.gotoBar()
}
return a.paintCanvas(body) + bottom
}
@@ -738,6 +822,15 @@ Foreground(a.theme.StatusFg).
Background(a.theme.StatusBg).
Width(maxInt(a.width, 1))
return bar.Render(" " + a.findInput.View() + " " + a.findStatus() + " ")
+}
+
+// gotoBar renders the go-to-line prompt as a themed full-width bottom bar.
+func (a *App) gotoBar() string {
+ bar := lipgloss.NewStyle().
+ Foreground(a.theme.StatusFg).
+ Background(a.theme.StatusBg).
+ Width(maxInt(a.width, 1))
+ return bar.Render(" " + a.gotoInput.View() + " ")
}
// saveBar renders the save-as prompt as a themed full-width bottom bar.
internal/app/polish_test.go +90 −0
@@ -0,0 +1,90 @@
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "glint/internal/editor"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestGotoLineJumps(t *testing.T) {
+ a := statusApp()
+ a.editor.SetContent([]byte("one\ntwo\nthree\nfour"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
+ if a.mode != ModeGotoLine {
+ t.Fatalf("Ctrl+L did not open go-to-line: mode = %v", a.mode)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
+ a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if a.mode != ModeEditor {
+ t.Errorf("after Enter mode = %v, want ModeEditor", a.mode)
+ }
+ if a.editor.Cursor.Row != 2 {
+ t.Errorf("cursor row = %d, want 2 (line 3)", a.editor.Cursor.Row)
+ }
+}
+
+func TestGotoLineEscCloses(t *testing.T) {
+ a := statusApp()
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
+ a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if a.mode != ModeEditor {
+ t.Errorf("Esc did not close go-to-line: mode = %v", a.mode)
+ }
+}
+
+func TestPerFileCursorMemory(t *testing.T) {
+ dir := t.TempDir()
+ pa := filepath.Join(dir, "a.md")
+ pb := filepath.Join(dir, "b.md")
+ os.WriteFile(pa, []byte("a1\na2\na3"), 0o644)
+ os.WriteFile(pb, []byte("b1\nb2"), 0o644)
+
+ a := statusApp()
+ a.Load(pa)
+ a.editor.SetCursor(editor.Position{Row: 2, Col: 1}) // remember this in a.md
+ a.Load(pb) // switch away
+ a.Load(pa) // return
+ if got := (a.editor.Cursor); got.Row != 2 {
+ t.Errorf("returning to a.md cursor = %+v, want row 2 restored", got)
+ }
+}
+
+func TestPasteURLOverSelectionMakesLink(t *testing.T) {
+ a := statusApp()
+ a.editor.SetContent([]byte("click here"))
+ a.editor.SetCursor(editor.Position{Row: 0, Col: 0})
+ // select "click"
+ for i := 0; i < 5; i++ {
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+ }
+ a.pasteText("https://example.com")
+ if got := a.editor.Lines[0]; got != "[click](https://example.com) here" {
+ t.Errorf("line = %q, want [click](https://example.com) here", got)
+ }
+}
+
+func TestPasteNonURLOverSelectionReplaces(t *testing.T) {
+ a := statusApp()
+ a.editor.SetContent([]byte("click here"))
+ a.editor.SetCursor(editor.Position{Row: 0, Col: 0})
+ for i := 0; i < 5; i++ {
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+ }
+ a.pasteText("tap")
+ if got := a.editor.Lines[0]; got != "tap here" {
+ t.Errorf("line = %q, want tap here", got)
+ }
+}
+
+func TestPasteURLNoSelectionInsertsLiterally(t *testing.T) {
+ a := statusApp()
+ a.editor.SetContent([]byte(""))
+ a.pasteText("https://example.com")
+ if got := a.editor.Lines[0]; got != "https://example.com" {
+ t.Errorf("line = %q, want literal URL", got)
+ }
+}
internal/editor/editor.go +91 −2
@@ -138,6 +138,44 @@ e.Scroll = max
}
}
+// GotoLine moves the cursor to the start of 1-based line n, clamped to the
+// document (TASK-012).
+func (e *Editor) GotoLine(n int) {
+ row := n - 1
+ if row < 0 {
+ row = 0
+ }
+ if row > len(e.Lines)-1 {
+ row = len(e.Lines) - 1
+ }
+ e.Cursor = Position{Row: row, Col: 0}
+ e.anchor = nil
+ e.setGoal()
+ e.followCursor()
+}
+
+// SetCursor parks the cursor at p, clamped to a valid document position. Used to
+// restore a remembered position when reopening a file (TASK-012).
+func (e *Editor) SetCursor(p Position) {
+ if p.Row < 0 {
+ p.Row = 0
+ }
+ if p.Row > len(e.Lines)-1 {
+ p.Row = len(e.Lines) - 1
+ }
+ maxCol := len([]rune(e.Lines[p.Row]))
+ if p.Col < 0 {
+ p.Col = 0
+ }
+ if p.Col > maxCol {
+ p.Col = maxCol
+ }
+ e.Cursor = p
+ e.anchor = nil
+ e.setGoal()
+ e.followCursor()
+}
+
// MoveDocStart jumps to the very start of the document (Cmd+Up via the terminal).
func (e *Editor) MoveDocStart() {
e.Cursor = Position{Row: 0, Col: 0}
@@ -267,6 +305,38 @@ e.setGoal()
e.followCursor()
}
+// autoClose maps an opening rune to the closer auto-inserted after it (TASK-012).
+func autoClose(r rune) (rune, bool) {
+ switch r {
+ case '(':
+ return ')', true
+ case '[':
+ return ']', true
+ case '`':
+ return '`', true
+ }
+ return 0, false
+}
+
+func isAutoCloser(r rune) bool { return r == ')' || r == ']' || r == '`' }
+
+// typeRune inserts a single typed rune with auto-close behavior: an opening
+// bracket or backtick inserts its matching closer with the cursor between the
+// pair; typing a closer when that same closer already sits to the cursor's right
+// steps over it instead of inserting a duplicate. Anything else inserts plainly.
+func (e *Editor) typeRune(r rune) {
+ rs := e.curLine()
+ if isAutoCloser(r) && e.Cursor.Col < len(rs) && rs[e.Cursor.Col] == r {
+ e.MoveRight()
+ return
+ }
+ if closer, ok := autoClose(r); ok {
+ e.insertPair(string(r), string(closer), 1)
+ return
+ }
+ e.InsertRune(r)
+}
+
// InsertNewline splits the current line at the cursor.
func (e *Editor) InsertNewline() {
rs := e.curLine()
@@ -390,9 +460,23 @@ e.Cursor.Row = t.logRow
e.Cursor.Col = t.start + min(e.goalCol, maxCol)
}
-// MoveHome moves to column 0.
+// MoveHome implements smart Home: it moves to the first non-whitespace column,
+// or to column 0 if already there (toggling between the two). A blank or
+// whitespace-only line goes straight to column 0 (TASK-012).
func (e *Editor) MoveHome() {
- e.Cursor.Col = 0
+ rs := e.curLine()
+ first := 0
+ for first < len(rs) && isWordSpace(rs[first]) {
+ first++
+ }
+ if first == len(rs) {
+ first = 0 // nothing but whitespace
+ }
+ if e.Cursor.Col == first {
+ e.Cursor.Col = 0
+ } else {
+ e.Cursor.Col = first
+ }
e.setGoal()
e.followCursor()
}
@@ -496,6 +580,11 @@ if rc, ok := wrapPair(k.Runes[0]); ok {
e.surroundSelection(string(k.Runes[0]), string(rc))
return
}
+ }
+ // No selection: a single rune goes through auto-close handling.
+ if !e.HasSelection() && len(k.Runes) == 1 {
+ e.typeRune(k.Runes[0])
+ return
}
e.replaceSelection()
for _, r := range k.Runes {
internal/editor/polish_test.go +124 −0
@@ -0,0 +1,124 @@
+package editor
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func typeRune(e *Editor, r rune) {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+}
+
+func TestAutoCloseInsertsPair(t *testing.T) {
+ cases := []struct {
+ open rune
+ want string
+ }{
+ {'(', "()"},
+ {'[', "[]"},
+ {'`', "``"},
+ }
+ for _, c := range cases {
+ e := newEditorWith("")
+ typeRune(e, c.open)
+ if e.Lines[0] != c.want {
+ t.Errorf("typing %q: line = %q, want %q", c.open, e.Lines[0], c.want)
+ }
+ if e.Cursor.Col != 1 {
+ t.Errorf("typing %q: cursor col = %d, want 1 (between pair)", c.open, e.Cursor.Col)
+ }
+ }
+}
+
+func TestAutoCloseStepsOverClosing(t *testing.T) {
+ e := newEditorWith("")
+ typeRune(e, '(') // -> "()", cursor 1
+ typeRune(e, ')') // should step over, not insert another )
+ if e.Lines[0] != "()" {
+ t.Errorf("line = %q, want ()", e.Lines[0])
+ }
+ if e.Cursor.Col != 2 {
+ t.Errorf("cursor col = %d, want 2 (past closing)", e.Cursor.Col)
+ }
+}
+
+func TestAutoCloseBacktickStepsOver(t *testing.T) {
+ e := newEditorWith("")
+ typeRune(e, '`') // -> "``", cursor 1
+ typeRune(e, '`') // step over
+ if e.Lines[0] != "``" || e.Cursor.Col != 2 {
+ t.Errorf("line=%q col=%d, want `` col 2", e.Lines[0], e.Cursor.Col)
+ }
+}
+
+func TestClosingWithoutPairInsertsNormally(t *testing.T) {
+ e := newEditorWith("")
+ typeRune(e, ')') // no auto-pair to the right -> plain insert
+ if e.Lines[0] != ")" || e.Cursor.Col != 1 {
+ t.Errorf("line=%q col=%d, want ) col 1", e.Lines[0], e.Cursor.Col)
+ }
+}
+
+func TestAutoCloseSkippedWithSelection(t *testing.T) {
+ e := newEditorWith("word")
+ e.setSelection(Position{0, 0}, Position{0, 4}) // select "word"
+ typeRune(e, '(') // surround, not auto-close-empty
+ if e.Lines[0] != "(word)" {
+ t.Errorf("line = %q, want (word)", e.Lines[0])
+ }
+}
+
+func TestSmartHomeTogglesFirstNonBlank(t *testing.T) {
+ e := newEditorWith(" - foo")
+ e.Cursor = Position{Row: 0, Col: 7} // end of line
+ e.MoveHome()
+ if e.Cursor.Col != 2 {
+ t.Errorf("first Home col = %d, want 2 (first non-blank)", e.Cursor.Col)
+ }
+ e.MoveHome()
+ if e.Cursor.Col != 0 {
+ t.Errorf("second Home col = %d, want 0", e.Cursor.Col)
+ }
+ e.MoveHome()
+ if e.Cursor.Col != 2 {
+ t.Errorf("third Home col = %d, want 2 (back to first non-blank)", e.Cursor.Col)
+ }
+}
+
+func TestGotoLineClamps(t *testing.T) {
+ e := newEditorWith("one", "two", "three")
+ e.GotoLine(2)
+ if e.Cursor.Row != 1 || e.Cursor.Col != 0 {
+ t.Errorf("GotoLine(2) cursor = %+v, want {1 0}", e.Cursor)
+ }
+ e.GotoLine(99)
+ if e.Cursor.Row != 2 {
+ t.Errorf("GotoLine(99) row = %d, want 2 (last)", e.Cursor.Row)
+ }
+ e.GotoLine(0)
+ if e.Cursor.Row != 0 {
+ t.Errorf("GotoLine(0) row = %d, want 0", e.Cursor.Row)
+ }
+}
+
+func TestSetCursorClamps(t *testing.T) {
+ e := newEditorWith("ab", "cde")
+ e.SetCursor(Position{Row: 1, Col: 2})
+ if e.Cursor != (Position{Row: 1, Col: 2}) {
+ t.Errorf("cursor = %+v, want {1 2}", e.Cursor)
+ }
+ e.SetCursor(Position{Row: 9, Col: 9}) // out of range -> clamp to {1,3}
+ if e.Cursor != (Position{Row: 1, Col: 3}) {
+ t.Errorf("clamped cursor = %+v, want {1 3}", e.Cursor)
+ }
+}
+
+func TestSmartHomeNoIndent(t *testing.T) {
+ e := newEditorWith("abc")
+ e.Cursor = Position{Row: 0, Col: 3}
+ e.MoveHome()
+ if e.Cursor.Col != 0 {
+ t.Errorf("Home col = %d, want 0", e.Cursor.Col)
+ }
+}
internal/help/help.go +7 −0
@@ -30,6 +30,7 @@ Ctrl+S save (an unnamed buffer prompts for a name)
Ctrl+P toggle the read preview
Ctrl+F fuzzy file picker
Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
+ Ctrl+L go to line (type a number, Enter to jump)
Ctrl+D today's daily note
Ctrl+N new note in the current directory
Ctrl+B new note in the inbox
@@ -43,6 +44,12 @@ Ctrl+Z / Ctrl+Y undo / redo
Ctrl+/ toggle this help overlay
Ctrl+Q quit (press twice if there are unsaved changes)
Esc back to the editor
+
+EDITING
+ Home jump to the first non-blank column, then to column 0
+ ( [ and backtick auto-close (no selection): inserts the matching closer
+ with the cursor between; type the closer to step past
+ paste a URL on a selection wraps it as a [selection](url) link
CONFIG
~/.config/glint/config.toml (run 'glint -c' to set it up)