diff --git a/go.mod b/go.mod index 2d1341e7..e3d873f4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gioui.org go 1.13 require ( - golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 + golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 + golang.org/x/image v0.0.0-20190802002840-cff245a6509b golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a ) diff --git a/go.sum b/go.sum index 17663b41..8e006a6b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,25 @@ -golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s= -golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/widget/button.go b/widget/button.go new file mode 100644 index 00000000..7beecef4 --- /dev/null +++ b/widget/button.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package widget + +import ( + "time" + + "gioui.org/f32" + "gioui.org/gesture" + "gioui.org/layout" + "gioui.org/op" +) + +type Button struct { + click gesture.Click + clicks int + history []Click +} + +// Click represents a historic click. +type Click struct { + Position f32.Point + Time time.Time +} + +func (b *Button) Clicked(gtx *layout.Context) bool { + for _, e := range b.click.Events(gtx) { + switch e.Type { + case gesture.TypeClick: + b.clicks++ + case gesture.TypePress: + b.history = append(b.history, Click{ + Position: e.Position, + Time: gtx.Now(), + }) + } + } + if b.clicks > 0 { + b.clicks-- + if b.clicks > 0 { + // Ensure timely delivery of remaining clicks. + op.InvalidateOp{}.Add(gtx.Ops) + } + return true + } + return false +} + +func (b *Button) Active() bool { + return b.click.Active() +} + +func (b *Button) History() []Click { + return b.history +} + +func (b *Button) Layout(gtx *layout.Context) { + b.click.Add(gtx.Ops) + if !b.Active() { + b.clicks = 0 + } + for len(b.history) > 0 { + c := b.history[0] + if gtx.Now().Sub(c.Time) < 1*time.Second { + break + } + copy(b.history, b.history[1:]) + b.history = b.history[:len(b.history)-1] + } +} diff --git a/widget/doc.go b/widget/doc.go new file mode 100644 index 00000000..4367ddb9 --- /dev/null +++ b/widget/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Package widget implements common user interface controls. Widgets +// contain peristent state and process user events. Theme packages +// such as `widget/material` implements drawing of widgets. +package widget diff --git a/widget/material/button.go b/widget/material/button.go new file mode 100644 index 00000000..bf9b55e8 --- /dev/null +++ b/widget/material/button.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Package material implements the Material design. +package material + +import ( + "image" + "image/color" + "image/draw" + + "gioui.org/f32" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "golang.org/x/exp/shiny/iconvg" +) + +type Button struct { + Text string + // Color is the text color. + Color color.RGBA + Font text.Font + Background color.RGBA + + shaper *text.Shaper +} + +type IconButton struct { + Background color.RGBA + Icon *Icon + Size unit.Value + Padding unit.Value +} + +type Icon struct { + src []byte + size unit.Value + + // Cached values. + img image.Image + imgSize int +} + +func (t *Theme) Button(txt string) Button { + return Button{ + Text: txt, + Color: rgb(0xffffff), + Background: t.Color.Primary, + Font: text.Font{ + Size: t.TextSize.Scale(14.0 / 16.0), + }, + shaper: t.Shaper, + } +} + +// NewIcon returns a new Icon from IconVG data. +func NewIcon(data []byte) (*Icon, error) { + _, err := iconvg.DecodeMetadata(data) + if err != nil { + return nil, err + } + return &Icon{src: data}, nil +} + +func (t *Theme) IconButton(icon *Icon) IconButton { + return IconButton{ + Background: t.Color.Primary, + Icon: icon, + Size: unit.Dp(56), + Padding: unit.Dp(20), + } +} + +func (b Button) Layout(gtx *layout.Context, button *widget.Button) { + col := b.Color + bgcol := b.Background + if !button.Active() { + col.A = 0xaa + bgcol.A = 0xaa + } + st := layout.Stack{} + lbl := st.Rigid(gtx, func() { + layout.UniformInset(unit.Dp(16)).Layout(gtx, func() { + paint.ColorOp{Color: col}.Add(gtx.Ops) + widget.Label{Alignment: text.Middle}.Layout(gtx, b.shaper, b.Font, b.Text) + }) + pointer.RectAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops) + button.Layout(gtx) + }) + bg := st.Expand(gtx, func() { + rr := float32(gtx.Px(unit.Dp(4))) + rrect(gtx.Ops, + float32(gtx.Constraints.Width.Max), + float32(gtx.Constraints.Height.Max), + rr, rr, rr, rr, + ) + fill(gtx, bgcol) + for _, c := range button.History() { + drawInk(gtx, c) + } + }) + st.Layout(gtx, bg, lbl) +} + +func (b IconButton) Layout(gtx *layout.Context, button *widget.Button) { + st := layout.Stack{} + ico := st.Rigid(gtx, func() { + layout.UniformInset(b.Padding).Layout(gtx, func() { + size := gtx.Px(b.Size) - gtx.Px(b.Padding) + ico := b.Icon.image(size) + paint.ImageOp{Src: ico, Rect: ico.Bounds()}.Add(gtx.Ops) + paint.PaintOp{ + Rect: toRectF(ico.Bounds()), + }.Add(gtx.Ops) + gtx.Dimensions = layout.Dimensions{ + Size: image.Point{X: size, Y: size}, + } + }) + pointer.EllipseAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops) + button.Layout(gtx) + }) + bgcol := b.Background + if !button.Active() { + bgcol.A = 0xaa + } + bg := st.Expand(gtx, func() { + size := float32(gtx.Constraints.Width.Max) + rr := float32(size) * .5 + rrect(gtx.Ops, + size, + size, + rr, rr, rr, rr, + ) + fill(gtx, bgcol) + for _, c := range button.History() { + drawInk(gtx, c) + } + }) + st.Layout(gtx, bg, ico) +} + +func (ic *Icon) image(sz int) image.Image { + if sz == ic.imgSize { + return ic.img + } + m, _ := iconvg.DecodeMetadata(ic.src) + dx, dy := m.ViewBox.AspectRatio() + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}}) + var ico iconvg.Rasterizer + ico.SetDstImage(img, img.Bounds(), draw.Src) + // Use white for icons. + m.Palette[0] = color.RGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} + iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{ + Palette: &m.Palette, + }) + ic.img = img + ic.imgSize = sz + return img +} + +func fab(gtx *layout.Context, ico image.Image, col color.RGBA, size int) { + dp := image.Point{X: (size - ico.Bounds().Dx()) / 2, Y: (size - ico.Bounds().Dy()) / 2} + dims := image.Point{X: size, Y: size} + rr := float32(size) * .5 + rrect(gtx.Ops, float32(size), float32(size), rr, rr, rr, rr) + paint.ColorOp{Color: col}.Add(gtx.Ops) + paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: float32(size), Y: float32(size)}}}.Add(gtx.Ops) + paint.ImageOp{Src: ico, Rect: ico.Bounds()}.Add(gtx.Ops) + paint.PaintOp{ + Rect: toRectF(ico.Bounds().Add(dp)), + }.Add(gtx.Ops) + gtx.Dimensions = layout.Dimensions{Size: dims} +} + +func toRectF(r image.Rectangle) f32.Rectangle { + return f32.Rectangle{ + Min: f32.Point{X: float32(r.Min.X), Y: float32(r.Min.Y)}, + Max: f32.Point{X: float32(r.Max.X), Y: float32(r.Max.Y)}, + } +} + +func drawInk(gtx *layout.Context, c widget.Click) { + d := gtx.Now().Sub(c.Time) + t := float32(d.Seconds()) + const duration = 0.5 + if t > duration { + return + } + t = t / duration + var stack op.StackOp + stack.Push(gtx.Ops) + size := float32(gtx.Px(unit.Dp(700))) * t + rr := size * .5 + col := byte(0xaa * (1 - t*t)) + ink := paint.ColorOp{Color: color.RGBA{A: col, R: col, G: col, B: col}} + ink.Add(gtx.Ops) + op.TransformOp{}.Offset(c.Position).Offset(f32.Point{ + X: -rr, + Y: -rr, + }).Add(gtx.Ops) + rrect(gtx.Ops, float32(size), float32(size), rr, rr, rr, rr) + paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: float32(size), Y: float32(size)}}}.Add(gtx.Ops) + stack.Pop() + op.InvalidateOp{}.Add(gtx.Ops) +} diff --git a/widget/material/editor.go b/widget/material/editor.go new file mode 100644 index 00000000..cac4af89 --- /dev/null +++ b/widget/material/editor.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package material + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" +) + +type Editor struct { + Font text.Font + // Color is the text color. + Color color.RGBA + // Hint contains the text displayed when the editor is empty. + Hint string + // HintColor is the color of hint text. + HintColor color.RGBA + + shaper *text.Shaper +} + +func (t *Theme) Editor(hint string) Editor { + return Editor{ + Font: text.Font{ + Size: unit.Sp(16), + }, + Color: t.Color.Text, + shaper: t.Shaper, + Hint: hint, + HintColor: t.Color.Hint, + } +} + +func (e Editor) Layout(gtx *layout.Context, editor *widget.Editor) { + var stack op.StackOp + stack.Push(gtx.Ops) + var macro op.MacroOp + macro.Record(gtx.Ops) + paint.ColorOp{Color: e.HintColor}.Add(gtx.Ops) + tl := widget.Label{Alignment: editor.Alignment} + tl.Layout(gtx, e.shaper, e.Font, e.Hint) + macro.Stop() + if w := gtx.Dimensions.Size.X; gtx.Constraints.Width.Min < w { + gtx.Constraints.Width.Min = w + } + if h := gtx.Dimensions.Size.Y; gtx.Constraints.Height.Min < h { + gtx.Constraints.Height.Min = h + } + editor.Layout(gtx, e.shaper, e.Font) + if editor.Len() > 0 { + paint.ColorOp{Color: e.Color}.Add(gtx.Ops) + editor.PaintText(gtx) + } else { + macro.Add(gtx.Ops) + } + paint.ColorOp{Color: e.Color}.Add(gtx.Ops) + editor.PaintCaret(gtx) + stack.Pop() +} diff --git a/widget/image.go b/widget/material/image.go similarity index 74% rename from widget/image.go rename to widget/material/image.go index 8fc7cb38..90d3a4dc 100644 --- a/widget/image.go +++ b/widget/material/image.go @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Unlicense OR MIT -// Package widget implements common widgets. -package widget +package material import ( "image" @@ -19,22 +18,22 @@ type Image struct { // Rect is the source rectangle. Rect image.Rectangle // Scale is the ratio of image pixels to - // device pixels. If zero, a scale that - // makes the image appear at approximately - // 72 DPI is used. + // dps. Scale float32 } +func (t *Theme) Image(img image.Image) Image { + return Image{ + Src: img, + Rect: img.Bounds(), + Scale: 160 / 72, // About 72 DPI. + } +} + func (im Image) Layout(gtx *layout.Context) { size := im.Src.Bounds() wf, hf := float32(size.Dx()), float32(size.Dy()) - var w, h int - if im.Scale == 0 { - const dpPrPx = 160 / 72 - w, h = gtx.Px(unit.Dp(wf*dpPrPx)), gtx.Px(unit.Dp(hf*dpPrPx)) - } else { - w, h = int(wf*im.Scale+.5), int(hf*im.Scale+.5) - } + w, h := gtx.Px(unit.Dp(wf*im.Scale)), gtx.Px(unit.Dp(hf*im.Scale)) cs := gtx.Constraints d := image.Point{X: cs.Width.Constrain(w), Y: cs.Height.Constrain(h)} aspect := float32(w) / float32(h) diff --git a/widget/material/label.go b/widget/material/label.go new file mode 100644 index 00000000..c4c6dca1 --- /dev/null +++ b/widget/material/label.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package material + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" +) + +type Label struct { + // Face defines the text style. + Font text.Font + // Color is the text color. + Color color.RGBA + // Alignment specify the text alignment. + Alignment text.Alignment + // MaxLines limits the number of lines. Zero means no limit. + MaxLines int + Text string + + shaper *text.Shaper +} + +func (t *Theme) H1(txt string) Label { + return t.Label(t.TextSize.Scale(96.0/16.0), txt) +} + +func (t *Theme) H2(txt string) Label { + return t.Label(t.TextSize.Scale(60.0/16.0), txt) +} + +func (t *Theme) H3(txt string) Label { + return t.Label(t.TextSize.Scale(48.0/16.0), txt) +} + +func (t *Theme) H4(txt string) Label { + return t.Label(t.TextSize.Scale(34.0/16.0), txt) +} + +func (t *Theme) H5(txt string) Label { + return t.Label(t.TextSize.Scale(24.0/16.0), txt) +} + +func (t *Theme) H6(txt string) Label { + return t.Label(t.TextSize.Scale(20.0/16.0), txt) +} + +func (t *Theme) Body1(txt string) Label { + return t.Label(t.TextSize, txt) +} + +func (t *Theme) Body2(txt string) Label { + return t.Label(t.TextSize.Scale(14.0/16.0), txt) +} + +func (t *Theme) Caption(txt string) Label { + return t.Label(t.TextSize.Scale(12.0/16.0), txt) +} + +func (t *Theme) Label(size unit.Value, txt string) Label { + return Label{ + Text: txt, + Color: t.Color.Text, + Font: text.Font{ + Size: size, + }, + shaper: t.Shaper, + } +} + +func (l Label) Layout(gtx *layout.Context) { + paint.ColorOp{Color: l.Color}.Add(gtx.Ops) + tl := widget.Label{Alignment: l.Alignment, MaxLines: l.MaxLines} + tl.Layout(gtx, l.shaper, l.Font, l.Text) +} diff --git a/widget/material/material.go b/widget/material/material.go new file mode 100644 index 00000000..10042b21 --- /dev/null +++ b/widget/material/material.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Package material implements the Material design. +package material + +import ( + "image" + "image/color" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" +) + +type Theme struct { + Shaper *text.Shaper + Color struct { + Primary color.RGBA + Text color.RGBA + Hint color.RGBA + } + TextSize unit.Value +} + +func NewTheme(shaper *text.Shaper) *Theme { + t := &Theme{ + Shaper: shaper, + } + t.Color.Primary = rgb(0x3f51b5) + t.Color.Text = rgb(0x000000) + t.Color.Hint = rgb(0xbbbbbb) + t.TextSize = unit.Sp(16) + return t +} + +func rgb(c uint32) color.RGBA { + return argb(0xff000000 | c) +} + +func argb(c uint32) color.RGBA { + return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} +} + +func fill(gtx *layout.Context, col color.RGBA) { + cs := gtx.Constraints + d := image.Point{X: cs.Width.Max, Y: cs.Height.Max} + dr := f32.Rectangle{ + Max: f32.Point{X: float32(d.X), Y: float32(d.Y)}, + } + paint.ColorOp{Color: col}.Add(gtx.Ops) + paint.PaintOp{Rect: dr}.Add(gtx.Ops) + gtx.Dimensions = layout.Dimensions{Size: d, Baseline: d.Y} +} + +// https://pomax.github.io/bezierinfo/#circles_cubic. +func rrect(ops *op.Ops, width, height, se, sw, nw, ne float32) { + w, h := float32(width), float32(height) + const c = 0.55228475 // 4*(sqrt(2)-1)/3 + var b paint.Path + b.Begin(ops) + b.Move(f32.Point{X: w, Y: h - se}) + b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE + b.Line(f32.Point{X: sw - w + se, Y: 0}) + b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW + b.Line(f32.Point{X: 0, Y: nw - h + sw}) + b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW + b.Line(f32.Point{X: w - ne - nw, Y: 0}) + b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE + b.End().Add(ops) +}