Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions font.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

package toolkit

import "github.com/go-widgets/painter"
import (
"unicode/utf8"

"github.com/go-widgets/painter"
)

// Bitmap font + text-drawing helper. The 5x7 glyph table below is
// copied verbatim from wasmdesk/wasmbox's dock scene (see
Expand Down Expand Up @@ -58,7 +62,19 @@ func (f *bitmapFont) Height() int { return baseGlyphHeight * f.Scale }

// Measure is the width text occupies. The bitmap font is monospace, so every
// rune (known or unknown) consumes exactly one Advance slot.
func (f *bitmapFont) Measure(text string) int { return len(text) * f.Advance() }
//
// ⛔⛔ IT USED TO COUNT BYTES, AND THE SENTENCE ABOVE HAS ALWAYS SAID RUNES.
// Everything outside ASCII was measured two or three times too wide. Measured
// 2026-09-07 at Advance 6: "e" 6, "é" 12, "€" 18, "abc" 18, "àbc" 24.
//
// ⭐ AND IT EXPLAINS A RULE SOMEBODY WROTE FROM THE SYMPTOM. go-xrkit/desk
// forbids apostrophes in its settings window because "the glasses' own menu
// bar" came out with a hole in it. The hole was THREE cells wide: a curly
// apostrophe is three bytes, so Draw walked three of them, found no glyph for
// any, and left three blanks where one character belonged.
func (f *bitmapFont) Measure(text string) int {
return utf8.RuneCountInString(text) * f.Advance()
}

// NewBitmapFont returns the built-in 5x7 font scaled by the given integer
// factor (clamped to at least 1). SetFont(NewBitmapFont(2)) doubles all text.
Expand Down Expand Up @@ -146,7 +162,13 @@ func GlyphAdvance() int { return CurrentFont().Advance() }
// glyph: 5 bytes, one per column, low 7 bits encode the rows from top
// (bit 0) to bottom (bit 6). Characters not in the map render as a
// blank (the advance is still consumed so columns line up).
var font5x7 = map[byte][5]byte{
// ⛔ KEYED BY RUNE, NOT BY BYTE. A byte key can only ever hold ASCII, so every
// character above 127 was a lookup that could not succeed -- and the ellipsis
// the toolkit trims text with, U+2026, was one of them: Ellipsis=true drew
// NOTHING where the ellipsis belonged, and a truncated label was
// indistinguishable from a clipped one. The literals below are rune constants
// already, so the table itself did not change.
var font5x7 = map[rune][5]byte{
// Digits.
'0': {0x3E, 0x51, 0x49, 0x45, 0x3E},
'1': {0x00, 0x42, 0x7F, 0x40, 0x00},
Expand Down Expand Up @@ -220,6 +242,13 @@ var font5x7 = map[byte][5]byte{
'.': {0x00, 0x60, 0x60, 0x00, 0x00},
',': {0x00, 0x50, 0x30, 0x00, 0x00},
':': {0x00, 0x36, 0x36, 0x00, 0x00},
// ⭐ THE ELLIPSIS THE TOOLKIT TRIMS WITH. ellipsize() appends U+2026, and
// until the table was keyed by rune there was no way to hold a glyph for it:
// Ellipsis=true drew nothing where the ellipsis belonged, so a truncated
// label looked exactly like a clipped one. Three single-pixel dots on the
// baseline, at columns 0, 2 and 4 -- lighter than three of the '.' glyph,
// which is a 2x2 block and would crowd a five-wide cell.
'…': {0x40, 0x00, 0x40, 0x00, 0x40},
'-': {0x08, 0x08, 0x08, 0x08, 0x08},
'_': {0x40, 0x40, 0x40, 0x40, 0x40},
'/': {0x20, 0x10, 0x08, 0x04, 0x02},
Expand Down Expand Up @@ -268,12 +297,20 @@ func (f *bitmapFont) Draw(p painter.Painter, x, y int, text string, ink RGBA) {
return
}
adv := f.Advance()
for k := 0; k < len(text); k++ {
bits, ok := font5x7[text[k]]
// ⛔ ONE CELL PER RUNE, NOT PER BYTE. Walking bytes gave a multi-byte
// character one cell for each of its bytes, and no glyph for any of them --
// so an accent or a curly quote became two or three blanks in a row, and
// everything after it was pushed along by that much.
cell := 0
for _, r := range text {
gx := x + cell*adv
cell++
// A rune with no glyph draws blank and still takes its one cell, which
// is what keeps a column of text lined up.
bits, ok := font5x7[r]
if !ok {
continue
}
gx := x + k*adv
for col := 0; col < 5; col++ {
cb := bits[col]
for row := 0; row < baseGlyphHeight; row++ {
Expand Down
58 changes: 58 additions & 0 deletions font_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,61 @@ func TestDrawTextHonoursInkAlpha(t *testing.T) {
t.Fatalf("half-alpha ink R = %d, want strictly between 0x10 and 0xC8 (blended)", got.R)
}
}

// TestTheBitmapFontCountsRunesNotBytes.
//
// ⛔⛔ IT COUNTED BYTES, AND ITS OWN DOC SAID RUNES. Every character outside
// ASCII was measured two or three times too wide, and Draw walked one cell per
// BYTE -- so an accent or a curly quote became two or three blanks in a row and
// pushed everything after it along by that much.
//
// ⭐ THAT IS WHERE A RULE IN ANOTHER REPOSITORY CAME FROM. go-xrkit/desk forbids
// apostrophes in its settings window because "the glasses' own menu bar" came
// out with a hole in it. The hole was THREE cells wide, because a curly
// apostrophe is three bytes.
func TestTheBitmapFontCountsRunesNotBytes(t *testing.T) {
f := NewBitmapFont(1)
adv := f.Advance()

for _, c := range []struct {
s string
cells int
}{
{"e", 1},
{"é", 1}, // two bytes
{"€", 1}, // three
{"…", 1}, // three -- the one the toolkit trims with
{"abc", 3},
{"àbc", 3}, // four bytes, three characters
{"", 0},
} {
if got, want := f.Measure(c.s), c.cells*adv; got != want {
t.Errorf("Measure(%q) = %d, want %d (%d cell(s) of %d): %d bytes were counted "+
"instead of %d runes", c.s, got, want, c.cells, adv, len(c.s), c.cells)
}
}
}

// TestTheEllipsisHasAGlyph.
//
// ⛔ ellipsize() appends U+2026, and while the glyph table was keyed by BYTE
// there was no way to hold one for it. Ellipsis=true therefore shortened the
// text and drew NOTHING in its place: on screen, indistinguishable from text
// clipped by its bounds.
func TestTheEllipsisHasAGlyph(t *testing.T) {
if _, ok := font5x7['…']; !ok {
t.Fatal("no glyph for U+2026, so an ellipsised label ends in nothing")
}
// And it must be inked, not an empty box that merely exists.
lit := 0
for _, col := range font5x7['…'] {
for row := 0; row < baseGlyphHeight; row++ {
if col&(1<<row) != 0 {
lit++
}
}
}
if lit != 3 {
t.Errorf("the ellipsis glyph lights %d pixels, want 3 dots", lit)
}
}
37 changes: 26 additions & 11 deletions label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,23 +161,38 @@ func TestLabelEllipsis(t *testing.T) {
t.Fatal("Ellipsis=true on an over-wide string must change the render")
}

// The truncated render must fit the bounds width and be narrower than the
// (overflowing) full render.
// The truncated render must fit the bounds width.
clipW := labelPaintedWidth(clip, w, h)
fullW := labelPaintedWidth(full, w, h)
if clipW > w {
t.Fatalf("truncated text width %d must fit bounds width %d", clipW, w)
}
if !(clipW < fullW) {
t.Fatalf("truncated width %d must be shorter than full width %d", clipW, fullW)
}

// With the bitmap font the ellipsis is 3 bytes (18px) and each glyph is 6px,
// so the widest prefix p with (len(p)+3)*6 <= 60 is 7 bytes: "This is". The
// truncated render must therefore equal "This is…" drawn directly.
want := drawLabel(w, h, func(l *Label) { l.Text().Set("This is…") })
// ⛔ IT IS NOT COMPARED WITH THE FULL RENDER ANY MORE. The full one is
// CLIPPED by the bounds, so its painted width is wherever its last surviving
// glyph happens to stop -- and an ellipsised render can legitimately paint
// FURTHER right, because the ellipsis sits at the far edge of its cell.
// Requiring "narrower" measured a coincidence of which letter fell last.

// ⭐ THE ELLIPSIS MUST BE DRAWN, NOT MERELY APPENDED. Until the glyph table
// was keyed by rune it could not hold U+2026 at all, so Ellipsis=true gave a
// shortened string ending in NOTHING -- indistinguishable on screen from
// text simply clipped by its bounds. Ink in the last cell is what says the
// ellipsis is there.
if lastCell := w - NewLabel("").glyphAdvance(); clipW <= lastCell {
t.Errorf("the truncated render stops at %d in a %d-wide box: nothing was "+
"painted in the last cell, so the ellipsis was appended but never drawn",
clipW, w)
}

// Each glyph is 6px and the ellipsis is ONE cell, so the widest prefix with
// (runes+1)*6 <= 60 is nine runes: "This is a".
//
// ⚠ THIS NUMBER USED TO BE SEVEN, and the comment here explained why: "the
// ellipsis is 3 bytes (18px)". That was the byte-counting defect written
// into the test as though it were the rule.
want := drawLabel(w, h, func(l *Label) { l.Text().Set("This is a…") })
if !bytes.Equal(clip, want) {
t.Fatal("truncated render must equal 'This is…' drawn directly")
t.Fatal("truncated render must equal 'This is a…' drawn directly")
}

// Ellipsis=true on text that already fits leaves it identical to the plain
Expand Down