package editor import ( "path/filepath" "strings" "glint/internal/spell" "glint/internal/theme" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Position is a cursor location in rune coordinates. type Position struct { Row, Col int } // Editor is a self-contained markdown text buffer. It knows nothing about // files or the vault; the app wires load and save around it. type Editor struct { Lines []string Cursor Position Scroll int Dirty bool Width int Height int // visible text rows goalCol int // remembered visual column for vertical moves anchor *Position // selection anchor; nil = no selection theme theme.Theme undo []snapshot // edit checkpoints, oldest first (TASK-006) redo []snapshot // undone checkpoints awaiting redo lastKind editKind // kind of the last recorded group, for coalescing find []match // in-document find matches, document order (TASK-007) findActive int // index of the active match in find, -1 = none findQuery string // current find query codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018) buildCount int // count of buildVisual scans; perf guard for tests (TASK-004) visual []vrow // memoized scan+wrap model; nil = stale, rebuilt lazily dict *spell.Dict // loaded spellchecker; nil = inert (TASK-020) spellOn bool // session spellcheck toggle spellCache map[string]bool // word -> known, cleared when the personal dict changes spellIgnore map[string]bool // words ignored for this session only } // SetLanguage selects the scanner from the file's extension: markdown/text/no // extension keep the prose scanner; any other extension uses the code scanner, // with the base filename passed to chroma for lexer selection. func (e *Editor) SetLanguage(path string) { switch strings.ToLower(filepath.Ext(path)) { case "", ".md", ".markdown", ".mdx", ".txt", ".text": e.codeFile = "" default: e.codeFile = filepath.Base(path) } e.invalidate() } // New returns an empty editor with one blank line and the default theme. func New() *Editor { return &Editor{ Lines: []string{""}, theme: theme.FlexokiDark(), Width: 80, Height: 24, } } // SetTheme swaps the active theme; the next View re-scans with the new colors. func (e *Editor) SetTheme(t theme.Theme) { e.theme = t; e.invalidate() } // SetContent replaces the buffer, resetting cursor, scroll, and dirty state. func (e *Editor) SetContent(b []byte) { text := strings.ReplaceAll(string(b), "\r\n", "\n") e.Lines = strings.Split(text, "\n") if len(e.Lines) == 0 { e.Lines = []string{""} } e.Cursor = Position{} e.Scroll = 0 e.goalCol = 0 e.Dirty = false e.resetHistory() e.ClearFind() e.invalidate() } // Bytes serializes the buffer with \n line separators. func (e *Editor) Bytes() []byte { return []byte(strings.Join(e.Lines, "\n")) } // SetSize records the viewport dimensions (h = visible text rows). func (e *Editor) SetSize(w, h int) { e.Width = w if h < 1 { h = 1 } e.Height = h e.invalidate() // width change alters wrapping e.followCursor() } func (e *Editor) curLine() []rune { return []rune(e.Lines[e.Cursor.Row]) } func (e *Editor) setLine(rs []rune) { e.Lines[e.Cursor.Row] = string(rs) } // KillToLineStart deletes from the start of the line to the cursor and parks the // cursor at column 0 (Ctrl+U; the macOS Cmd+Delete behavior). func (e *Editor) KillToLineStart() { if e.Cursor.Col == 0 { return } e.setLine(e.curLine()[e.Cursor.Col:]) e.Cursor.Col = 0 e.Dirty = true e.setGoal() e.followCursor() } // MoveToVisual places the cursor at absolute visual row vi, col runes into that // row — used to map a mouse click (after the caller subtracts margins/scroll) to // a buffer position. Indices are clamped to the document. func (e *Editor) MoveToVisual(vi, col int) { rows := e.buildVisual() if len(rows) == 0 { return } if vi < 0 { vi = 0 } if vi >= len(rows) { vi = len(rows) - 1 } vr := rows[vi] if col < 0 { col = 0 } if col > vr.runes { col = vr.runes } e.Cursor.Row = vr.logRow e.Cursor.Col = vr.start + col e.setGoal() e.followCursorWith(rows) } // ScrollBy moves the viewport by delta visual rows (negative = up) without // moving the cursor, clamped so it never scrolls past the content. func (e *Editor) ScrollBy(delta int) { max := len(e.buildVisual()) - e.Height if max < 0 { max = 0 } e.Scroll += delta if e.Scroll < 0 { e.Scroll = 0 } if e.Scroll > max { 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} e.setGoal() e.followCursor() } // MoveDocEnd jumps to the end of the last line (Cmd+Down via the terminal). func (e *Editor) MoveDocEnd() { last := len(e.Lines) - 1 e.Cursor.Row = last e.Cursor.Col = len([]rune(e.Lines[last])) e.setGoal() e.followCursor() } func isWordSpace(r rune) bool { return r == ' ' || r == '\t' } // MoveWordLeft moves to the start of the previous word (Alt+Left). func (e *Editor) MoveWordLeft() { if e.Cursor.Col == 0 { if e.Cursor.Row > 0 { e.Cursor.Row-- e.Cursor.Col = len(e.curLine()) } } else { rs := e.curLine() c := e.Cursor.Col for c > 0 && isWordSpace(rs[c-1]) { c-- } for c > 0 && !isWordSpace(rs[c-1]) { c-- } e.Cursor.Col = c } e.setGoal() e.followCursor() } // MoveWordRight moves to the end of the next word (Alt+Right). func (e *Editor) MoveWordRight() { rs := e.curLine() if e.Cursor.Col >= len(rs) { if e.Cursor.Row < len(e.Lines)-1 { e.Cursor.Row++ e.Cursor.Col = 0 } } else { c := e.Cursor.Col for c < len(rs) && isWordSpace(rs[c]) { c++ } for c < len(rs) && !isWordSpace(rs[c]) { c++ } e.Cursor.Col = c } e.setGoal() e.followCursor() } // DeleteWordLeft deletes the word before the cursor (Alt+Backspace). func (e *Editor) DeleteWordLeft() { if e.Cursor.Col == 0 { e.Backspace() // at line start, fall back to joining lines return } rs := e.curLine() end := e.Cursor.Col start := end for start > 0 && isWordSpace(rs[start-1]) { start-- } for start > 0 && !isWordSpace(rs[start-1]) { start-- } e.setLine(append(rs[:start], rs[end:]...)) e.Cursor.Col = start e.Dirty = true e.setGoal() e.followCursor() } // DeleteWordRight deletes the word after the cursor (Alt+D). func (e *Editor) DeleteWordRight() { rs := e.curLine() if e.Cursor.Col >= len(rs) { e.Delete() // at line end, fall back to joining lines return } c := e.Cursor.Col end := c for end < len(rs) && isWordSpace(rs[end]) { end++ } for end < len(rs) && !isWordSpace(rs[end]) { end++ } e.setLine(append(rs[:c], rs[end:]...)) e.Dirty = true e.setGoal() e.followCursor() } // KillToLineEnd deletes from the cursor to the end of the line (Ctrl+K). func (e *Editor) KillToLineEnd() { rs := e.curLine() if e.Cursor.Col >= len(rs) { return } e.setLine(rs[:e.Cursor.Col]) e.Dirty = true e.setGoal() e.followCursor() } // InsertRune inserts r at the cursor and advances it. func (e *Editor) InsertRune(r rune) { rs := e.curLine() col := clamp(e.Cursor.Col, 0, len(rs)) rs = append(rs[:col], append([]rune{r}, rs[col:]...)...) e.setLine(rs) e.Cursor.Col = col + 1 e.Dirty = true 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() col := clamp(e.Cursor.Col, 0, len(rs)) left, right := string(rs[:col]), string(rs[col:]) e.Lines[e.Cursor.Row] = left rest := append([]string{right}, e.Lines[e.Cursor.Row+1:]...) e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...) e.Cursor.Row++ e.Cursor.Col = 0 e.Dirty = true e.setGoal() e.followCursor() } // Backspace deletes the rune before the cursor, joining lines at column 0. func (e *Editor) Backspace() { if e.Cursor.Col > 0 { rs := e.curLine() rs = append(rs[:e.Cursor.Col-1], rs[e.Cursor.Col:]...) e.setLine(rs) e.Cursor.Col-- e.Dirty = true e.setGoal() e.followCursor() return } if e.Cursor.Row == 0 { return } prev := []rune(e.Lines[e.Cursor.Row-1]) joinCol := len(prev) merged := string(prev) + e.Lines[e.Cursor.Row] e.Lines[e.Cursor.Row-1] = merged e.Lines = append(e.Lines[:e.Cursor.Row], e.Lines[e.Cursor.Row+1:]...) e.Cursor.Row-- e.Cursor.Col = joinCol e.Dirty = true e.setGoal() e.followCursor() } // Delete removes the rune at the cursor, joining the next line at end-of-line. func (e *Editor) Delete() { rs := e.curLine() if e.Cursor.Col < len(rs) { rs = append(rs[:e.Cursor.Col], rs[e.Cursor.Col+1:]...) e.setLine(rs) e.Dirty = true e.setGoal() e.followCursor() return } if e.Cursor.Row >= len(e.Lines)-1 { return } e.Lines[e.Cursor.Row] = e.Lines[e.Cursor.Row] + e.Lines[e.Cursor.Row+1] e.Lines = append(e.Lines[:e.Cursor.Row+1], e.Lines[e.Cursor.Row+2:]...) e.Dirty = true e.setGoal() e.followCursor() } // MoveLeft moves one rune left, wrapping to the end of the previous line. func (e *Editor) MoveLeft() { if e.Cursor.Col > 0 { e.Cursor.Col-- } else if e.Cursor.Row > 0 { e.Cursor.Row-- e.Cursor.Col = len([]rune(e.Lines[e.Cursor.Row])) } e.setGoal() e.followCursor() } // MoveRight moves one rune right, wrapping to the start of the next line. func (e *Editor) MoveRight() { if e.Cursor.Col < len(e.curLine()) { e.Cursor.Col++ } else if e.Cursor.Row < len(e.Lines)-1 { e.Cursor.Row++ e.Cursor.Col = 0 } e.setGoal() e.followCursor() } // MoveUp moves to the previous visual row, keeping the goal column. func (e *Editor) MoveUp() { rows := e.buildVisual() ci := cursorVIndex(rows, e.Cursor) if ci <= 0 { return } e.applyGoal(rows, ci-1) e.followCursorWith(rows) } // MoveDown moves to the next visual row, keeping the goal column. func (e *Editor) MoveDown() { rows := e.buildVisual() ci := cursorVIndex(rows, e.Cursor) if ci < 0 || ci+1 >= len(rows) { return } e.applyGoal(rows, ci+1) e.followCursorWith(rows) } // applyGoal places the cursor at the goal column on the visual row at idx. For a // non-last visual segment of a wrapped line, the column is clamped to runes-1: // start+runes is the next segment's first column, so landing there would skip // the cursor onto the following visual row. func (e *Editor) applyGoal(rows []vrow, idx int) { t := rows[idx] maxCol := t.runes if idx+1 < len(rows) && rows[idx+1].logRow == t.logRow && maxCol > 0 { maxCol-- // non-last segment of this logical line } e.Cursor.Row = t.logRow e.Cursor.Col = t.start + min(e.goalCol, maxCol) } // 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() { 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() } // MoveEnd moves to end of line. func (e *Editor) MoveEnd() { e.Cursor.Col = len(e.curLine()) e.setGoal() e.followCursor() } // followCursor scrolls the viewport so the cursor's visual row stays visible. func (e *Editor) followCursor() { e.followCursorWith(e.buildVisual()) } // followCursorWith is followCursor against an already-built visual model, so a // caller that just scanned (MoveUp/MoveDown/MoveToVisual) reuses those rows // instead of triggering a second O(document) scan (TASK-004). func (e *Editor) followCursorWith(rows []vrow) { ci := cursorVIndex(rows, e.Cursor) if ci < 0 { e.Scroll = 0 return } if ci < e.Scroll { e.Scroll = ci } if ci >= e.Scroll+e.Height { e.Scroll = ci - e.Height + 1 } if e.Scroll < 0 { e.Scroll = 0 } } // setGoal records the cursor's current visual column as the goal for vertical moves. func (e *Editor) setGoal() { e.goalCol = e.visualColOf(e.Cursor.Row, e.Cursor.Col) } // visualColOf returns the column of (row, col) within its visual segment. func (e *Editor) visualColOf(row, col int) int { segs := wrapLine(e.Lines[row], e.Width) for i := len(segs) - 1; i >= 0; i-- { if col >= segs[i].start { return col - segs[i].start } } return col } // HandleKey maps a key message to a buffer operation, recording an undo // checkpoint around any mutation. Navigation breaks typing coalescing so a // later edit starts a fresh group. func (e *Editor) HandleKey(k tea.KeyMsg) { kind, mutates := editKindOf(k) if !mutates { e.lastKind = kindNone e.dispatch(k) return } e.invalidate() // mutating key: drop the cache so dispatch rebuilds from new content // Continuing a typing run: extend the existing group, no new checkpoint. if kind == kindType && e.lastKind == kindType && len(e.undo) > 0 { e.dispatch(k) e.redo = nil return } pre := e.snapshot() e.dispatch(k) if e.sameContent(pre) { return // no-op key (e.g. backspace at start of doc): don't record } e.pushUndoSnapshot(pre) e.redo = nil e.lastKind = kind } // dispatch performs the raw buffer operation for a key, without undo bookkeeping. func (e *Editor) dispatch(k tea.KeyMsg) { switch k.Type { // Text input — replaces any active selection. case tea.KeyRunes: // Option-as-Meta terminals send Option+Left/Right as Alt+b / Alt+f // (readline word motion). Handle those and never insert an Alt-runed key. if k.Alt { switch string(k.Runes) { case "b": e.MoveWordLeft() case "f": e.MoveWordRight() case "d": e.DeleteWordRight() case "s": // bold (strong) e.WrapBold() case "i": // italic e.WrapItalic() case "c": // inline code e.WrapCode() case "k": // link e.WrapLink() case "x": // toggle checkbox (TASK-023) e.ToggleCheckbox() } return } // With a selection, typing a wrap punctuation surrounds it (nests on // repeat) instead of replacing it. if e.HasSelection() && len(k.Runes) == 1 { 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 { e.InsertRune(r) } case tea.KeySpace: e.replaceSelection() if len(k.Runes) == 0 { e.InsertRune(' ') } for _, r := range k.Runes { e.InsertRune(r) } case tea.KeyEnter: e.replaceSelection() if !e.ContinueList() { e.InsertNewline() } case tea.KeyTab: 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: if e.DeleteSelection() { return } if k.Alt { e.DeleteWordLeft() } else { e.Backspace() } case tea.KeyDelete: if e.DeleteSelection() { return } e.Delete() case tea.KeyCtrlU: e.KillToLineStart() case tea.KeyCtrlK: e.KillToLineEnd() case tea.KeyCtrlW: e.DeleteWordLeft() // Plain movement collapses the selection. case tea.KeyLeft: e.ClearSelection() if k.Alt { e.MoveWordLeft() } else { e.MoveLeft() } case tea.KeyRight: e.ClearSelection() if k.Alt { e.MoveWordRight() } else { e.MoveRight() } case tea.KeyUp: if k.Alt { e.MoveLineUp() // reorder the current line (TASK-024) return } e.ClearSelection() e.MoveUp() case tea.KeyDown: if k.Alt { e.MoveLineDown() // reorder the current line (TASK-024) return } e.ClearSelection() e.MoveDown() case tea.KeyCtrlUp: e.ClearSelection() e.MoveHome() case tea.KeyCtrlDown: e.ClearSelection() e.MoveEnd() case tea.KeyHome: e.ClearSelection() e.MoveDocStart() case tea.KeyEnd: e.ClearSelection() e.MoveDocEnd() // Shift movement extends the selection. case tea.KeyShiftLeft: e.startSelection() e.MoveLeft() case tea.KeyShiftRight: e.startSelection() e.MoveRight() case tea.KeyShiftUp: e.startSelection() e.MoveUp() case tea.KeyShiftDown: e.startSelection() e.MoveDown() case tea.KeyShiftHome: e.startSelection() e.MoveDocStart() case tea.KeyShiftEnd: e.startSelection() e.MoveDocEnd() case tea.KeyCtrlShiftLeft: e.startSelection() e.MoveWordLeft() case tea.KeyCtrlShiftRight: e.startSelection() e.MoveWordRight() case tea.KeyCtrlShiftUp: e.startSelection() e.MoveHome() case tea.KeyCtrlShiftDown: e.startSelection() e.MoveEnd() } } // replaceSelection deletes the selection (if any) so the next insert overwrites it. func (e *Editor) replaceSelection() { e.DeleteSelection() } // View renders the visible visual rows, styled, with a themed cursor cell on // the cursor's visual row. Output is e.Width columns wide; the app adds margins. func (e *Editor) View() string { cursorStyle := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.Pointer) selStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg) findStyle := lipgloss.NewStyle().Foreground(e.theme.Text).Background(e.theme.Highlight) findActiveStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg) rows := e.buildVisual() ci := cursorVIndex(rows, e.Cursor) var b strings.Builder end := e.Scroll + e.Height if end > len(rows) { end = len(rows) } for r := e.Scroll; r < end; r++ { spans := rows[r].spans if a, bb, ok := e.selectionForRow(rows[r]); ok { spans = overlaySelection(spans, a, bb, selStyle) } for _, m := range e.matchRangesForRow(rows[r]) { st := findStyle if m.active { st = findActiveStyle } spans = overlaySelection(spans, m.a, m.b, st) } if r == ci { b.WriteString(renderSpansCursor(spans, e.Cursor.Col-rows[r].start, cursorStyle)) } else { b.WriteString(renderSpans(spans)) } b.WriteByte('\n') } for r := end; r < e.Scroll+e.Height; r++ { b.WriteByte('\n') } return b.String() } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v }