1package ui
2
3import (
4 "math"
5 "sort"
6 "unicode/utf8"
7
8 "github.com/bnema/nefergui/internal/edit"
9 "github.com/bnema/nefergui/internal/layout"
10 "github.com/go-text/typesetting/di"
11)
12
13// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14// far edge of the character that precedes byteOffset; otherwise it is the
15// leading edge of the character that follows. line indexes layout.Result.Lines.
16type textBoundary struct {
17 byteOffset int
18 x, y float64
19 line int
20 trailing bool
21}
22
23// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
24// Runs are in visual order; glyph cluster indices are source rune offsets.
25func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
26 if n == nil {
27 return nil
28 }
29 offsets := edit.Boundaries(value)
30 runeAt := make(map[int]int, len(offsets))
31 for _, b := range offsets {
32 runeAt[utf8.RuneCountInString(value[:b])] = b
33 }
34 var runs []layout.Run
35 for _, cmd := range display {
36 if cmd.ID == n.ID && cmd.Op == "text" {
37 runs = cmd.Runs
38 break
39 }
40 }
41 var lines [][]textBoundary
42 ri := 0
43 top := n.Content.Y
44 for _, line := range n.Lines {
45 current := []textBoundary{}
46 for _, shaped := range line.Runs {
47 if ri >= len(runs) {
48 break
49 }
50 run := runs[ri]
51 ri++
52 // Cluster start and end positions are derived from the glyph ink advances,
53 // not rune counts (ligatures and combining graphemes have one visual span).
54 type span struct{ low, high float64 }
55 spans := make(map[int]span)
56 for _, g := range run.Glyphs {
57 p, ok := spans[g.Cluster]
58 if !ok {
59 p = span{g.X, g.X + g.Advance}
60 } else {
61 p.low = math.Min(p.low, g.X)
62 p.high = math.Max(p.high, g.X+g.Advance)
63 }
64 spans[g.Cluster] = p
65 }
66 starts := make([]int, 0, len(spans))
67 for k := range spans {
68 starts = append(starts, k)
69 }
70 sort.Ints(starts)
71 for i, k := range starts {
72 end := shaped.End
73 if i+1 < len(starts) {
74 end = starts[i+1]
75 }
76 p := spans[k]
77 a, b := p.low, p.high
78 if shaped.Direction == di.DirectionRTL {
79 a, b = b, a
80 }
81 if offset, ok := runeAt[k]; ok {
82 current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
83 }
84 if offset, ok := runeAt[end]; ok {
85 current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
86 }
87 }
88 }
89 if len(current) == 0 { // empty paragraph, including a trailing newline
90 offset := 0
91 if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
92 for _, boundary := range lines[len(lines)-1] {
93 if boundary.byteOffset > offset {
94 offset = boundary.byteOffset
95 }
96 }
97 for offset < len(value) && value[offset] == '\n' {
98 offset++
99 }
100 }
101 current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
102 }
103 lines = append(lines, current)
104 top += line.Height
105 }
106 return lines
107}
108
109// displayText is the text an editor shows for a logical value, with the
110// mapping between their byte offsets. A password shows one bullet per grapheme
111// cluster, so logical and shown offsets differ; otherwise they are identical.
112// It is the only place that maps password offsets.
113type displayText struct {
114 shown string
115 logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116}
117
118func newDisplayText(value string, password bool) displayText {
119 if !password {
120 return displayText{shown: value}
121 }
122 shown := (&edit.State{Value: value}).Mask()
123 return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124}
125
126// toShown maps a logical byte offset to the shown string. An offset inside a
127// grapheme cluster maps to the start of that cluster.
128func (d displayText) toShown(offset int) int {
129 if d.logical == nil {
130 return offset
131 }
132 i := sort.SearchInts(d.logical, offset+1) - 1
133 if i < 0 {
134 i = 0
135 }
136 return d.display[i]
137}
138
139// toLogical maps a byte offset of the shown string back to the value.
140func (d displayText) toLogical(offset int) int {
141 if d.logical == nil {
142 return offset
143 }
144 i := sort.SearchInts(d.display, offset+1) - 1
145 if i < 0 {
146 i = 0
147 }
148 return d.logical[i]
149}
150
151// editorGeometry is the single owner of where editor byte offsets sit on
152// screen. Every caller (pointer placement, vertical movement, selection and
153// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154// Boundary offsets are logical value offsets, password or not.
155//
156// Caret policy: a boundary can have several visual positions (bidi run
157// changes, soft wraps). The caret sits at the trailing edge of the character
158// before the offset; the leading edge of the following character is used only
159// when no character precedes the offset.
160type editorGeometry struct {
161 node *layout.Result
162 lines [][]textBoundary
163}
164
165func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166 text := newDisplayText(s.Value, password)
167 lines := textBoundaries(text.shown, n, display)
168 if text.logical != nil {
169 for _, line := range lines {
170 for i := range line {
171 line[i].byteOffset = text.toLogical(line[i].byteOffset)
172 }
173 }
174 }
175 return editorGeometry{node: n, lines: lines}
176}
177
178// caret returns the visual caret position for a logical offset.
179func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180 var leading *textBoundary
181 for i := range g.lines {
182 b, trailing := preferredBoundary(g.lines[i], offset)
183 if b == nil {
184 continue
185 }
186 if trailing {
187 return *b, true
188 }
189 if leading == nil {
190 leading = b
191 }
192 }
193 if leading == nil {
194 return textBoundary{}, false
195 }
196 return *leading, true
197}
198
199// caretOnLine applies the caret policy within one visual line.
200func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201 b, _ := preferredBoundary(g.lines[line], offset)
202 if b == nil {
203 return textBoundary{}, false
204 }
205 return *b, true
206}
207
208// preferredBoundary picks the trailing edge of the preceding character over
209// the leading edge of the following one, else the first leading edge.
210func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211 var leading *textBoundary
212 for j := range line {
213 b := &line[j]
214 if b.byteOffset != offset {
215 continue
216 }
217 if b.trailing {
218 return b, true
219 }
220 if leading == nil {
221 leading = b
222 }
223 }
224 return leading, false
225}
226
227// lineHeight is the height of a visual line, falling back to the content box.
228func (g editorGeometry) lineHeight(line int) float64 {
229 if line >= 0 && line < len(g.node.Lines) {
230 return g.node.Lines[line].Height
231 }
232 return g.node.Content.H
233}
234
235// nearest returns the logical offset whose boundary is horizontally closest
236// to (x, y) on the visual line under y.
237func (g editorGeometry) nearest(x, y float64) int {
238 if len(g.lines) == 0 {
239 return 0
240 }
241 line := 0
242 top := g.node.Content.Y
243 for i, shaped := range g.node.Lines {
244 if y >= top+shaped.Height/2 {
245 line = i
246 }
247 top += shaped.Height
248 }
249 if line >= len(g.lines) {
250 line = len(g.lines) - 1
251 }
252 best := 0
253 distance := math.Inf(1)
254 for _, b := range g.lines[line] {
255 if d := math.Abs(b.x - x); d < distance {
256 distance = d
257 best = b.byteOffset
258 }
259 }
260 return best
261}