Skip to content

Commit c3b2cc2

Browse files
hajimehoshiclaude
andcommitted
basicwidget/internal: represent font attributes as variations and features
Replace font.Attributes' ad-hoc Weight, Liga, and Tnum members with general OpenType variation and feature settings. The settings are stored as taggedValues, a canonical string encoding sorted by tag, so Attributes remains comparable and keeps working as the face cache key and in change checks. Attributes values are built with WithVariation and WithFeature, and resolveFace applies the settings generically instead of special-casing individual tags. Move the Feature and Variation types from textstyle to font so the OpenType tag/value concepts live in the leaf package; textstyle's Style now exposes them as font.Feature and font.Variation. A zero Attributes no longer forces the wght axis to 0 on resolved faces; unset axes now leave the font's defaults untouched. Updates #131 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 510e5a2 commit c3b2cc2

8 files changed

Lines changed: 154 additions & 69 deletions

File tree

basicwidget/internal/font/font.go

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"bytes"
88
"compress/gzip"
99
_ "embed"
10+
"encoding/binary"
11+
"iter"
12+
"math"
1013
"slices"
1114
"sync/atomic"
1215

@@ -41,26 +44,90 @@ func init() {
4144
theDefaultFaceSource = e
4245
}
4346

44-
var (
45-
tagWght = text.MustParseTag("wght")
46-
tagLiga = text.MustParseTag("liga")
47-
tagTnum = text.MustParseTag("tnum")
48-
)
47+
// Feature is an OpenType feature setting.
48+
type Feature struct {
49+
// Tag identifies the feature, like 'liga'.
50+
Tag text.Tag
51+
52+
// Value is the feature value handed to the font. For typical on/off
53+
// features, 0 disables and 1 enables the feature.
54+
Value uint32
55+
}
56+
57+
// Variation is an OpenType variation axis setting.
58+
type Variation struct {
59+
// Tag identifies the axis, like 'wght'.
60+
Tag text.Tag
61+
62+
// Value is the axis value.
63+
Value float32
64+
}
65+
66+
// taggedValues is a canonical, comparable encoding of OpenType (tag, value)
67+
// settings: 8 bytes per entry, a big-endian uint32 tag followed by a
68+
// big-endian uint32 value, sorted by tag with at most one entry per tag.
69+
type taggedValues string
70+
71+
// uint32At returns the big-endian uint32 at byte offset i.
72+
func (t taggedValues) uint32At(i int) uint32 {
73+
return uint32(t[i])<<24 | uint32(t[i+1])<<16 | uint32(t[i+2])<<8 | uint32(t[i+3])
74+
}
75+
76+
// with returns t with tag set to value, keeping the canonical order.
77+
func (t taggedValues) with(tag text.Tag, value uint32) taggedValues {
78+
var buf [8]byte
79+
binary.BigEndian.PutUint32(buf[:4], uint32(tag))
80+
binary.BigEndian.PutUint32(buf[4:], value)
81+
entry := taggedValues(buf[:])
82+
for i := 0; i < len(t); i += 8 {
83+
switch existing := text.Tag(t.uint32At(i)); {
84+
case existing < tag:
85+
continue
86+
case existing == tag:
87+
return t[:i] + entry + t[i+8:]
88+
default:
89+
return t[:i] + entry + t[i:]
90+
}
91+
}
92+
return t + entry
93+
}
4994

50-
// Attributes is a comparable set of text rendering attributes: size, weight,
51-
// ligatures, tabular numerals, and language. A face is resolved from
52-
// Attributes together with a [Family]; the attributes alone do not identify a
53-
// font.
54-
//
55-
// TODO: Weight, Liga, and Tnum are ad-hoc special cases. Represent them as
56-
// general OpenType variations (wght) and features (liga, tnum) while keeping
57-
// Attributes comparable (#131).
95+
// all iterates the settings in tag order.
96+
func (t taggedValues) all() iter.Seq2[text.Tag, uint32] {
97+
return func(yield func(text.Tag, uint32) bool) {
98+
for i := 0; i < len(t); i += 8 {
99+
if !yield(text.Tag(t.uint32At(i)), t.uint32At(i+4)) {
100+
return
101+
}
102+
}
103+
}
104+
}
105+
106+
// Attributes is a comparable set of text rendering attributes: size,
107+
// language, and OpenType variation and feature settings. A face is resolved
108+
// from Attributes together with a [Family]; the attributes alone do not
109+
// identify a font.
58110
type Attributes struct {
59-
Size float64
60-
Weight text.Weight
61-
Liga bool
62-
Tnum bool
63-
Lang language.Tag
111+
Size float64
112+
Lang language.Tag
113+
114+
// variations is the canonical encoding of the variation axis settings.
115+
variations taggedValues
116+
117+
// features is the canonical encoding of the feature settings.
118+
features taggedValues
119+
}
120+
121+
// WithVariation returns a with the OpenType variation axis tag set to value.
122+
func (a Attributes) WithVariation(tag text.Tag, value float32) Attributes {
123+
a.variations = a.variations.with(tag, math.Float32bits(value))
124+
return a
125+
}
126+
127+
// WithFeature returns a with the OpenType feature tag set to value.
128+
func (a Attributes) WithFeature(tag text.Tag, value uint32) Attributes {
129+
a.features = a.features.with(tag, value)
130+
return a
64131
}
65132

66133
// Face is a resolved text face.
@@ -238,16 +305,11 @@ func resolveFace(context *guigui.Context, fnt *Family, attributes Attributes) (t
238305
Size: attributes.Size,
239306
Language: attributes.Lang,
240307
}
241-
gtf.SetVariation(tagWght, float32(attributes.Weight))
242-
if attributes.Liga {
243-
gtf.SetFeature(tagLiga, 1)
244-
} else {
245-
gtf.SetFeature(tagLiga, 0)
308+
for tag, value := range attributes.variations.all() {
309+
gtf.SetVariation(tag, math.Float32frombits(value))
246310
}
247-
if attributes.Tnum {
248-
gtf.SetFeature(tagTnum, 1)
249-
} else {
250-
gtf.SetFeature(tagTnum, 0)
311+
for tag, value := range attributes.features.all() {
312+
gtf.SetFeature(tag, value)
251313
}
252314

253315
var f text.Face

basicwidget/internal/font/font_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,38 @@ package font_test
66
import (
77
"testing"
88

9+
"github.com/hajimehoshi/ebiten/v2/text/v2"
10+
911
"github.com/guigui-gui/guigui"
1012
"github.com/guigui-gui/guigui/basicwidget/internal/font"
1113
)
1214

15+
func TestAttributesCanonical(t *testing.T) {
16+
tagWght := text.MustParseTag("wght")
17+
tagLiga := text.MustParseTag("liga")
18+
tagTnum := text.MustParseTag("tnum")
19+
20+
// The setting order does not affect equality.
21+
a1 := font.Attributes{Size: 16}.WithVariation(tagWght, 700).WithFeature(tagLiga, 1).WithFeature(tagTnum, 0)
22+
a2 := font.Attributes{Size: 16}.WithFeature(tagTnum, 0).WithFeature(tagLiga, 1).WithVariation(tagWght, 700)
23+
if a1 != a2 {
24+
t.Errorf("attributes with the same settings should be equal: %v != %v", a1, a2)
25+
}
26+
27+
// Setting a tag again overwrites the previous value.
28+
a3 := font.Attributes{Size: 16}.WithVariation(tagWght, 400).WithVariation(tagWght, 700)
29+
a4 := font.Attributes{Size: 16}.WithVariation(tagWght, 700)
30+
if a3 != a4 {
31+
t.Errorf("overwriting a tag should be equal to setting it once: %v != %v", a3, a4)
32+
}
33+
34+
// Different values are not equal.
35+
a5 := font.Attributes{Size: 16}.WithVariation(tagWght, 400)
36+
if a4 == a5 {
37+
t.Errorf("attributes with different settings should not be equal: %v == %v", a4, a5)
38+
}
39+
}
40+
1341
func TestFaceIDStableForSameRecipe(t *testing.T) {
1442
var context guigui.Context
1543

basicwidget/internal/textstyle/export_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,14 @@ func (s Style) WithLang(lang language.Tag) Style {
5555
}
5656

5757
func (s Style) WithFeature(tag text.Tag, value uint32) Style {
58-
s.features = mergeTagged(s.features, []Feature{{Tag: tag, Value: value}}, func(f Feature) text.Tag {
58+
s.features = mergeTagged(s.features, []font.Feature{{Tag: tag, Value: value}}, func(f font.Feature) text.Tag {
5959
return f.Tag
6060
})
6161
return s
6262
}
6363

6464
func (s Style) WithVariation(tag text.Tag, value float32) Style {
65-
s.variations = mergeTagged(s.variations, []Variation{{Tag: tag, Value: value}}, func(v Variation) text.Tag {
65+
s.variations = mergeTagged(s.variations, []font.Variation{{Tag: tag, Value: value}}, func(v font.Variation) text.Tag {
6666
return v.Tag
6767
})
6868
return s

basicwidget/internal/textstyle/runs.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func (r *Runs) UnsetLang(start, end int) {
124124

125125
// SetFeature overrides the OpenType feature tag in [start, end) with value.
126126
func (r *Runs) SetFeature(start, end int, tag text.Tag, value uint32) {
127-
r.apply(start, end, Style{features: []Feature{{Tag: tag, Value: value}}})
127+
r.apply(start, end, Style{features: []font.Feature{{Tag: tag, Value: value}}})
128128
}
129129

130130
// UnsetFeature removes the override of the OpenType feature tag in
@@ -136,7 +136,7 @@ func (r *Runs) UnsetFeature(start, end int, tag text.Tag) {
136136
// SetVariation overrides the OpenType variation axis tag in [start, end)
137137
// with value.
138138
func (r *Runs) SetVariation(start, end int, tag text.Tag, value float32) {
139-
r.apply(start, end, Style{variations: []Variation{{Tag: tag, Value: value}}})
139+
r.apply(start, end, Style{variations: []font.Variation{{Tag: tag, Value: value}}})
140140
}
141141

142142
// UnsetVariation removes the override of the OpenType variation axis tag in

basicwidget/internal/textstyle/runs_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/hajimehoshi/ebiten/v2/text/v2"
1313
"golang.org/x/text/language"
1414

15+
"github.com/guigui-gui/guigui/basicwidget/internal/font"
1516
"github.com/guigui-gui/guigui/basicwidget/internal/textstyle"
1617
)
1718

@@ -427,7 +428,7 @@ func TestRunsStyleGetters(t *testing.T) {
427428
if _, ok := style.Underline(); ok {
428429
t.Errorf("Underline(): got set, want unset")
429430
}
430-
if got, want := style.Features(), []textstyle.Feature{{Tag: tagTnum, Value: 1}}; !slices.Equal(got, want) {
431+
if got, want := style.Features(), []font.Feature{{Tag: tagTnum, Value: 1}}; !slices.Equal(got, want) {
431432
t.Errorf("Features(): got: %+v, want: %+v", got, want)
432433
}
433434
if got := style.Variations(); got != nil {

basicwidget/internal/textstyle/style.go

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,6 @@ func (o optional[T]) Value() (T, bool) {
3636
return o.value, o.set
3737
}
3838

39-
// Feature is an OpenType feature setting.
40-
type Feature struct {
41-
// Tag identifies the feature, like 'liga'.
42-
Tag text.Tag
43-
44-
// Value is the feature value handed to the font. For typical on/off
45-
// features, 0 disables and 1 enables the feature.
46-
Value uint32
47-
}
48-
49-
// Variation is an OpenType variation axis setting.
50-
type Variation struct {
51-
// Tag identifies the axis, like 'wght'.
52-
Tag text.Tag
53-
54-
// Value is the axis value.
55-
Value float32
56-
}
57-
5839
// Style is a set of style overrides for a byte range of a text value. The
5940
// zero value overrides nothing. Styles are produced by [Runs]; an unset
6041
// property does not override anything.
@@ -73,8 +54,8 @@ type Style struct {
7354

7455
// features and variations are sorted by tag with at most one entry per
7556
// tag.
76-
features []Feature
77-
variations []Variation
57+
features []font.Feature
58+
variations []font.Variation
7859
}
7960

8061
// Family returns the font family override and whether it is set.
@@ -120,12 +101,12 @@ func (s Style) Lang() (language.Tag, bool) {
120101
}
121102

122103
// Features returns the OpenType feature overrides, sorted by tag.
123-
func (s Style) Features() []Feature {
104+
func (s Style) Features() []font.Feature {
124105
return s.features
125106
}
126107

127108
// Variations returns the OpenType variation axis overrides, sorted by tag.
128-
func (s Style) Variations() []Variation {
109+
func (s Style) Variations() []font.Variation {
129110
return s.variations
130111
}
131112

@@ -184,10 +165,10 @@ func (s Style) merge(other Style) Style {
184165
if other.lang.set {
185166
s.lang = other.lang
186167
}
187-
s.features = mergeTagged(s.features, other.features, func(f Feature) text.Tag {
168+
s.features = mergeTagged(s.features, other.features, func(f font.Feature) text.Tag {
188169
return f.Tag
189170
})
190-
s.variations = mergeTagged(s.variations, other.variations, func(v Variation) text.Tag {
171+
s.variations = mergeTagged(s.variations, other.variations, func(v font.Variation) text.Tag {
191172
return v.Tag
192173
})
193174
return s
@@ -247,10 +228,10 @@ func (s Style) remove(mask styleMask) Style {
247228
if mask.lang {
248229
s.lang = optional[language.Tag]{}
249230
}
250-
s.features = removeTagged(s.features, mask.featureTags, func(f Feature) text.Tag {
231+
s.features = removeTagged(s.features, mask.featureTags, func(f font.Feature) text.Tag {
251232
return f.Tag
252233
})
253-
s.variations = removeTagged(s.variations, mask.variationTags, func(v Variation) text.Tag {
234+
s.variations = removeTagged(s.variations, mask.variationTags, func(v font.Variation) text.Tag {
254235
return v.Tag
255236
})
256237
return s
@@ -279,10 +260,10 @@ func removeTagged[T any](entries []T, tags []text.Tag, tag func(T) text.Tag) []T
279260
// canonicalized returns s with features and variations sorted by tag and
280261
// deduplicated, keeping the last entry for each tag.
281262
func (s Style) canonicalized() Style {
282-
s.features = canonicalTagged(s.features, func(f Feature) text.Tag {
263+
s.features = canonicalTagged(s.features, func(f font.Feature) text.Tag {
283264
return f.Tag
284265
})
285-
s.variations = canonicalTagged(s.variations, func(v Variation) text.Tag {
266+
s.variations = canonicalTagged(s.variations, func(v font.Variation) text.Tag {
286267
return v.Tag
287268
})
288269
return s

basicwidget/internal/textwidget/style.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,33 @@ func (s *textStyle) fontFamilyID() uint64 {
9595
// faceAttributes returns the font attributes to shape the value with. liga
9696
// sets whether ligatures are enabled.
9797
func (s *textStyle) faceAttributes(forceBold bool, liga bool) font.Attributes {
98-
size := s.baseFontSize * s.scale()
9998
weight := text.WeightMedium
10099
if s.bold || forceBold {
101100
weight = text.WeightBold
102101
}
103-
return font.Attributes{
104-
Size: size,
105-
Weight: weight,
106-
Liga: liga,
107-
Tnum: s.tabular,
108-
Lang: s.lang,
102+
a := font.Attributes{
103+
Size: s.baseFontSize * s.scale(),
104+
Lang: s.lang,
109105
}
106+
a = a.WithVariation(tagWght, float32(weight))
107+
a = a.WithFeature(tagLiga, boolToFeatureValue(liga))
108+
a = a.WithFeature(tagTnum, boolToFeatureValue(s.tabular))
109+
return a
110110
}
111111

112+
func boolToFeatureValue(b bool) uint32 {
113+
if b {
114+
return 1
115+
}
116+
return 0
117+
}
118+
119+
var (
120+
tagWght = text.MustParseTag("wght")
121+
tagLiga = text.MustParseTag("liga")
122+
tagTnum = text.MustParseTag("tnum")
123+
)
124+
112125
// ensureStyleRuns clears the ranged style overrides if the store's
113126
// renderable content has been mutated since they were applied, and returns
114127
// the runs.

basicwidget/internal/textwidget/text.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ func (t *Text) faceAttributes(forceBold bool) font.Attributes {
754754
func (t *Text) face(context *guigui.Context, forceBold bool) font.Face {
755755
attrs := t.lastFaceAttributes
756756
if forceBold {
757-
attrs.Weight = text.WeightBold
757+
attrs = attrs.WithVariation(tagWght, float32(text.WeightBold))
758758
}
759759
return font.NewFace(context, t.style.fontFamily, attrs)
760760
}

0 commit comments

Comments
 (0)