diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-03-03 14:22:43 -0500 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-03-03 14:22:43 -0500 |
| commit | 0a0b9b8cc9cdc0ffe1819de0266dd1e3c3eb564f (patch) | |
| tree | ead2723b26a2dcb1d1db80efc01390579056d4fc /style | |
| parent | 8858a54b5ddb3a2d8a42ecb1a837c02800bc934f (diff) | |
| download | gui-0a0b9b8cc9cdc0ffe1819de0266dd1e3c3eb564f.zip | |
style: unit conversion
Implemented the golang.org/x/exp/shiny/unit.Converter interface on
style.Style.
Diffstat (limited to 'style')
| -rw-r--r-- | style/fixed.go | 11 | ||||
| -rw-r--r-- | style/fixed_test.go | 74 | ||||
| -rw-r--r-- | style/options.go | 62 | ||||
| -rw-r--r-- | style/style.go | 109 | ||||
| -rw-r--r-- | style/style_test.go | 202 |
5 files changed, 455 insertions, 3 deletions
diff --git a/style/fixed.go b/style/fixed.go new file mode 100644 index 0000000..fe3f91e --- /dev/null +++ b/style/fixed.go @@ -0,0 +1,11 @@ +package style + +import "golang.org/x/image/math/fixed" + +func fixed26ToFloat(n fixed.Int26_6) float64 { + return float64(n) / (1 << 6) +} + +func floatToFixed26(f float64) fixed.Int26_6 { + return fixed.Int26_6(f * (1 << 6)) +} diff --git a/style/fixed_test.go b/style/fixed_test.go new file mode 100644 index 0000000..5cae4bf --- /dev/null +++ b/style/fixed_test.go @@ -0,0 +1,74 @@ +package style + +import ( + "testing" + + "golang.org/x/image/math/fixed" +) + +func TestFixed26ToFloat(t *testing.T) { + t.Parallel() + + var ( + in fixed.Int26_6 + out, want float64 + ) + + in = 0 + out = fixed26ToFloat(in) + want = 0 + if out != want { + t.Errorf("float(%#v) = %v; want %v", in, out, want) + } + + in = fixed.I(123) + out = fixed26ToFloat(in) + want = 123 + if out != want { + t.Errorf("float(%#v) = %v; want %v", in, out, want) + } + + in = fixed.I(-123) + out = fixed26ToFloat(in) + want = -123 + if out != want { + t.Errorf("float(%#v) = %v; want %v", in, out, want) + } + + in = fixed.Int26_6(1<<6 + 1<<4) + out = fixed26ToFloat(in) + want = 1.25 + if out != want { + t.Errorf("float(%#v) = %v; want %v", in, out, want) + } +} + +func TestFloatToFixed26(t *testing.T) { + t.Parallel() + + var ( + in float64 + out, want fixed.Int26_6 + ) + + in = 0 + out = floatToFixed26(in) + want = 0 + if out != want { + t.Errorf("fixed(%#v) = %v; want %v", in, out, want) + } + + in = 1.25 + out = floatToFixed26(in) + want = fixed.Int26_6(1<<6 + 1<<4) + if out != want { + t.Errorf("fixed(%#v) = %v; want %v", in, out, want) + } + + in = -1.25 + out = floatToFixed26(in) + want = -fixed.Int26_6(1<<6 + 1<<4) + if out != want { + t.Errorf("fixed(%#v) = %v; want %v", in, out, want) + } +} diff --git a/style/options.go b/style/options.go new file mode 100644 index 0000000..c1514c0 --- /dev/null +++ b/style/options.go @@ -0,0 +1,62 @@ +package style + +import ( + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/opentype" +) + +var defaultOptions = options{ + font: goregular.TTF, + FaceOptions: opentype.FaceOptions{ + DefaultFontSize, + DefaultDpi, + DefaultHinting, + }, +} + +// Option is a functional option to Style constructor, New. +type Option func(*options) + +type options struct { + font []byte + opentype.FaceOptions +} + +func parseOpts(opts ...Option) options { + o := defaultOptions + for i := range opts { + opts[i](&o) + } + return o +} + +// Font option sets the font source to use. It takes TTF or OTF data +// (see golang.org/x/image/font/opentype.Parse). +func Font(src []byte) Option { + return func(o *options) { + o.font = src + } +} + +// FontSize option sets the font size in points (1/72"). +func FontSize(size float64) Option { + return func(o *options) { + o.FaceOptions.Size = size + } +} + +// Dpi option sets the dots per inch resolution of the display. +func Dpi(dpi float64) Option { + return func(o *options) { + o.FaceOptions.DPI = dpi + } +} + +// Hinting options selects how to quantize a vector font's glyph +// nodes. +func Hinting(h font.Hinting) Option { + return func(o *options) { + o.FaceOptions.Hinting = h + } +} diff --git a/style/style.go b/style/style.go index 5b74952..fe10833 100644 --- a/style/style.go +++ b/style/style.go @@ -1,22 +1,125 @@ package style import ( + "fmt" + "golang.org/x/exp/shiny/unit" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" ) -// TODO +const ( + DefaultFontSize = 12.0 // points (1/72") + DefaultDpi = 72.0 // dots per inch + DefaultHinting = font.HintingFull +) + +// Style defines the colors and font faces that widgets are drawn with. type Style struct { + font *opentype.Font + faceOpts opentype.FaceOptions + face font.Face // for measurements + + // TODO +} + +// New returns a new Style with the given options. +// The Style should be closed after use. +func New(opts ...Option) (*Style, error) { + o := parseOpts(opts...) + + fnt, err := opentype.Parse(o.font) + if err != nil { + return nil, err + } + face, err := opentype.NewFace(fnt, &o.FaceOptions) + if err != nil { + return nil, err + } + + return &Style{ + fnt, + o.FaceOptions, + face, + }, nil +} + +func (s *Style) Close() error { + return s.face.Close() +} + +// NewFace returns a new font.Face using the Style's Font and FaceOptions. +func (s *Style) NewFace() (font.Face, error) { + return opentype.NewFace(s.font, &s.faceOpts) } // Convert implements the golang.org/x/exp/shiny/unit.Converter // interface. func (s *Style) Convert(v unit.Value, to unit.Unit) unit.Value { - // TODO + px := fixed26ToFloat(s.Pixels(v)) + var f float64 + switch to { + case unit.Px: + f = px + case unit.Dp: + f = s.pixelsToInches(px) * unit.DensityIndependentPixelsPerInch + case unit.Pt: + f = s.pixelsToInches(px) * unit.PointsPerInch + case unit.Mm: + f = s.pixelsToInches(px) * unit.MillimetresPerInch + case unit.In: + f = s.pixelsToInches(px) + case unit.Em: + f = px / fixed26ToFloat(s.face.Metrics().Height) + case unit.Ex: + f = px / fixed26ToFloat(s.face.Metrics().XHeight) + case unit.Ch: + f = px / fixed26ToFloat(s.zeroWidth()) + default: + panic(fmt.Sprintf("unreachable: impossible %T: %v", to, to)) + } + return unit.Value{f, to} } // Pixels implements the golang.org/x/exp/shiny/unit.Converter // interface. func (s *Style) Pixels(v unit.Value) fixed.Int26_6 { - // TODO + f := floatToFixed26(v.F) + switch v.U { + case unit.Px: + return f + case unit.Dp: + return s.inchesToPixels(v.F / unit.DensityIndependentPixelsPerInch) + case unit.Pt: + return s.inchesToPixels(v.F / unit.PointsPerInch) + case unit.Mm: + return s.inchesToPixels(v.F / unit.MillimetresPerInch) + case unit.In: + return s.inchesToPixels(v.F) + case unit.Em: + return f.Mul(s.face.Metrics().Height) + case unit.Ex: + return f.Mul(s.face.Metrics().XHeight) + case unit.Ch: + return f.Mul(s.zeroWidth()) + default: + panic(fmt.Sprintf("unreachable: impossible %T: %v", v.U, v.U)) + } +} + +func (s *Style) pixelsToInches(px float64) (in float64) { + return px / s.faceOpts.DPI +} + +func (s *Style) inchesToPixels(in float64) (px fixed.Int26_6) { + return floatToFixed26(in * s.faceOpts.DPI) +} + +func (s *Style) zeroWidth() (px fixed.Int26_6) { + if advance, ok := s.face.GlyphAdvance('0'); ok { + return advance + } else { + return floatToFixed26(0.5).Mul(s.face.Metrics().Height) // 0.5em + } } diff --git a/style/style_test.go b/style/style_test.go new file mode 100644 index 0000000..14f00e2 --- /dev/null +++ b/style/style_test.go @@ -0,0 +1,202 @@ +package style + +import ( + "testing" + + "golang.org/x/exp/shiny/unit" + "golang.org/x/image/font/gofont/gomono" + "golang.org/x/image/math/fixed" +) + +const ( + dpi = 150.0 + epsilon = 0.5 // TODO: optimize precision +) + +var ( + fontTtf = gomono.TTF + units = []unit.Unit{unit.Px, unit.Dp, unit.Pt, unit.Mm, unit.In, unit.Em, unit.Ex, unit.Ch} +) + +func TestConvert(t *testing.T) { + t.Parallel() + + s, err := New(Font(fontTtf), Dpi(dpi)) + if err != nil { + t.Error(err) + } + defer func() { + if err := s.Close(); err != nil { + t.Fatal(err) + } + }() + + // Trivial + for _, from := range units { + for _, to := range units { + testConvert(t, s, unit.Value{0, from}, to, 0) + } + } + + pxPerEm := fixed26ToFloat(s.face.Metrics().Height) + pxPerEx := fixed26ToFloat(s.face.Metrics().XHeight) + advance, ok := s.face.GlyphAdvance('0') + if !ok { + t.Fail() + } + pxPerCh := fixed26ToFloat(advance) + + // px → all + px := 123.4 + in := px / dpi + from := unit.Value{px, unit.Px} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // dp → all + dp := 123.4 + in = dp / 160 + px = in * dpi + from = unit.Value{dp, unit.Dp} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, dp) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // Pt → all + pt := 123.4 + in = pt / 72 + px = in * dpi + from = unit.Value{pt, unit.Pt} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, pt) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // Mm → all + mm := 123.4 + in = mm / 25.4 + px = in * dpi + from = unit.Value{mm, unit.Mm} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, mm) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // In → all + in = 123.4 + px = in * dpi + from = unit.Value{in, unit.In} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // Em → all + em := 123.4 + px = em * pxPerEm + in = px / dpi + from = unit.Value{em, unit.Em} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, em) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // Ex → all + ex := 123.4 + px = ex * pxPerEx + in = px / dpi + from = unit.Value{ex, unit.Ex} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, ex) + testConvert(t, s, from, unit.Ch, px/pxPerCh) + + // Ch → all + ch := 123.4 + px = ch * pxPerCh + in = px / dpi + from = unit.Value{ch, unit.Ch} + testConvert(t, s, from, unit.Px, px) + testConvert(t, s, from, unit.Dp, in*160) + testConvert(t, s, from, unit.Pt, in*72) + testConvert(t, s, from, unit.Mm, in*25.4) + testConvert(t, s, from, unit.In, in) + testConvert(t, s, from, unit.Em, px/pxPerEm) + testConvert(t, s, from, unit.Ex, px/pxPerEx) + testConvert(t, s, from, unit.Ch, ch) +} + +func testConvert(t *testing.T, s *Style, from unit.Value, to unit.Unit, want float64) { + out := s.Convert(from, to) + if out.U != to || out.F < want-epsilon || out.F > want+epsilon { + t.Errorf("Convert(%v, %v) = %v; want %v", from, to, out, unit.Value{want, to}) + } +} + +func TestPixels(t *testing.T) { + t.Parallel() + + s, err := New(Font(fontTtf), Dpi(dpi)) + if err != nil { + t.Error(err) + } + defer func() { + if err := s.Close(); err != nil { + t.Fatal(err) + } + }() + + for _, u := range units { + testPixels(t, s, unit.Value{0, u}, 0) + } + testPixels(t, s, unit.Value{123.4, unit.Px}, floatToFixed26(123.4)) + testPixels(t, s, unit.Value{123.4, unit.Dp}, floatToFixed26(123.4/160*dpi)) + testPixels(t, s, unit.Value{123.4, unit.Pt}, floatToFixed26(123.4/72*dpi)) + testPixels(t, s, unit.Value{123.4, unit.Mm}, floatToFixed26(123.4/25.4*dpi)) + testPixels(t, s, unit.Value{123.4, unit.In}, floatToFixed26(123.4*dpi)) + testPixels(t, s, unit.Value{123.4, unit.Em}, floatToFixed26(123.4).Mul(s.face.Metrics().Height)) + testPixels(t, s, unit.Value{123.4, unit.Ex}, floatToFixed26(123.4).Mul(s.face.Metrics().XHeight)) + if advance, ok := s.face.GlyphAdvance('0'); ok { + testPixels(t, s, unit.Value{123.4, unit.Ch}, floatToFixed26(123.4).Mul(advance)) + } else { + t.Fail() + } +} + +func testPixels(t *testing.T, s *Style, in unit.Value, want fixed.Int26_6) { + out := s.Pixels(in) + if out != want { + t.Errorf("Pixels(%#v) = %v; want %v", in, out, want) + } +} |