v0.1.0 47aaa0f

edit_geometry_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close