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
|
package editor
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// SetGrammar sets whether grammar checking is enabled for the session. Diagnostics
// keep arriving from the app regardless; this only gates their rendering.
func (e *Editor) SetGrammar(on bool) { e.grammarOn = on; e.invalidate() }
// ToggleGrammar flips grammar checking and returns the new state.
func (e *Editor) ToggleGrammar() bool { e.grammarOn = !e.grammarOn; e.invalidate(); return e.grammarOn }
// GrammarEnabled reports the user's session toggle for grammar checking.
func (e *Editor) GrammarEnabled() bool { return e.grammarOn }
// SetGrammarDiags replaces the grammar-underline ranges, keyed by logical line
// (each range is a [start,end) rune column pair). Callers pass whatever Harper
// last reported for the current buffer; an empty map clears all grammar
// underlines. The visual model is invalidated so the change shows on next render.
func (e *Editor) SetGrammarDiags(byLine map[int][][2]int) {
e.grammarDiags = byLine
e.invalidate()
}
// grammarActive reports whether grammar underlines should render: enabled and the
// buffer is prose (grammar, like spellcheck, skips code files).
func (e *Editor) grammarActive() bool {
return e.grammarOn && e.codeFile == "" && len(e.grammarDiags) > 0
}
// GrammarSpanAt returns the rune range [start,end) of the grammar underline covering
// rune column col on row (inclusive of the column just past the span, so a cursor
// resting at its end still resolves it). ok is false when grammar is inactive or no
// span sits there.
func (e *Editor) GrammarSpanAt(row, col int) (start, end int, ok bool) {
if !e.grammarActive() {
return 0, 0, false
}
for _, r := range e.grammarDiags[row] {
if col >= r[0] && col <= r[1] {
return r[0], r[1], true
}
}
return 0, 0, false
}
// RuneRangeText returns the substring of row over rune range [start,end), clamped to
// the line, or "" when the row is out of range.
func (e *Editor) RuneRangeText(row, start, end int) string {
if row < 0 || row >= len(e.Lines) {
return ""
}
r := []rune(e.Lines[row])
if start < 0 {
start = 0
}
if end > len(r) {
end = len(r)
}
if start >= end {
return ""
}
return string(r[start:end])
}
// ReplaceRuneRange replaces the document span from (startRow,startCol) to
// (endRow,endCol) — rune columns, end exclusive — with s, which may itself contain
// newlines. It parks the cursor after the inserted text, marks the buffer dirty, and
// records an undo checkpoint. Out-of-range coordinates are a no-op. This applies a
// single Harper fix edit (usually single-line, but multi-line ranges are honored).
func (e *Editor) ReplaceRuneRange(startRow, startCol, endRow, endCol int, s string) {
if startRow < 0 || endRow >= len(e.Lines) || startRow > endRow {
return
}
first := []rune(e.Lines[startRow])
last := []rune(e.Lines[endRow])
if startCol < 0 || startCol > len(first) || endCol < 0 || endCol > len(last) {
return
}
if startRow == endRow && startCol > endCol {
return
}
e.PushUndo()
merged := string(first[:startCol]) + s + string(last[endCol:])
parts := strings.Split(merged, "\n")
rebuilt := make([]string, 0, len(e.Lines)-(endRow-startRow)+len(parts)-1)
rebuilt = append(rebuilt, e.Lines[:startRow]...)
rebuilt = append(rebuilt, parts...)
rebuilt = append(rebuilt, e.Lines[endRow+1:]...)
e.Lines = rebuilt
// Park the cursor at the end of the inserted text (not the merged line): count
// s's own newlines, so a trailing suffix after the edit stays to the cursor's right.
sLines := strings.Split(s, "\n")
curRow := startRow + len(sLines) - 1
curCol := len([]rune(sLines[len(sLines)-1]))
if len(sLines) == 1 {
curCol = startCol + curCol
}
e.Cursor = Position{Row: curRow, Col: curCol}
e.anchor = nil
e.Dirty = true
e.invalidate()
e.setGoal()
e.followCursor()
}
// grammarPass overlays a green undercurl on each grammar range, layered after
// spellPass so a word already flagged red (misspelled) keeps its red underline —
// spelling outranks grammar on the same text.
func (e *Editor) grammarPass(all [][]Span) [][]Span {
for li := range all {
ranges := e.grammarDiags[li]
if len(ranges) == 0 {
continue
}
spans := all[li]
for _, r := range ranges {
spans = overlayUndercurl(spans, r[0], r[1], e.theme.Grammar)
}
all[li] = spans
}
return all
}
// overlayUndercurl marks the rune range [a,b) of spans with a curly underline in
// color, splitting spans at the range boundaries (like overlaySelection). Only
// prose spans not already Wavy are marked, so grammar green never repaints a
// misspelling's red nor underlines markup punctuation.
func overlayUndercurl(spans []Span, a, b int, color lipgloss.Color) []Span {
total := 0
for _, sp := range spans {
total += len([]rune(sp.Text))
}
if a < 0 {
a = 0
}
if b > total {
b = total
}
if a >= b {
return spans
}
out := sliceSpans(spans, 0, a)
mid := sliceSpans(spans, a, b)
for i := range mid {
if mid[i].Prose && !mid[i].Wavy {
mid[i].Wavy = true
mid[i].UnderColor = color
}
}
out = append(out, mid...)
return append(out, sliceSpans(spans, b, total)...)
}
|