turbo-editors/turbo-corepublic Fork 0
v1.0.0
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.

📦 Turbo Core f3ade8d · on v1.0.0 · k33g · 11h ago
view_test.go · 557 lines · 18.2 KBGo Blame HistoryRaw
  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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
package editor

import (
	"fmt"
	"math"
	"strings"
	"testing"

	"github.com/gdamore/tcell/v2"

	"rickub.com/turbo-editors/turbo-core/buffer"
	"rickub.com/turbo-editors/turbo-core/theme"
	"rickub.com/turbo-editors/turbo-core/ui"
)

// newTestView returns a view onto text, sized to bounds, with a screen behind
// it so that Draw can be exercised for real.
func newTestView(t *testing.T, text string, width, height int) (*View, tcell.SimulationScreen, *ui.Painter) {
	t.Helper()

	screen := tcell.NewSimulationScreen("UTF-8")
	if err := screen.Init(); err != nil {
		t.Fatalf("initialising the simulation screen: %v", err)
	}
	t.Cleanup(screen.Fini)
	screen.SetSize(width, height)

	buf := buffer.NewFromString(text)
	buf.SetPath("build.sh")
	view := NewView(buf, &Clipboard{})
	view.SetBounds(ui.Rect{X: 0, Y: 0, W: width, H: height})

	return view, screen, ui.NewPainter(screen)
}

// screenLines returns what the simulated screen shows, one string per row.
func screenLines(t *testing.T, screen tcell.SimulationScreen) []string {
	t.Helper()

	cells, width, height := screen.GetContents()
	lines := make([]string, height)
	for y := range height {
		var row strings.Builder
		for x := range width {
			runes := cells[y*width+x].Runes
			if len(runes) == 0 {
				row.WriteRune(' ')
				continue
			}
			row.WriteRune(runes[0])
		}
		lines[y] = row.String()
	}
	return lines
}

// testTheme returns a theme good enough to draw with.
func testTheme(t *testing.T) *theme.Theme {
	t.Helper()
	return theme.Default("")
}

// draw paints the view and returns what the screen shows.
func draw(t *testing.T, view *View, screen tcell.SimulationScreen, p *ui.Painter) []string {
	t.Helper()
	view.Draw(p.Sub(view.Bounds()), testTheme(t))
	screen.Show()
	return screenLines(t, screen)
}

func key(k tcell.Key, mods tcell.ModMask) *tcell.EventKey {
	return tcell.NewEventKey(k, 0, mods)
}

func typed(r rune) *tcell.EventKey {
	return tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)
}

func TestViewDrawsTheTextWithLineNumbers(t *testing.T) {
	view, screen, p := newTestView(t, "package main\n\nfunc main() {}\n", 40, 8)

	lines := draw(t, view, screen, p)

	if !strings.HasPrefix(lines[0], "1 package main") {
		t.Errorf("row 0 = %q, want the line number then the text", lines[0])
	}
	if !strings.HasPrefix(lines[2], "3 func main() {}") {
		t.Errorf("row 2 = %q", lines[2])
	}
}

func TestLineNumbersCanBeTurnedOff(t *testing.T) {
	view, screen, p := newTestView(t, "package main\n", 40, 6)
	view.SetLineNumbers(false)

	lines := draw(t, view, screen, p)

	if !strings.HasPrefix(lines[0], "package main") {
		t.Errorf("row 0 = %q, want no gutter", lines[0])
	}
	if view.gutterWidth() != 0 {
		t.Errorf("gutterWidth() = %d, want 0", view.gutterWidth())
	}
}

func TestTheGutterGrowsWithTheLineCount(t *testing.T) {
	view, _, _ := newTestView(t, strings.Repeat("x\n", 150), 40, 8)

	// 151 lines needs three digits plus the separating space.
	if got := view.gutterWidth(); got != 4 {
		t.Errorf("gutterWidth() = %d, want 4", got)
	}
}

func TestKeywordsAreColouredDifferentlyFromIdentifiers(t *testing.T) {
	view, screen, p := newTestView(t, "while running", 40, 6)
	th := testTheme(t)

	view.Draw(p.Sub(view.Bounds()), th)
	screen.Show()

	cells, width, _ := screen.GetContents()
	gutter := view.gutterWidth()
	keyword := cells[gutter].Style      // the "w" of while
	identifier := cells[gutter+6].Style // the "r" of running

	if keyword == identifier {
		t.Error("a keyword and an identifier were drawn in the same style")
	}
	_ = width
}

func TestAFileNoLanguageClaimsIsNotColoured(t *testing.T) {
	buf := buffer.NewFromString("hello")
	buf.SetPath("notes.txt")
	view := NewView(buf, &Clipboard{})

	if view.highlight.Enabled() {
		t.Error("colouring is on for a file no registered language claims")
	}
}

func TestRefreshSyntaxFollowsARename(t *testing.T) {
	buf := buffer.NewFromString("# Title")
	buf.SetPath("notes.txt")
	view := NewView(buf, &Clipboard{})

	buf.SetPath("notes.md")
	view.RefreshSyntax()

	if !view.highlight.Enabled() {
		t.Error("colouring stayed off after the buffer became a Markdown file")
	}
}

func TestTabsAreExpandedOnScreen(t *testing.T) {
	view, screen, p := newTestView(t, "\tx", 40, 4)
	view.SetLineNumbers(false)
	view.Buffer().SetTabWidth(4)

	lines := draw(t, view, screen, p)

	if !strings.HasPrefix(lines[0], "    x") {
		t.Errorf("row 0 = %q, want the tab expanded to four columns", lines[0])
	}
}

func TestScrollingKeepsTheCursorInView(t *testing.T) {
	view, _, _ := newTestView(t, strings.Repeat("line\n", 100), 40, 10)

	view.Buffer().SetCursor(buffer.Position{Line: 50})
	view.EnsureCursorVisible()

	top := view.TopLine()
	if 50 < top || 50 >= top+view.VisibleLines() {
		t.Errorf("the cursor at line 50 is outside the view starting at %d", top)
	}
}

func TestScrollToClampsToTheBuffer(t *testing.T) {
	view, _, _ := newTestView(t, "a\nb\nc", 40, 10)

	view.ScrollTo(999)
	if got := view.TopLine(); got != 2 {
		t.Errorf("TopLine() = %d, want the last line", got)
	}

	view.ScrollTo(-5)
	if got := view.TopLine(); got != 0 {
		t.Errorf("TopLine() = %d, want 0", got)
	}
}

func TestHorizontalScrollFollowsALongLine(t *testing.T) {
	view, screen, p := newTestView(t, strings.Repeat("x", 200), 30, 5)
	view.SetLineNumbers(false)

	view.Buffer().MoveLineEnd()
	lines := draw(t, view, screen, p)

	if view.left == 0 {
		t.Error("the view did not scroll sideways to follow the cursor")
	}
	if strings.TrimSpace(lines[0]) == "" {
		t.Error("nothing was drawn after the horizontal scroll")
	}
}

func TestCursorStatusCountsFromOne(t *testing.T) {
	view, _, _ := newTestView(t, "abc\ndef", 40, 6)

	view.Buffer().SetCursor(buffer.Position{Line: 1, Col: 2})

	if got := view.CursorStatus(); got != "2:3" {
		t.Errorf("CursorStatus() = %q, want %q", got, "2:3")
	}
}

func TestScrollBarsAreDrawnAtTheEdges(t *testing.T) {
	view, screen, p := newTestView(t, strings.Repeat("line\n", 50), 30, 8)

	lines := draw(t, view, screen, p)

	if !strings.Contains(lines[0], "▲") {
		t.Errorf("row 0 = %q, want the vertical scroll bar's up arrow", lines[0])
	}
	if !strings.Contains(lines[7], "◄") {
		t.Errorf("the bottom row is %q, want the horizontal scroll bar", lines[7])
	}
}

func TestTheCursorIsPlacedOnScreen(t *testing.T) {
	view, screen, p := newTestView(t, "package main", 40, 6)
	view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 8})

	draw(t, view, screen, p)

	x, y, visible := screen.GetCursor()
	if !visible {
		t.Fatal("the terminal cursor was not placed")
	}
	if x != view.gutterWidth()+8 || y != 0 {
		t.Errorf("the cursor is at (%d, %d), want (%d, 0)", x, y, view.gutterWidth()+8)
	}
}

func TestASelectionIsDrawnInItsOwnStyle(t *testing.T) {
	view, screen, p := newTestView(t, "hello world", 40, 5)
	view.SetLineNumbers(false)
	th := testTheme(t)

	view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 0})
	view.Buffer().StartSelection()
	view.Buffer().SetCursorKeepingSelection(buffer.Position{Line: 0, Col: 5})

	view.Draw(p.Sub(view.Bounds()), th)
	screen.Show()

	cells, width, _ := screen.GetContents()
	selected := cells[0].Style
	unselected := cells[6].Style
	if selected == unselected {
		t.Error("the selected text was drawn in the same style as the rest")
	}
	if selected != th.Style(theme.KeyEditorSelection) {
		t.Error("the selection is not drawn in the theme's selection style")
	}
	_ = width
}

func TestTheCursorCellIsPaintedInTheThemesCursorStyle(t *testing.T) {
	// The terminal draws its own cursor in the *user's* colour, not the
	// theme's, and a thin bar in the wrong colour is invisible on a dark
	// background. So the editor paints the cell itself as well.
	view, screen, p := newTestView(t, "package main", 40, 6)
	th := testTheme(t)
	view.Buffer().SetCursor(buffer.Position{Line: 0, Col: 4})

	view.Draw(p.Sub(view.Bounds()), th)
	screen.Show()

	x, _ := view.cursorCell()
	cells, width, _ := screen.GetContents()
	got := cells[x].Style

	if got != th.Style(theme.KeyEditorCursor) {
		t.Error("the cell under the cursor is not drawn in the theme's cursor style")
	}
	if got == th.Style(theme.KeyEditorCurrent) {
		t.Error("the cursor cell is indistinguishable from the rest of its line")
	}
	if r := cells[x].Runes; len(r) == 0 || r[0] != 'a' {
		t.Errorf("the character under the cursor is %q, want it kept", r)
	}
	_ = width
}

// channelDistance returns how far apart two colours are, as the largest
// difference in any one channel.
//
// Colours a handful of values apart are the same colour as far as an eye
// looking at a terminal is concerned, which is the failure this measures.
func channelDistance(a, b tcell.Color) int32 {
	ar, ag, ab := a.RGB()
	br, bg, bb := b.RGB()

	distance := int32(0)
	for _, pair := range [][2]int32{{ar, br}, {ag, bg}, {ab, bb}} {
		if difference := max(pair[0]-pair[1], pair[1]-pair[0]); difference > distance {
			distance = difference
		}
	}
	return distance
}

// The two thresholds below are in 0-255 channel values. The line highlight is
// meant to be quiet, so it only has to be noticeable; the cursor has to be
// findable at a glance, so it has to be obvious.
const (
	noticeable = 16
	obvious    = 64

	// readable is the floor for anything a person has to read: a quarter of
	// the range between two colours, in the channel they differ in most. It is
	// the same number as obvious for a different reason, and it is a floor
	// rather than a target — the dimmest reading colour any shipped theme uses
	// is 80, so this catches a regression rather than the present.
	readable = 64
)

// contrastRatio is the WCAG relative-luminance ratio between two colours, from
// 1 (identical) to 21 (black on white).
//
// It exists beside channelDistance because the two measure different things and
// this package needs both. channelDistance answers "can the eye see that these
// are two colours"; it is the right question for a cursor, and the wrong one
// for prose. Turbo Classic drew comments in #808080 on #000080: 128 apart in
// the blue channel, which sails past every threshold here, and 4.05:1 to read,
// which is below the 4.5 the W3C sets for body text. The comments were hard to
// read and nothing in this file objected.
func contrastRatio(a, b tcell.Color) float64 {
	lighter, darker := relativeLuminance(a), relativeLuminance(b)
	if lighter < darker {
		lighter, darker = darker, lighter
	}
	return (lighter + 0.05) / (darker + 0.05)
}

// relativeLuminance is WCAG 2's L: each channel linearised, then weighted by
// how much the eye owes it.
func relativeLuminance(c tcell.Color) float64 {
	r, g, b := c.RGB()
	return 0.2126*linearise(r) + 0.7152*linearise(g) + 0.0722*linearise(b)
}

// linearise undoes the sRGB transfer curve for one 0-255 channel.
func linearise(value int32) float64 {
	channel := float64(value) / 255
	if channel <= 0.03928 {
		return channel / 12.92
	}
	return math.Pow((channel+0.055)/1.055, 2.4)
}

// readableAsProse is the W3C's AA threshold for body text. It is applied to
// comments and to nothing else in this file, deliberately: a comment is prose,
// read word by word, and is the one class every theme is tempted to dim until
// it disappears. Punctuation is quieter still in several themes and stays that
// way — it is recognised by shape, not read.
const readableAsProse = 4.5

// palettesWeDoNotOwn are themes whose colours are somebody else's published
// work, faithfully copied.
//
// Catppuccin assigns comments its "overlay0" against "base", which is 2.87:1 in
// Frappé and 2.83:1 in Latte. Both are below the threshold and both are
// correct: a theme called Catppuccin that is not those exact values is a
// different theme wearing a borrowed name. The fix, if anyone wants one, is
// upstream — it is not ours to make here.
var palettesWeDoNotOwn = map[string]bool{
	"catppuccin-frappe": true,
	"catppuccin-latte":  true,
}

func TestEveryThemeWeAuthorKeepsItsCommentsReadable(t *testing.T) {
	// Comments were the dimmest reading colour in six of the eight themes, and
	// under 4.5:1 in every one of them. This is the test that says so.
	for _, name := range theme.Available("") {
		if palettesWeDoNotOwn[name] {
			continue
		}
		t.Run(name, func(t *testing.T) {
			th, err := theme.Load(name, "")
			if err != nil {
				t.Fatalf("Load() error = %v", err)
			}

			fg, bg, _ := th.Style(theme.KeySyntaxComment).Decompose()
			if got := contrastRatio(fg, bg); got < readableAsProse {
				t.Errorf("comments read at %.2f:1, want at least %.1f — %v on %v", got, readableAsProse, fg, bg)
			}
		})
	}
}

func TestTheThemesWeDoNotOwnAreStillNamedThemesWeShip(t *testing.T) {
	// An exemption list that names a theme nobody ships is an exemption that
	// has stopped meaning anything, and would silently excuse the next theme to
	// take that name.
	shipped := map[string]bool{}
	for _, name := range theme.Available("") {
		shipped[name] = true
	}
	for name := range palettesWeDoNotOwn {
		if !shipped[name] {
			t.Errorf("%q is exempt from the comment-contrast rule but is not shipped", name)
		}
	}
}

func TestEveryThemeMakesTheCursorObviousAgainstItsLine(t *testing.T) {
	// A cursor colour that merely reverses the line would be inverted straight
	// back into invisibility by terminals that draw their cursor by inverting
	// the cell. Each theme must use a distinct pair, and a loud one.
	for _, name := range theme.Available("") {
		t.Run(name, func(t *testing.T) {
			th, err := theme.Load(name, "")
			if err != nil {
				t.Fatalf("Load() error = %v", err)
			}

			cursorFg, cursorBg, _ := th.Style(theme.KeyEditorCursor).Decompose()
			lineFg, lineBg, _ := th.Style(theme.KeyEditorCurrent).Decompose()

			if got := channelDistance(cursorBg, lineBg); got < obvious {
				t.Errorf("the cursor is %d from its line, want at least %d — it has to be findable", got, obvious)
			}
			if cursorBg == lineFg && cursorFg == lineBg {
				t.Error("the cursor style is a plain reversal of the line, which a reversing terminal undoes")
			}
		})
	}
}

func TestEveryThemeMakesTheCurrentLineNoticeable(t *testing.T) {
	// turbo-dark once highlighted the cursor's line with #262626 against a
	// #1c1c1c background: ten values in each channel, which is no highlight at
	// all. This is the test that stops that coming back.
	for _, name := range theme.Available("") {
		t.Run(name, func(t *testing.T) {
			th, err := theme.Load(name, "")
			if err != nil {
				t.Fatalf("Load() error = %v", err)
			}

			_, lineBg, _ := th.Style(theme.KeyEditorCurrent).Decompose()
			_, textBg, _ := th.Style(theme.KeyEditorText).Decompose()

			if got := channelDistance(lineBg, textBg); got < noticeable {
				t.Errorf("the current line is %d from the page, want at least %d", got, noticeable)
			}
		})
	}
}

// readKeys are the styles whose text a person has to read.
//
// The furniture is deliberately left out: the desktop backdrop, the shadow,
// the scrollbar trough, an inactive frame or title, a disabled menu entry, the
// line-number gutter, a dialog or completion frame. Those exist to recede, and
// every theme in the repository — including the two that shipped first — puts
// them between 20 and 70, which is correct rather than a defect.
var readKeys = []string{
	theme.KeyDefault, theme.KeyWindowBody,
	theme.KeyEditorText, theme.KeyEditorCurrent, theme.KeyEditorSelection,
	theme.KeyTerminalText,
	theme.KeyTreeText, theme.KeyTreeDirectory, theme.KeyTreeSelected, theme.KeyTreeUnfocused,
	theme.KeyMenuBar, theme.KeyMenuItem, theme.KeyMenuSelected, theme.KeyMenuShortcut,
	theme.KeyStatusBar, theme.KeyStatusBarKey, theme.KeyStatusBarHint,
	theme.KeyDialogBody, theme.KeyDialogTitle, theme.KeyDialogLabel,
	theme.KeyButton, theme.KeyButtonFocused, theme.KeyButtonShortcut,
	theme.KeyInput, theme.KeyInputFocused, theme.KeyInputSelection,
	theme.KeyList, theme.KeyListSelected, theme.KeyListUnfocused,
	theme.KeyCheckbox, theme.KeyCheckboxFocused,
	theme.KeyCompletionItem, theme.KeyCompletionSelected, theme.KeyCompletionDetail,
	theme.KeyDiagnosticError, theme.KeyDiagnosticWarning, theme.KeyDiagnosticInfo,
	theme.KeySyntaxIdentifier, theme.KeySyntaxKeyword, theme.KeySyntaxType,
	theme.KeySyntaxBuiltin, theme.KeySyntaxConstant, theme.KeySyntaxFunction,
	theme.KeySyntaxString, theme.KeySyntaxChar, theme.KeySyntaxNumber,
	theme.KeySyntaxComment, theme.KeySyntaxOperator, theme.KeySyntaxPunctuation,
	theme.KeySyntaxHeading, theme.KeySyntaxTag, theme.KeySyntaxAttribute,
	theme.KeySyntaxEmphasis, theme.KeySyntaxLink,
}

func TestEveryThemeKeepsItsTextReadable(t *testing.T) {
	// A theme inherits what it does not set, so a key left out of a dark theme
	// shows a colour Turbo Classic chose for its blue background. That is how
	// an unreadable pairing gets in without anyone writing it down.
	for _, name := range theme.Available("") {
		t.Run(name, func(t *testing.T) {
			th, err := theme.Load(name, "")
			if err != nil {
				t.Fatalf("Load() error = %v", err)
			}

			for _, key := range readKeys {
				fg, bg, _ := th.Style(key).Decompose()
				if got := channelDistance(fg, bg); got < readable {
					t.Errorf("%s: text is %d from its background, want at least %d", key, got, readable)
				}
			}
		})
	}
}

// tellApart are groups of syntax classes a reader meets side by side, and so
// has to be able to tell apart. Classes deliberately painted alike are not
// grouped together: string and char are one idea, as are constant and number,
// and builtin is type with weight added.
var tellApart = map[string][]string{
	"code": {
		theme.KeySyntaxKeyword, theme.KeySyntaxType, theme.KeySyntaxConstant,
		theme.KeySyntaxFunction, theme.KeySyntaxString, theme.KeySyntaxComment,
		theme.KeySyntaxIdentifier,
	},
	"markup": {
		theme.KeySyntaxHeading, theme.KeySyntaxEmphasis, theme.KeySyntaxLink,
		theme.KeySyntaxString, theme.KeySyntaxComment, theme.KeySyntaxAttribute,
	},
}

func TestEveryThemeTellsAdjacentSyntaxClassesApart(t *testing.T) {
	// turbo-classic once painted syntax.link the same lime as syntax.string,
	// so a Markdown link and an inline code span were the same thing on
	// screen. Nothing caught it — the theme was complete, every colour was
	// readable, and the two were simply equal.
	for _, name := range theme.Available("") {
		t.Run(name, func(t *testing.T) {
			th, err := theme.Load(name, "")
			if err != nil {
				t.Fatalf("Load() error = %v", err)
			}

			for group, keys := range tellApart {
				seen := map[string]string{}
				for _, key := range keys {
					fg, _, attrs := th.Style(key).Decompose()
					look := fmt.Sprintf("%v/%v", fg, attrs)
					if other, clash := seen[look]; clash {
						t.Errorf("in %s, %q and %q are drawn identically", group, other, key)
					}
					seen[look] = key
				}
			}
		})
	}
}