turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

view_test.go · 557 lines · 18.2 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 20h ago1package editor
2
3import (
4 "fmt"
5 "math"
6 "strings"
7 "testing"
8
9 "github.com/gdamore/tcell/v2"
10
📦 Turbo Core f3ade8d k33g 13h ago11 "rickub.com/turbo-editors/turbo-core/buffer"
12 "rickub.com/turbo-editors/turbo-core/theme"
13 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 20h ago14)
15
16// newTestView returns a view onto text, sized to bounds, with a screen behind
17// it so that Draw can be exercised for real.
18func newTestView(t *testing.T, text string, width, height int) (*View, tcell.SimulationScreen, *ui.Painter) {
19 t.Helper()
20
21 screen := tcell.NewSimulationScreen("UTF-8")
22 if err := screen.Init(); err != nil {
23 t.Fatalf("initialising the simulation screen: %v", err)
24 }
25 t.Cleanup(screen.Fini)
26 screen.SetSize(width, height)
27
28 buf := buffer.NewFromString(text)
29 buf.SetPath("build.sh")
30 view := NewView(buf, &Clipboard{})
31 view.SetBounds(ui.Rect{X: 0, Y: 0, W: width, H: height})
32
33 return view, screen, ui.NewPainter(screen)
34}
35
36// screenLines returns what the simulated screen shows, one string per row.
37func screenLines(t *testing.T, screen tcell.SimulationScreen) []string {
38 t.Helper()
39
40 cells, width, height := screen.GetContents()
41 lines := make([]string, height)
42 for y := range height {
43 var row strings.Builder
44 for x := range width {
45 runes := cells[y*width+x].Runes
46 if len(runes) == 0 {
47 row.WriteRune(' ')
48 continue
49 }
50 row.WriteRune(runes[0])
51 }
52 lines[y] = row.String()
53 }
54 return lines
55}
56
57// testTheme returns a theme good enough to draw with.
58func testTheme(t *testing.T) *theme.Theme {
59 t.Helper()
60 return theme.Default("")
61}
62
63// draw paints the view and returns what the screen shows.
64func draw(t *testing.T, view *View, screen tcell.SimulationScreen, p *ui.Painter) []string {
65 t.Helper()
66 view.Draw(p.Sub(view.Bounds()), testTheme(t))
67 screen.Show()
68 return screenLines(t, screen)
69}
70
71func key(k tcell.Key, mods tcell.ModMask) *tcell.EventKey {
72 return tcell.NewEventKey(k, 0, mods)
73}
74
75func typed(r rune) *tcell.EventKey {
76 return tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)
77}
78
79func TestViewDrawsTheTextWithLineNumbers(t *testing.T) {
80 view, screen, p := newTestView(t, "package main\n\nfunc main() {}\n", 40, 8)
81
82 lines := draw(t, view, screen, p)
83
84 if !strings.HasPrefix(lines[0], "1 package main") {
85 t.Errorf("row 0 = %q, want the line number then the text", lines[0])
86 }
87 if !strings.HasPrefix(lines[2], "3 func main() {}") {
88 t.Errorf("row 2 = %q", lines[2])
89 }
90}
91
92func TestLineNumbersCanBeTurnedOff(t *testing.T) {
93 view, screen, p := newTestView(t, "package main\n", 40, 6)
94 view.SetLineNumbers(false)
95
96 lines := draw(t, view, screen, p)
97
98 if !strings.HasPrefix(lines[0], "package main") {
99 t.Errorf("row 0 = %q, want no gutter", lines[0])
100 }
101 if view.gutterWidth() != 0 {
102 t.Errorf("gutterWidth() = %d, want 0", view.gutterWidth())
103 }
104}
105
106func TestTheGutterGrowsWithTheLineCount(t *testing.T) {
107 view, _, _ := newTestView(t, strings.Repeat("x\n", 150), 40, 8)
108
109 // 151 lines needs three digits plus the separating space.
110 if got := view.gutterWidth(); got != 4 {
111 t.Errorf("gutterWidth() = %d, want 4", got)
112 }
113}
114
115func TestKeywordsAreColouredDifferentlyFromIdentifiers(t *testing.T) {
116 view, screen, p := newTestView(t, "while running", 40, 6)
117 th := testTheme(t)
118
119 view.Draw(p.Sub(view.Bounds()), th)
120 screen.Show()
121
122 cells, width, _ := screen.GetContents()
123 gutter := view.gutterWidth()
124 keyword := cells[gutter].Style // the "w" of while
125 identifier := cells[gutter+6].Style // the "r" of running
126
127 if keyword == identifier {
128 t.Error("a keyword and an identifier were drawn in the same style")
129 }
130 _ = width
131}
132
133func TestAFileNoLanguageClaimsIsNotColoured(t *testing.T) {
134 buf := buffer.NewFromString("hello")
135 buf.SetPath("notes.txt")
136 view := NewView(buf, &Clipboard{})
137
138 if view.highlight.Enabled() {
139 t.Error("colouring is on for a file no registered language claims")
140 }
141}
142
143func TestRefreshSyntaxFollowsARename(t *testing.T) {
144 buf := buffer.NewFromString("# Title")
145 buf.SetPath("notes.txt")
146 view := NewView(buf, &Clipboard{})
147
148 buf.SetPath("notes.md")
149 view.RefreshSyntax()
150
151 if !view.highlight.Enabled() {
152 t.Error("colouring stayed off after the buffer became a Markdown file")
153 }
154}
155
156func TestTabsAreExpandedOnScreen(t *testing.T) {
157 view, screen, p := newTestView(t, "\tx", 40, 4)
158 view.SetLineNumbers(false)
159 view.Buffer().SetTabWidth(4)
160
161 lines := draw(t, view, screen, p)
162
163 if !strings.HasPrefix(lines[0], " x") {
164 t.Errorf("row 0 = %q, want the tab expanded to four columns", lines[0])
165 }
166}
167
168func TestScrollingKeepsTheCursorInView(t *testing.T) {
169 view, _, _ := newTestView(t, strings.Repeat("line\n", 100), 40, 10)
170
171 view.Buffer().SetCursor(buffer.Position{Line: 50})
172 view.EnsureCursorVisible()
173
174 top := view.TopLine()
175 if 50 < top || 50 >= top+view.VisibleLines() {
176 t.Errorf("the cursor at line 50 is outside the view starting at %d", top)
177 }
178}
179
180func TestScrollToClampsToTheBuffer(t *testing.T) {
181 view, _, _ := newTestView(t, "a\nb\nc", 40, 10)
182
183 view.ScrollTo(999)
184 if got := view.TopLine(); got != 2 {
185 t.Errorf("TopLine() = %d, want the last line", got)
186 }
187
188 view.ScrollTo(-5)
189 if got := view.TopLine(); got != 0 {
190 t.Errorf("TopLine() = %d, want 0", got)
191 }
192}
193
194func TestHorizontalScrollFollowsALongLine(t *testing.T) {
195 view, screen, p := newTestView(t, strings.Repeat("x", 200), 30, 5)
196 view.SetLineNumbers(false)
197
198 view.Buffer().MoveLineEnd()
199 lines := draw(t, view, screen, p)
200
201 if view.left == 0 {
202 t.Error("the view did not scroll sideways to follow the cursor")
203 }
204 if strings.TrimSpace(lines[0]) == "" {
205 t.Error("nothing was drawn after the horizontal scroll")
206 }
207}
208
209func TestCursorStatusCountsFromOne(t *testing.T) {
210 view, _, _ := newTestView(t, "abc\ndef", 40, 6)
211
212 view.Buffer().SetCursor(buffer.Position{Line: 1, Col: 2})
213
214 if got := view.CursorStatus(); got != "2:3" {
215 t.Errorf("CursorStatus() = %q, want %q", got, "2:3")
216 }
217}
218
219func TestScrollBarsAreDrawnAtTheEdges(t *testing.T) {
220 view, screen, p := newTestView(t, strings.Repeat("line\n", 50), 30, 8)
221
222 lines := draw(t, view, screen, p)
223
224 if !strings.Contains(lines[0], "▲") {
225 t.Errorf("row 0 = %q, want the vertical scroll bar's up arrow", lines[0])
226 }
227 if !strings.Contains(lines[7], "◄") {
228 t.Errorf("the bottom row is %q, want the horizontal scroll bar", lines[7])
229 }
230}
231
232func TestTheCursorIsPlacedOnScreen(t *testing.T) {
233 view, screen, p := newTestView(t, "package main", 40, 6)
234 view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 8})
235
236 draw(t, view, screen, p)
237
238 x, y, visible := screen.GetCursor()
239 if !visible {
240 t.Fatal("the terminal cursor was not placed")
241 }
242 if x != view.gutterWidth()+8 || y != 0 {
243 t.Errorf("the cursor is at (%d, %d), want (%d, 0)", x, y, view.gutterWidth()+8)
244 }
245}
246
247func TestASelectionIsDrawnInItsOwnStyle(t *testing.T) {
248 view, screen, p := newTestView(t, "hello world", 40, 5)
249 view.SetLineNumbers(false)
250 th := testTheme(t)
251
252 view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 0})
253 view.Buffer().StartSelection()
254 view.Buffer().SetCursorKeepingSelection(buffer.Position{Line: 0, Col: 5})
255
256 view.Draw(p.Sub(view.Bounds()), th)
257 screen.Show()
258
259 cells, width, _ := screen.GetContents()
260 selected := cells[0].Style
261 unselected := cells[6].Style
262 if selected == unselected {
263 t.Error("the selected text was drawn in the same style as the rest")
264 }
265 if selected != th.Style(theme.KeyEditorSelection) {
266 t.Error("the selection is not drawn in the theme's selection style")
267 }
268 _ = width
269}
270
271func TestTheCursorCellIsPaintedInTheThemesCursorStyle(t *testing.T) {
272 // The terminal draws its own cursor in the *user's* colour, not the
273 // theme's, and a thin bar in the wrong colour is invisible on a dark
274 // background. So the editor paints the cell itself as well.
275 view, screen, p := newTestView(t, "package main", 40, 6)
276 th := testTheme(t)
277 view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 4})
278
279 view.Draw(p.Sub(view.Bounds()), th)
280 screen.Show()
281
282 x, _ := view.cursorCell()
283 cells, width, _ := screen.GetContents()
284 got := cells[x].Style
285
286 if got != th.Style(theme.KeyEditorCursor) {
287 t.Error("the cell under the cursor is not drawn in the theme's cursor style")
288 }
289 if got == th.Style(theme.KeyEditorCurrent) {
290 t.Error("the cursor cell is indistinguishable from the rest of its line")
291 }
292 if r := cells[x].Runes; len(r) == 0 || r[0] != 'a' {
293 t.Errorf("the character under the cursor is %q, want it kept", r)
294 }
295 _ = width
296}
297
298// channelDistance returns how far apart two colours are, as the largest
299// difference in any one channel.
300//
301// Colours a handful of values apart are the same colour as far as an eye
302// looking at a terminal is concerned, which is the failure this measures.
303func channelDistance(a, b tcell.Color) int32 {
304 ar, ag, ab := a.RGB()
305 br, bg, bb := b.RGB()
306
307 distance := int32(0)
308 for _, pair := range [][2]int32{{ar, br}, {ag, bg}, {ab, bb}} {
309 if difference := max(pair[0]-pair[1], pair[1]-pair[0]); difference > distance {
310 distance = difference
311 }
312 }
313 return distance
314}
315
316// The two thresholds below are in 0-255 channel values. The line highlight is
317// meant to be quiet, so it only has to be noticeable; the cursor has to be
318// findable at a glance, so it has to be obvious.
319const (
320 noticeable = 16
321 obvious = 64
322
323 // readable is the floor for anything a person has to read: a quarter of
324 // the range between two colours, in the channel they differ in most. It is
325 // the same number as obvious for a different reason, and it is a floor
326 // rather than a target — the dimmest reading colour any shipped theme uses
327 // is 80, so this catches a regression rather than the present.
328 readable = 64
329)
330
331// contrastRatio is the WCAG relative-luminance ratio between two colours, from
332// 1 (identical) to 21 (black on white).
333//
334// It exists beside channelDistance because the two measure different things and
335// this package needs both. channelDistance answers "can the eye see that these
336// are two colours"; it is the right question for a cursor, and the wrong one
337// for prose. Turbo Classic drew comments in #808080 on #000080: 128 apart in
338// the blue channel, which sails past every threshold here, and 4.05:1 to read,
339// which is below the 4.5 the W3C sets for body text. The comments were hard to
340// read and nothing in this file objected.
341func contrastRatio(a, b tcell.Color) float64 {
342 lighter, darker := relativeLuminance(a), relativeLuminance(b)
343 if lighter < darker {
344 lighter, darker = darker, lighter
345 }
346 return (lighter + 0.05) / (darker + 0.05)
347}
348
349// relativeLuminance is WCAG 2's L: each channel linearised, then weighted by
350// how much the eye owes it.
351func relativeLuminance(c tcell.Color) float64 {
352 r, g, b := c.RGB()
353 return 0.2126*linearise(r) + 0.7152*linearise(g) + 0.0722*linearise(b)
354}
355
356// linearise undoes the sRGB transfer curve for one 0-255 channel.
357func linearise(value int32) float64 {
358 channel := float64(value) / 255
359 if channel <= 0.03928 {
360 return channel / 12.92
361 }
362 return math.Pow((channel+0.055)/1.055, 2.4)
363}
364
365// readableAsProse is the W3C's AA threshold for body text. It is applied to
366// comments and to nothing else in this file, deliberately: a comment is prose,
367// read word by word, and is the one class every theme is tempted to dim until
368// it disappears. Punctuation is quieter still in several themes and stays that
369// way — it is recognised by shape, not read.
370const readableAsProse = 4.5
371
372// palettesWeDoNotOwn are themes whose colours are somebody else's published
373// work, faithfully copied.
374//
375// Catppuccin assigns comments its "overlay0" against "base", which is 2.87:1 in
376// Frappé and 2.83:1 in Latte. Both are below the threshold and both are
377// correct: a theme called Catppuccin that is not those exact values is a
378// different theme wearing a borrowed name. The fix, if anyone wants one, is
379// upstream — it is not ours to make here.
380var palettesWeDoNotOwn = map[string]bool{
381 "catppuccin-frappe": true,
382 "catppuccin-latte": true,
383}
384
385func TestEveryThemeWeAuthorKeepsItsCommentsReadable(t *testing.T) {
386 // Comments were the dimmest reading colour in six of the eight themes, and
387 // under 4.5:1 in every one of them. This is the test that says so.
388 for _, name := range theme.Available("") {
389 if palettesWeDoNotOwn[name] {
390 continue
391 }
392 t.Run(name, func(t *testing.T) {
393 th, err := theme.Load(name, "")
394 if err != nil {
395 t.Fatalf("Load() error = %v", err)
396 }
397
398 fg, bg, _ := th.Style(theme.KeySyntaxComment).Decompose()
399 if got := contrastRatio(fg, bg); got < readableAsProse {
400 t.Errorf("comments read at %.2f:1, want at least %.1f — %v on %v", got, readableAsProse, fg, bg)
401 }
402 })
403 }
404}
405
406func TestTheThemesWeDoNotOwnAreStillNamedThemesWeShip(t *testing.T) {
407 // An exemption list that names a theme nobody ships is an exemption that
408 // has stopped meaning anything, and would silently excuse the next theme to
409 // take that name.
410 shipped := map[string]bool{}
411 for _, name := range theme.Available("") {
412 shipped[name] = true
413 }
414 for name := range palettesWeDoNotOwn {
415 if !shipped[name] {
416 t.Errorf("%q is exempt from the comment-contrast rule but is not shipped", name)
417 }
418 }
419}
420
421func TestEveryThemeMakesTheCursorObviousAgainstItsLine(t *testing.T) {
422 // A cursor colour that merely reverses the line would be inverted straight
423 // back into invisibility by terminals that draw their cursor by inverting
424 // the cell. Each theme must use a distinct pair, and a loud one.
425 for _, name := range theme.Available("") {
426 t.Run(name, func(t *testing.T) {
427 th, err := theme.Load(name, "")
428 if err != nil {
429 t.Fatalf("Load() error = %v", err)
430 }
431
432 cursorFg, cursorBg, _ := th.Style(theme.KeyEditorCursor).Decompose()
433 lineFg, lineBg, _ := th.Style(theme.KeyEditorCurrent).Decompose()
434
435 if got := channelDistance(cursorBg, lineBg); got < obvious {
436 t.Errorf("the cursor is %d from its line, want at least %d — it has to be findable", got, obvious)
437 }
438 if cursorBg == lineFg && cursorFg == lineBg {
439 t.Error("the cursor style is a plain reversal of the line, which a reversing terminal undoes")
440 }
441 })
442 }
443}
444
445func TestEveryThemeMakesTheCurrentLineNoticeable(t *testing.T) {
446 // turbo-dark once highlighted the cursor's line with #262626 against a
447 // #1c1c1c background: ten values in each channel, which is no highlight at
448 // all. This is the test that stops that coming back.
449 for _, name := range theme.Available("") {
450 t.Run(name, func(t *testing.T) {
451 th, err := theme.Load(name, "")
452 if err != nil {
453 t.Fatalf("Load() error = %v", err)
454 }
455
456 _, lineBg, _ := th.Style(theme.KeyEditorCurrent).Decompose()
457 _, textBg, _ := th.Style(theme.KeyEditorText).Decompose()
458
459 if got := channelDistance(lineBg, textBg); got < noticeable {
460 t.Errorf("the current line is %d from the page, want at least %d", got, noticeable)
461 }
462 })
463 }
464}
465
466// readKeys are the styles whose text a person has to read.
467//
468// The furniture is deliberately left out: the desktop backdrop, the shadow,
469// the scrollbar trough, an inactive frame or title, a disabled menu entry, the
470// line-number gutter, a dialog or completion frame. Those exist to recede, and
471// every theme in the repository — including the two that shipped first — puts
472// them between 20 and 70, which is correct rather than a defect.
473var readKeys = []string{
474 theme.KeyDefault, theme.KeyWindowBody,
475 theme.KeyEditorText, theme.KeyEditorCurrent, theme.KeyEditorSelection,
476 theme.KeyTerminalText,
477 theme.KeyTreeText, theme.KeyTreeDirectory, theme.KeyTreeSelected, theme.KeyTreeUnfocused,
478 theme.KeyMenuBar, theme.KeyMenuItem, theme.KeyMenuSelected, theme.KeyMenuShortcut,
479 theme.KeyStatusBar, theme.KeyStatusBarKey, theme.KeyStatusBarHint,
480 theme.KeyDialogBody, theme.KeyDialogTitle, theme.KeyDialogLabel,
481 theme.KeyButton, theme.KeyButtonFocused, theme.KeyButtonShortcut,
482 theme.KeyInput, theme.KeyInputFocused, theme.KeyInputSelection,
483 theme.KeyList, theme.KeyListSelected, theme.KeyListUnfocused,
484 theme.KeyCheckbox, theme.KeyCheckboxFocused,
485 theme.KeyCompletionItem, theme.KeyCompletionSelected, theme.KeyCompletionDetail,
486 theme.KeyDiagnosticError, theme.KeyDiagnosticWarning, theme.KeyDiagnosticInfo,
487 theme.KeySyntaxIdentifier, theme.KeySyntaxKeyword, theme.KeySyntaxType,
488 theme.KeySyntaxBuiltin, theme.KeySyntaxConstant, theme.KeySyntaxFunction,
489 theme.KeySyntaxString, theme.KeySyntaxChar, theme.KeySyntaxNumber,
490 theme.KeySyntaxComment, theme.KeySyntaxOperator, theme.KeySyntaxPunctuation,
491 theme.KeySyntaxHeading, theme.KeySyntaxTag, theme.KeySyntaxAttribute,
492 theme.KeySyntaxEmphasis, theme.KeySyntaxLink,
493}
494
495func TestEveryThemeKeepsItsTextReadable(t *testing.T) {
496 // A theme inherits what it does not set, so a key left out of a dark theme
497 // shows a colour Turbo Classic chose for its blue background. That is how
498 // an unreadable pairing gets in without anyone writing it down.
499 for _, name := range theme.Available("") {
500 t.Run(name, func(t *testing.T) {
501 th, err := theme.Load(name, "")
502 if err != nil {
503 t.Fatalf("Load() error = %v", err)
504 }
505
506 for _, key := range readKeys {
507 fg, bg, _ := th.Style(key).Decompose()
508 if got := channelDistance(fg, bg); got < readable {
509 t.Errorf("%s: text is %d from its background, want at least %d", key, got, readable)
510 }
511 }
512 })
513 }
514}
515
516// tellApart are groups of syntax classes a reader meets side by side, and so
517// has to be able to tell apart. Classes deliberately painted alike are not
518// grouped together: string and char are one idea, as are constant and number,
519// and builtin is type with weight added.
520var tellApart = map[string][]string{
521 "code": {
522 theme.KeySyntaxKeyword, theme.KeySyntaxType, theme.KeySyntaxConstant,
523 theme.KeySyntaxFunction, theme.KeySyntaxString, theme.KeySyntaxComment,
524 theme.KeySyntaxIdentifier,
525 },
526 "markup": {
527 theme.KeySyntaxHeading, theme.KeySyntaxEmphasis, theme.KeySyntaxLink,
528 theme.KeySyntaxString, theme.KeySyntaxComment, theme.KeySyntaxAttribute,
529 },
530}
531
532func TestEveryThemeTellsAdjacentSyntaxClassesApart(t *testing.T) {
533 // turbo-classic once painted syntax.link the same lime as syntax.string,
534 // so a Markdown link and an inline code span were the same thing on
535 // screen. Nothing caught it — the theme was complete, every colour was
536 // readable, and the two were simply equal.
537 for _, name := range theme.Available("") {
538 t.Run(name, func(t *testing.T) {
539 th, err := theme.Load(name, "")
540 if err != nil {
541 t.Fatalf("Load() error = %v", err)
542 }
543
544 for group, keys := range tellApart {
545 seen := map[string]string{}
546 for _, key := range keys {
547 fg, _, attrs := th.Style(key).Decompose()
548 look := fmt.Sprintf("%v/%v", fg, attrs)
549 if other, clash := seen[look]; clash {
550 t.Errorf("in %s, %q and %q are drawn identically", group, other, key)
551 }
552 seen[look] = key
553 }
554 }
555 })
556 }
557}