From 1ba1dcc8e17acf4e9f48bc4e52f30307a2a566cc Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 7 Sep 2026 18:41:33 +0200 Subject: [PATCH] The bitmap font counts runes, and the ellipsis has a glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measure did len(text), and its own doc line has always said "every rune (known or unknown) consumes exactly one Advance slot". Draw walked bytes too, indexing the glyph table by byte. So everything outside ASCII was measured two or three times too wide and drawn as two or three blanks in a row, pushing the rest of the line along by that much. Measured at Advance 6, before: "e" 6 "é" 12 "€" 18 "…" 18 "abc" 18 "àbc" 24 ⭐ AND IT EXPLAINS A RULE IN ANOTHER REPOSITORY. 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 and found no glyph for any. ⛔ AND THE ELLIPSIS COULD NOT HAVE A GLYPH AT ALL. font5x7 was keyed by BYTE, so no key above 127 could exist -- including U+2026, which ellipsize() appends. Ellipsis=true therefore shortened the text and drew NOTHING in its place: on screen, indistinguishable from text clipped by its bounds. The table is keyed by rune now (its literals were rune constants already, so it did not change) and carries three dots on the baseline. TestLabelEllipsis was passing for the wrong reason, and its own comment said so: "the ellipsis is 3 bytes (18px) ... the widest prefix is 7 bytes". The byte count was written into the test as though it were the rule. It now expects nine runes, and asserts that the last cell is INKED -- which is what distinguishes an ellipsis from a clip. Its "narrower than the full render" check is gone: the full render is clipped by the bounds, so its width is wherever its last surviving glyph stops, and an ellipsised line can legitimately paint further right. Proved by sabotage: restoring len(text) fails both new tests and the ellipsis test, naming the byte counts. Co-Authored-By: Claude Opus 5 --- font.go | 49 +++++++++++++++++++++++++++++++++++++------ font_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ label_test.go | 37 ++++++++++++++++++++++---------- 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/font.go b/font.go index 59ef740..7556d6c 100644 --- a/font.go +++ b/font.go @@ -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 @@ -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. @@ -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}, @@ -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}, @@ -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++ { diff --git a/font_test.go b/font_test.go index 7282297..11e1cb0 100644 --- a/font_test.go +++ b/font_test.go @@ -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< 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