diff options
| -rw-r--r-- | go.mod | 9 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -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 |
7 files changed, 465 insertions, 8 deletions
@@ -6,10 +6,9 @@ require ( github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728 + github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f + golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa + golang.org/x/image v0.36.0 ) -require ( - github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f // indirect - golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/image v0.36.0 // indirect -) +require golang.org/x/text v0.34.0 // indirect @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 h1:baVdMKlASEHrj19iqjARrPbaRisD7EuZEVJj6ZMLl1Q= github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3/go.mod h1:VEPNJUlxl5KdWjDvz6Q1l+rJlxF2i6xqDeGuGAxa87M= @@ -7,12 +8,17 @@ github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728 h1:Ak0LUgy7whfnJGPcjhR4 github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:fOxQgJvH6dIDHn5YOoXiNC8tUMMNuCgbMK2yZTlZVQA= github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f h1:BJPLqH5ylnMCrDyViN4w9kHYup2w1BNKbEihSAQpDOk= github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f/go.mod h1:iaGbC3JZCbafAF9UkQTn2To3bMVOkTXNFTfS2yRvclM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa h1:+7e7RPzOw2fG8DBbddatlOmHGNCg+VlA2Ar0yVMw7sM= golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:zxsA7NyDTOUjcveVwAMFI/YIErWwayTW/4RGqB/RzKk= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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) + } +} |