1package ui
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "math"
8 "strings"
9 "testing"
10
11 "github.com/bnema/nefergui/internal/css"
12 "github.com/bnema/nefergui/internal/edit"
13 "github.com/bnema/nefergui/internal/layout"
14 "github.com/bnema/nefergui/internal/text"
15 "github.com/go-text/typesetting/di"
16)
17
18func geometryRuntime(t *testing.T, sheet string) *runtime {
19 t.Helper()
20 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21 if err != nil {
22 t.Fatal(err)
23 }
24 r := newRuntime()
25 r.setTextEngine(text.NewEngine(catalog))
26 r.styles = css.Compile(css.UA(), css.Parse(sheet))
27 return r
28}
29
30func displayCommand(r *runtime, id, op string) (layout.Command, bool) {
31 for _, c := range r.output.Display {
32 if c.ID == id && c.Op == op {
33 return c, true
34 }
35 }
36 return layout.Command{}, false
37}
38
39// rawTrailingEdge computes, independently of editorGeometry, the x where the
40// character at source rune index `rune` ends in reading order: its right ink
41// edge in an LTR run and its left edge in an RTL run. It reads the shaped
42// glyphs of the painted text command and the line's run directions only.
43func rawTrailingEdge(t *testing.T, r *runtime, n *layout.Result, rune int) float64 {
44 t.Helper()
45 cmd, ok := displayCommand(r, n.ID, "text")
46 if !ok {
47 t.Fatal("missing text command")
48 }
49 ri := 0
50 for _, line := range n.Lines {
51 for _, shaped := range line.Runs {
52 run := cmd.Runs[ri]
53 ri++
54 low, high, seen := math.Inf(1), math.Inf(-1), false
55 for _, g := range run.Glyphs {
56 if g.Cluster == rune {
57 seen = true
58 low = math.Min(low, g.X)
59 high = math.Max(high, g.X+g.Advance)
60 }
61 }
62 if !seen {
63 continue
64 }
65 if shaped.Direction == di.DirectionRTL {
66 return low
67 }
68 return high
69 }
70 }
71 t.Fatalf("no glyph for rune %d", rune)
72 return 0
73}
74
75func TestDisplayTextMapsPasswordGraphemes(t *testing.T) {
76 value := "e\u0301猫a"
77 d := newDisplayText(value, true)
78 if d.shown != "•••" {
79 t.Fatalf("shown %q", d.shown)
80 }
81 bullet := len("•")
82 for _, c := range []struct{ logical, shown int }{
83 {0, 0}, {len("e\u0301"), bullet}, {len("e\u0301猫"), 2 * bullet}, {len(value), 3 * bullet},
84 {1, 0}, // inside the first grapheme: start of its cluster
85 } {
86 if got := d.toShown(c.logical); got != c.shown {
87 t.Errorf("toShown(%d) = %d, want %d", c.logical, got, c.shown)
88 }
89 }
90 for _, c := range []struct{ shown, logical int }{{0, 0}, {bullet, len("e\u0301")}, {3 * bullet, len(value)}} {
91 if got := d.toLogical(c.shown); got != c.logical {
92 t.Errorf("toLogical(%d) = %d, want %d", c.shown, got, c.logical)
93 }
94 }
95 plain := newDisplayText(value, false)
96 if plain.shown != value || plain.toShown(3) != 3 || plain.toLogical(3) != 3 {
97 t.Fatalf("plain text must map identically: %+v", plain)
98 }
99}
100
101// At a bidi run boundary one offset has two visual positions. Painting,
102// scrolling and preedit must all use the preceding character's trailing edge.
103func TestMixedDirectionCaretPolicyAgreesAcrossCallers(t *testing.T) {
104 value := "abc مرحبا def"
105 view := func(f *Frame) { f.Root().Input("mixed", &value) }
106 // offset -> source rune index of the character before it.
107 for _, c := range []struct{ offset, before int }{{len("abc "), 3}, {len("abc مرحبا"), 8}} {
108 offset := c.offset
109 r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
110 r.Build(view)
111 r.route(key("Tab"))
112 r.Build(view)
113 n := r.output.Tree.Children[0]
114 r.edits[n.ID].Move(offset, false)
115 r.edits[n.ID].Preedit = "x"
116 r.Redraw()
117 r.Build(view)
118 n = r.output.Tree.Children[0]
119
120 g := newEditorGeometry(r.edits[n.ID], n, r.output.Display, false)
121 var edges []textBoundary
122 for _, b := range g.lines[0] {
123 if b.byteOffset == offset {
124 edges = append(edges, b)
125 }
126 }
127 var leading textBoundary
128 for _, b := range edges {
129 if !b.trailing {
130 leading = b
131 }
132 }
133 want := rawTrailingEdge(t, r, n, c.before)
134 if len(edges) < 2 || math.Abs(want-leading.x) < 1 {
135 t.Fatalf("offset %d is not an ambiguous bidi boundary: %+v (preceding edge %g)", offset, edges, want)
136 }
137 caret, ok := displayCommand(r, n.ID, "caret")
138 if !ok {
139 t.Fatal("missing caret")
140 }
141 preedit, ok := displayCommand(r, n.ID, "preedit-text")
142 if !ok {
143 t.Fatal("missing preedit")
144 }
145 if math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
146 t.Fatalf("offset %d: caret %g preedit %g, want preceding trailing edge %g (leading %g)", offset, caret.Rect.X, preedit.Rect.X, want, leading.x)
147 }
148 }
149}
150
151// The scroll target must be the painted caret, not another duplicate offset.
152func TestMixedDirectionScrollFollowsPaintedCaret(t *testing.T) {
153 value := "abc مرحبا def"
154 view := func(f *Frame) { f.Root().Input("mixed", &value) }
155 // The trailing edge of "abc " is left of the leading edge of the RTL run.
156 // A content box between the two edges scrolls only if the wrong one is used.
157 r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
158 r.Build(view)
159 r.route(key("Tab"))
160 r.Build(view)
161 n := r.output.Tree.Children[0]
162 // Expected edges come from raw glyphs. The space before the RTL run ends at
163 // trailing; the RTL run's reading-order start (its right ink edge) is the
164 // other, leading duplicate of the same offset.
165 trailing := rawTrailingEdge(t, r, n, 3)
166 leading := math.Inf(-1)
167 cmd, _ := displayCommand(r, n.ID, "text")
168 for i, run := range cmd.Runs {
169 if n.Lines[0].Runs[i].Direction == di.DirectionRTL {
170 for _, g := range run.Glyphs {
171 leading = math.Max(leading, g.X+g.Advance)
172 }
173 }
174 }
175 if trailing >= leading {
176 t.Fatalf("fixture changed: trailing %g leading %g", trailing, leading)
177 }
178 // Content wide enough for the preceding character's trailing edge but not
179 // for the following run's leading edge.
180 padding := 300 - n.Content.W
181 contentW := math.Floor((trailing + leading) / 2)
182 r = geometryRuntime(t, fmt.Sprintf("input {height:28px;font-size:16px;width:%gpx}", contentW+padding))
183 r.Build(view)
184 n = r.output.Tree.Children[0]
185 r.edits[n.ID].Move(len("abc "), false) // before focus, so no earlier scroll persists
186 r.route(key("Tab"))
187 r.Build(view)
188 n = r.output.Tree.Children[0]
189 caret, _ := displayCommand(r, n.ID, "caret")
190 if n.ScrollX != 0 || math.Abs(caret.Rect.X-trailing) > 0.01 || caret.Rect.X > n.Content.X+n.Content.W-1 {
191 t.Fatalf("scroll %g caret %g want unscrolled at %g; content %+v", n.ScrollX, caret.Rect.X, trailing, n.Content)
192 }
193}
194
195func TestPasswordCombiningGraphemesScrollAndPreedit(t *testing.T) {
196 value := strings.Repeat("e\u0301", 12)
197 view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
198 r := geometryRuntime(t, `input {width:60px;height:28px;font-size:16px}`)
199 r.Build(view)
200 r.route(key("Tab"))
201 r.Build(view)
202 n := r.output.Tree.Children[0]
203 s := r.edits[n.ID]
204 if s.Cursor != len(value) {
205 t.Fatalf("cursor %d", s.Cursor)
206 }
207 // 12 graphemes are 24 runes; counting runes used to miss the mask boundary.
208 r.Redraw()
209 r.Build(view)
210 n = r.output.Tree.Children[0]
211 if n.ScrollX <= 0 {
212 t.Fatalf("caret at end of a long password did not scroll: %+v", n)
213 }
214 caret, ok := displayCommand(r, n.ID, "caret")
215 if !ok || caret.Rect.X < n.Content.X-0.01 || caret.Rect.X > n.Content.X+n.Content.W {
216 t.Fatalf("caret %+v outside content %+v", caret, n.Content)
217 }
218
219 // Move inside the value, then compose: origin is the painted caret.
220 mid := len(strings.Repeat("e\u0301", 4))
221 s.Move(mid, false)
222 s.Preedit = "e\u0301猫"
223 s.PreeditBegin = len("e\u0301")
224 r.Redraw()
225 r.Build(view)
226 n = r.output.Tree.Children[0]
227 caret, _ = displayCommand(r, n.ID, "caret")
228 preedit, ok := displayCommand(r, n.ID, "preedit-text")
229 // Independent expectation: the trailing edge of the 4th bullet (4 graphemes,
230 // 8 runes, precede the caret) in the shaped masked text.
231 want := rawTrailingEdge(t, r, n, 3)
232 if !ok || math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
233 t.Fatalf("caret %g preedit %g, want after 4th bullet at %g", caret.Rect.X, preedit.Rect.X, want)
234 }
235 if preedit.Text != "••" {
236 t.Fatalf("preedit text %q", preedit.Text)
237 }
238 data, err := json.Marshal(r.output.Display)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if bytes.Contains(data, []byte("e\u0301")) || bytes.Contains(data, []byte("猫")) {
243 t.Fatalf("plaintext leaked: %s", data)
244 }
245 if value != strings.Repeat("e\u0301", 12) {
246 t.Fatalf("model changed: %q", value)
247 }
248}
249
250func TestPasswordPointerPlacementUsesGraphemeOffsets(t *testing.T) {
251 value := "e\u0301a\u0300b"
252 view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
253 r := geometryRuntime(t, `input {width:200px;height:28px;font-size:16px}`)
254 r.Build(view)
255 n := r.output.Tree.Children[0]
256 g := newEditorGeometry(&edit.State{Value: value}, n, r.output.Display, true)
257 // Bullet k (0-based) ends the k+1'th grapheme; its raw trailing edge must
258 // place the caret after that grapheme in the logical value.
259 for k, want := range []int{len("e\u0301"), len("e\u0301a\u0300"), len(value)} {
260 x := rawTrailingEdge(t, r, n, k)
261 if got := g.nearest(x, n.Content.Y+1); got != want {
262 t.Fatalf("nearest(%g) = %d, want %d", x, got, want)
263 }
264 }
265 if got := g.nearest(n.Content.X, n.Content.Y+1); got != 0 {
266 t.Fatalf("nearest at start = %d", got)
267 }
268}
269
270const wrapValue = "one two three four five six"
271
272// wrapFixture builds a focused, narrow textarea and returns the source offset
273// shared by the end of visual line 0 and the start of line 1 ("one |two").
274// The offset comes from the shaped runs, not from editorGeometry.
275func wrapFixture(t *testing.T, height string) (*runtime, func(*Frame), *layout.Result, int) {
276 t.Helper()
277 r := geometryRuntime(t, "textarea {width:45px;height:"+height+";font-size:16px}")
278 value := wrapValue
279 view := func(f *Frame) { f.Root().Textarea("wrapped", &value) }
280 r.Build(view)
281 n := r.output.Tree.Children[0]
282 if len(n.Lines) < 3 {
283 t.Fatalf("expected wrapped lines, got %d", len(n.Lines))
284 }
285 first, second := n.Lines[0].Runs, n.Lines[1].Runs
286 shared := first[len(first)-1].End
287 if shared == 0 || shared != second[0].Start || shared >= len(value) {
288 t.Fatalf("lines do not share a wrap offset: %+v %+v", first, second)
289 }
290 r.edits[n.ID].Move(shared, false) // ASCII: rune index == byte offset
291 r.route(key("Tab"))
292 r.Build(view)
293 return r, view, r.output.Tree.Children[0], shared
294}
295
296// At a soft wrap the offset is the end of line 0 and the start of line 1.
297// Painting and preedit use the end of line 0 (preceding character), and the
298// scroll follows that same caret, while vertical navigation still treats the
299// offset as being on the following line.
300func TestSoftWrapCaretPolicyAgreesAcrossCallers(t *testing.T) {
301 r, view, n, shared := wrapFixture(t, "100px")
302 s := r.edits[n.ID]
303 if s.Cursor != shared {
304 t.Fatalf("cursor %d want %d", s.Cursor, shared)
305 }
306 wantX := rawTrailingEdge(t, r, n, shared-1)
307 wantY := n.Content.Y
308 nextLineY := n.Content.Y + n.Lines[0].Height
309 s.Preedit = "x"
310 r.Redraw()
311 r.Build(view)
312 n = r.output.Tree.Children[0]
313 caret, ok := displayCommand(r, n.ID, "caret")
314 if !ok {
315 t.Fatal("missing caret")
316 }
317 preedit, ok := displayCommand(r, n.ID, "preedit-text")
318 if !ok {
319 t.Fatal("missing preedit")
320 }
321 for name, rect := range map[string]layout.Rect{"caret": caret.Rect, "preedit": preedit.Rect} {
322 if math.Abs(rect.X-wantX) > 0.01 || math.Abs(rect.Y-wantY) > 0.01 || rect.Y == nextLineY {
323 t.Errorf("%s at (%g,%g), want end of preceding line (%g,%g)", name, rect.X, rect.Y, wantX, wantY)
324 }
325 }
326 if caret.Rect.H != n.Lines[0].Height {
327 t.Errorf("caret height %g want line height %g", caret.Rect.H, n.Lines[0].Height)
328 }
329}
330
331func TestSoftWrapScrollFollowsPrecedingLineCaret(t *testing.T) {
332 // Content holds exactly one line. The caret at the wrap offset is on line
333 // 0, so nothing scrolls; following line 1 instead would scroll.
334 r, _, n, _ := wrapFixture(t, "22px")
335 if n.Content.H >= 2*n.Lines[0].Height {
336 t.Fatalf("fixture shows more than one line: %+v", n.Content)
337 }
338 if n.ScrollY != 0 {
339 t.Fatalf("scrolled to the following line: %g", n.ScrollY)
340 }
341 if caret, ok := displayCommand(r, n.ID, "caret"); !ok || caret.Rect.Y != n.Content.Y {
342 t.Fatalf("caret %+v content %+v", caret, n.Content)
343 }
344}
345
346func TestSoftWrapVerticalNavigationKeepsFollowingLinePolicy(t *testing.T) {
347 // Down from the shared offset moves off line 1 (to the start of line 2),
348 // not off line 0; up moves off line 1 to line 0, whichever the caret paints.
349 r, view, n, shared := wrapFixture(t, "100px")
350 s := r.edits[n.ID]
351 lineEnd := n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End
352 r.route(key("Down"))
353 r.Build(view)
354 if s.Cursor != lineEnd {
355 t.Fatalf("down from %d = %d, want start of line 2 at %d", shared, s.Cursor, lineEnd)
356 }
357 if !s.PreferredXValid || s.PreferredX != n.Content.X {
358 t.Fatalf("preferred x %v %g, want line-1 start %g", s.PreferredXValid, s.PreferredX, n.Content.X)
359 }
360 s.Move(shared, false)
361 s.PreferredXValid = false
362 r.route(key("Up"))
363 r.Build(view)
364 if s.Cursor != 0 {
365 t.Fatalf("up from %d = %d, want start of line 0", shared, s.Cursor)
366 }
367}
368
369// Scrolling uses the height of the caret's own line, not line 0's.
370func TestScrollCaretUsesCaretLineHeight(t *testing.T) {
371 r, _, n, _ := wrapFixture(t, "100px")
372 id := n.ID
373 // Line 0 is short and line 1 is tall. The caret is at the end of line 1.
374 n.Lines[0].Height = 10
375 n.Lines[1].Height = 30
376 n.Content.H = 30
377 n.ContentSize.H = 200
378 s := r.edits[id]
379 s.Move(n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End-1, false) // before "two"'s trailing space edge: on line 1
380 if r.state.scroll != nil {
381 delete(r.state.scroll, id)
382 }
383 if !r.scrollCaret(r.committed, &r.output) {
384 t.Fatal("caret below the viewport did not scroll")
385 }
386 // Caret line 1 spans y 10..40 in a 30px viewport: scroll by 10. Using line 0's
387 // height (10) would end at 20 and not scroll at all.
388 if got := r.state.scroll[id].H; math.Abs(got-10) > 0.01 {
389 t.Fatalf("scroll %g, want 10 (caret line bottom 40 - viewport 30)", got)
390 }
391}