aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-03-03 14:22:43 -0500
committerSam Anthony <sam@samanthony.xyz>2026-03-03 14:22:43 -0500
commit0a0b9b8cc9cdc0ffe1819de0266dd1e3c3eb564f (patch)
treeead2723b26a2dcb1d1db80efc01390579056d4fc
parent8858a54b5ddb3a2d8a42ecb1a837c02800bc934f (diff)
downloadgui-0a0b9b8cc9cdc0ffe1819de0266dd1e3c3eb564f.zip
style: unit conversion
Implemented the golang.org/x/exp/shiny/unit.Converter interface on style.Style.
-rw-r--r--go.mod9
-rw-r--r--go.sum6
-rw-r--r--style/fixed.go11
-rw-r--r--style/fixed_test.go74
-rw-r--r--style/options.go62
-rw-r--r--style/style.go109
-rw-r--r--style/style_test.go202
7 files changed, 465 insertions, 8 deletions
diff --git a/go.mod b/go.mod
index a062971..921713c 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 025737b..d8426d4 100644
--- a/go.sum
+++ b/go.sum
@@ -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)
+ }
+}