├── examples ├── material │ ├── two.txt │ ├── music.jpg │ ├── main_test.go │ └── material.go ├── resizers │ ├── gopher.jpg │ ├── main_test.go │ └── resizers.go ├── simple-demo │ ├── gopher.jpg │ ├── main_test.go │ └── simple-demo.go ├── Hello │ ├── hello.go │ └── main_test.go ├── grid │ ├── main_test.go │ └── grid.go ├── calculator │ ├── main_test.go │ └── calculator.go ├── Password │ ├── main_test.go │ └── password.go ├── buttons │ ├── main_test.go │ └── buttons.go ├── colors │ ├── main_test.go │ └── colors.go └── my-kitchen │ ├── main_test.go │ └── kitchen.go ├── demo.png ├── grid.png ├── hello.png ├── .gitattributes ├── .gitignore ├── go.mod ├── wid ├── separator.go ├── icon.go ├── image.go ├── progressbar.go ├── fit.go ├── values.go ├── col.go ├── label.go ├── resizer.go ├── dialog.go ├── shadow.go ├── switch.go ├── clickable.go ├── container.go ├── checkbox.go ├── base.go ├── tooltip.go ├── slider.go ├── rgba.go ├── animation.go ├── row.go ├── scrollbar.go ├── edit.go ├── options.go ├── dropdown.go ├── button.go └── theme.go ├── LICENSE ├── go.sum └── README.md /examples/material/two.txt: -------------------------------------------------------------------------------- 1 | 2 -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/demo.png -------------------------------------------------------------------------------- /grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/grid.png -------------------------------------------------------------------------------- /hello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/hello.png -------------------------------------------------------------------------------- /examples/material/music.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/examples/material/music.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Treat all files as binary, with no git magic updating 2 | # line endings. 3 | * -text 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | **/android/build 3 | *.exe 4 | profile*.* 5 | *.out 6 | .idea/ 7 | notes/ 8 | old/ -------------------------------------------------------------------------------- /examples/resizers/gopher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/examples/resizers/gopher.jpg -------------------------------------------------------------------------------- /examples/simple-demo/gopher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkvatne/gio-v/HEAD/examples/simple-demo/gopher.jpg -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jkvatne/gio-v 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | gioui.org v0.7.1 7 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 8 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 9 | ) 10 | 11 | require ( 12 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect 13 | gioui.org/shader v1.0.8 // indirect 14 | github.com/go-text/typesetting v0.1.1 // indirect 15 | golang.org/x/image v0.18.0 // indirect 16 | golang.org/x/sys v0.22.0 // indirect 17 | golang.org/x/text v0.16.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /examples/Hello/hello.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/jkvatne/gio-v/wid" 7 | 8 | "gioui.org/app" 9 | "gioui.org/font/gofont" 10 | "gioui.org/unit" 11 | ) 12 | 13 | var ( 14 | theme *wid.Theme 15 | form wid.Wid 16 | win app.Window // The main window 17 | ) 18 | 19 | func main() { 20 | theme = wid.NewTheme(gofont.Collection(), 14) 21 | form = hello(theme) 22 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(300), unit.Dp(100))) 23 | go wid.Run(&win, &form, theme) 24 | app.Main() 25 | } 26 | 27 | func hello(th *wid.Theme) wid.Wid { 28 | return wid.List(th, wid.Overlay, 29 | wid.Label(th, "Hello gio..", wid.Heading(), wid.Bold()), 30 | wid.Label(th, "A small demo program using 28 lines total"), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /examples/grid/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestGrid(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | onWinChange() 17 | gtx := layout.Context{ 18 | Ops: new(op.Ops), 19 | // Rigid constraints with both minimum and maximum set. 20 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 21 | } 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkGrid(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | onWinChange() 30 | for i := 0; i < b.N; i++ { 31 | gtx := layout.Context{ 32 | Ops: new(op.Ops), 33 | // Rigid constraints with both minimum and maximum set. 34 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 35 | } 36 | form(gtx) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/Hello/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestHello(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = hello(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkHello(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = hello(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/calculator/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gioui.org/op" 5 | "github.com/jkvatne/gio-v/wid" 6 | "image" 7 | "testing" 8 | 9 | "gioui.org/font/gofont" 10 | "gioui.org/layout" 11 | ) 12 | 13 | func TestButtons(t *testing.T) { 14 | theme = wid.NewTheme(gofont.Collection(), 14) 15 | gtx := layout.Context{ 16 | Ops: new(op.Ops), 17 | // Rigid constraints with both minimum and maximum set. 18 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 19 | } 20 | form = demo(theme) 21 | form(gtx) 22 | } 23 | 24 | func BenchmarkButtons(b *testing.B) { 25 | theme = wid.NewTheme(gofont.Collection(), 14) 26 | b.ResetTimer() 27 | b.ReportAllocs() 28 | 29 | form = demo(theme) 30 | for i := 0; i < b.N; i++ { 31 | gtx := layout.Context{ 32 | Ops: new(op.Ops), 33 | // Rigid constraints with both minimum and maximum set. 34 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 35 | } 36 | form(gtx) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/Password/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestHello(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = hello(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkHello(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = hello(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/buttons/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestButtons(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = demo(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkButtons(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = demo(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/colors/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestColors(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = demo2(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkColors(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = demo1(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/material/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestButtons(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = demo(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkButtons(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = demo(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/resizers/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestButtons(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = demo(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkButtons(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = demo(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/simple-demo/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestDemo(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = demo(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkDemo(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = demo(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/my-kitchen/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "testing" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | 11 | "gioui.org/font/gofont" 12 | ) 13 | 14 | func TestKitchen(t *testing.T) { 15 | theme = wid.NewTheme(gofont.Collection(), 14) 16 | gtx := layout.Context{ 17 | Ops: new(op.Ops), 18 | // Rigid constraints with both minimum and maximum set. 19 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 20 | } 21 | form = kitchen(theme) 22 | form(gtx) 23 | } 24 | 25 | func BenchmarkKitchen(b *testing.B) { 26 | theme = wid.NewTheme(gofont.Collection(), 14) 27 | b.ResetTimer() 28 | b.ReportAllocs() 29 | 30 | form = kitchen(theme) 31 | for i := 0; i < b.N; i++ { 32 | gtx := layout.Context{ 33 | Ops: new(op.Ops), 34 | // Rigid constraints with both minimum and maximum set. 35 | Constraints: layout.Exact(image.Point{X: 500, Y: 400}), 36 | } 37 | form(gtx) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/resizers/resizers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | 6 | "gioui.org/app" 7 | "gioui.org/font/gofont" 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | ) 11 | 12 | var ( 13 | theme *wid.Theme 14 | form layout.Widget 15 | win app.Window 16 | ) 17 | 18 | func main() { 19 | theme = wid.NewTheme(gofont.Collection(), 14) 20 | form = demo(theme) 21 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(900), unit.Dp(500))) 22 | wid.Run(&win, &form, theme) 23 | app.Main() 24 | } 25 | 26 | func demo(th *wid.Theme) layout.Widget { 27 | return wid.Col(nil, 28 | wid.Label(theme, "Resizer demo", wid.Middle(), wid.Heading(), wid.Bold(), wid.Role(wid.PrimaryContainer)), 29 | wid.SplitVertical(th, 0.5, 30 | wid.ImageFromJpgFile("gopher.jpg", wid.Contain), 31 | wid.SplitHorizontal(th, 0.5, 32 | wid.ImageFromJpgFile("gopher.jpg", wid.Contain), 33 | wid.ImageFromJpgFile("gopher.jpg", wid.Contain), 34 | ), 35 | ), 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /examples/Password/password.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "gioui.org/app" 7 | "gioui.org/font/gofont" 8 | "gioui.org/unit" 9 | "github.com/jkvatne/gio-v/wid" 10 | ) 11 | 12 | var ( 13 | theme *wid.Theme 14 | form wid.Wid 15 | UserName, Password string 16 | win app.Window // The main window 17 | ) 18 | 19 | func main() { 20 | theme = wid.NewTheme(gofont.Collection(), 14) 21 | form = hello(theme) 22 | win.Option() 23 | win.Option(app.Title("Gio-v password demo"), app.Size(unit.Dp(500), unit.Dp(170))) 24 | go wid.Run(&win, &form, theme) 25 | app.Main() 26 | } 27 | 28 | func onLogin() {} 29 | 30 | func onCancel() {} 31 | 32 | func hello(th *wid.Theme) wid.Wid { 33 | return wid.Col(wid.SpaceClose, 34 | wid.Label(th, "Enter user name and password", wid.Heading(), wid.Bold()), 35 | wid.Edit(th, &UserName, wid.Ls(0.2), wid.Lbl("User name")), 36 | wid.Edit(th, &Password, '*', wid.Ls(0.2), wid.Lbl("Password")), 37 | wid.Row(th, nil, []float32{25, 0, 0}, 38 | wid.Space(1), 39 | wid.Button(theme, "Log in", wid.Do(onLogin), wid.W(20)), 40 | wid.Button(theme, "Cancel", wid.Do(onCancel), wid.W(20)), 41 | ), 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /wid/separator.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | "gioui.org/unit" 11 | ) 12 | 13 | // SeparatorStyle defines material rendering parameters for separator 14 | type SeparatorStyle struct { 15 | Base 16 | thickness unit.Dp 17 | } 18 | 19 | // Space will create an open space, like separator but without any line drawn 20 | func Space(size unit.Dp) layout.Widget { 21 | s := SeparatorStyle{} 22 | s.thickness = size 23 | return func(gtx C) D { 24 | return layout.Dimensions{Size: image.Pt(gtx.Constraints.Min.X, Px(gtx, s.thickness))} 25 | } 26 | } 27 | 28 | // Separator creates a material separator widget 29 | func Separator(th *Theme, thickness unit.Dp, options ...Option) layout.Widget { 30 | s := &SeparatorStyle{ 31 | Base: Base{ 32 | th: th, 33 | role: Surface, 34 | }, 35 | thickness: thickness, 36 | } 37 | // Read in options to change from default values to something else. 38 | for _, option := range options { 39 | option.apply(s) 40 | } 41 | return s.Layout 42 | } 43 | 44 | func (s SeparatorStyle) Layout(gtx C) D { 45 | dim := gtx.Constraints.Min 46 | dim.Y = Px(gtx, s.thickness+s.padding.Top+s.padding.Bottom) 47 | x := dim.X - Px(gtx, s.padding.Left+s.padding.Right) 48 | y := Px(gtx, s.thickness) 49 | size := image.Pt(x, y) 50 | if w := Px(gtx, s.Base.width); w > size.X { 51 | size.X = w 52 | } 53 | defer op.Offset(image.Pt(Px(gtx, s.padding.Left), Px(gtx, s.padding.Top))).Push(gtx.Ops).Pop() 54 | defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() 55 | paint.ColorOp{Color: s.Fg()}.Add(gtx.Ops) 56 | paint.PaintOp{}.Add(gtx.Ops) 57 | return layout.Dimensions{Size: dim} 58 | } 59 | -------------------------------------------------------------------------------- /wid/icon.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | 10 | "gioui.org/op/clip" 11 | "gioui.org/op/paint" 12 | "golang.org/x/exp/shiny/iconvg" 13 | ) 14 | 15 | // Icon is the definition of an icon 16 | type Icon struct { 17 | src []byte 18 | // Cached values. 19 | op paint.ImageOp 20 | imgSize int 21 | imgColor color.NRGBA 22 | } 23 | 24 | // NewIcon returns a new Icon from IconVG data. 25 | func NewIcon(data []byte) (*Icon, error) { 26 | _, err := iconvg.DecodeMetadata(data) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &Icon{src: data}, nil 31 | } 32 | 33 | // Layout displays the icon with its size set to the X minimum constraint. 34 | func (ic *Icon) Layout(gtx C, color color.NRGBA) D { 35 | sz := gtx.Constraints.Min.X 36 | size := gtx.Constraints.Constrain(image.Pt(sz, sz)) 37 | defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() 38 | ico := ic.image(size.X, color) 39 | ico.Add(gtx.Ops) 40 | paint.PaintOp{}.Add(gtx.Ops) 41 | return D{Size: ico.Size()} 42 | } 43 | 44 | func (ic *Icon) Update(data []byte) error { 45 | _, err := iconvg.DecodeMetadata(data) 46 | if err != nil { 47 | return err 48 | } 49 | ic.src = data 50 | ic.imgSize = 0 51 | ic.imgColor = color.NRGBA{} 52 | return nil 53 | } 54 | 55 | func (ic *Icon) image(sz int, c color.NRGBA) paint.ImageOp { 56 | if sz < 1 { 57 | sz = 1 58 | } 59 | if sz == ic.imgSize && c == ic.imgColor { 60 | return ic.op 61 | } 62 | m, _ := iconvg.DecodeMetadata(ic.src) 63 | dx, dy := m.ViewBox.AspectRatio() 64 | img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}}) 65 | var ico iconvg.Rasterizer 66 | ico.SetDstImage(img, img.Bounds(), draw.Src) 67 | 68 | // palette uses pre-multiplied RGBA colors. Apply pre-multiplication here. 69 | r, g, b, a := c.RGBA() 70 | m.Palette[0] = color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} 71 | 72 | _ = iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{ 73 | Palette: &m.Palette, 74 | }) 75 | ic.op = paint.NewImageOp(img) 76 | ic.imgSize = sz 77 | ic.imgColor = c 78 | return ic.op 79 | } 80 | -------------------------------------------------------------------------------- /wid/image.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "fmt" 7 | "image" 8 | _ "image/jpeg" 9 | "os" 10 | 11 | "gioui.org/f32" 12 | "gioui.org/layout" 13 | "gioui.org/op" 14 | "gioui.org/op/clip" 15 | "gioui.org/op/paint" 16 | ) 17 | 18 | // ) 19 | 20 | // ImageDef is a widget that displays an image. 21 | type ImageDef struct { 22 | // Src is the image to display. 23 | Src paint.ImageOp 24 | // Fit specifies how to scale the image to the constraints. 25 | // By default, it does not do any scaling. 26 | Fit Fit 27 | // Position specifies where to position the image within 28 | // the constraints. 29 | Position layout.Direction 30 | // Scale is the ratio of image pixels to 31 | // dps. If Scale is zero Image falls back to 32 | // a scale that match a standard 72 DPI. 33 | Scale float32 34 | } 35 | 36 | func ImageFromJpgFile(filename string, fit Fit) layout.Widget { 37 | f, err := os.Open(filename) 38 | defer func() { 39 | err := f.Close() 40 | if err != nil { 41 | panic("Jpg file close failed") 42 | } 43 | }() 44 | if err != nil { 45 | panic(fmt.Sprintf("Image '%s' not found", filename)) 46 | } 47 | pict, _, err := image.Decode(f) 48 | if err != nil { 49 | panic(fmt.Sprintf("Image '%s' has unknown format", filename)) 50 | } 51 | return Image(pict, fit) 52 | } 53 | 54 | func Image(img image.Image, fit Fit) layout.Widget { 55 | src := paint.NewImageOp(img) 56 | im := ImageDef{} 57 | im.Fit = fit 58 | im.Src = src 59 | return func(gtx C) D { 60 | return im.Layout(gtx) 61 | } 62 | } 63 | 64 | func (im ImageDef) Layout(gtx C) D { 65 | scale := im.Scale 66 | if scale == 0 { 67 | scale = gtx.Metric.PxPerDp 68 | } 69 | 70 | w := int(float32(im.Src.Size().X) * scale) 71 | h := int(float32(im.Src.Size().Y) * scale) 72 | 73 | dims, trans := im.Fit.scale(gtx.Constraints, im.Position, layout.Dimensions{Size: image.Pt(w, h)}) 74 | defer clip.Rect{Max: dims.Size}.Push(gtx.Ops).Pop() 75 | 76 | trans = trans.Mul(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(scale, scale))) 77 | defer op.Affine(trans).Push(gtx.Ops).Pop() 78 | 79 | im.Src.Add(gtx.Ops) 80 | paint.PaintOp{}.Add(gtx.Ops) 81 | 82 | return dims 83 | } 84 | -------------------------------------------------------------------------------- /wid/progressbar.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/op" 7 | "image" 8 | 9 | "gioui.org/layout" 10 | "gioui.org/op/clip" 11 | "gioui.org/op/paint" 12 | "gioui.org/unit" 13 | ) 14 | 15 | // ProgressBarDef defines the progress bar 16 | type ProgressBarDef struct { 17 | Base 18 | Progress *float32 19 | Thickness unit.Dp 20 | } 21 | 22 | // ProgressBar returns a widget for a progress bar 23 | func ProgressBar(th *Theme, progress *float32, options ...Option) layout.Widget { 24 | p := &ProgressBarDef{ 25 | Base: Base{ 26 | th: th, 27 | cornerRadius: unit.Dp(10), 28 | role: SurfaceVariant, 29 | padding: layout.Inset{2, 2, 2, 2}, 30 | }, 31 | Progress: progress, 32 | Thickness: 20, 33 | } 34 | // Read in options to change from default values to something else. 35 | for _, option := range options { 36 | option.apply(p) 37 | } 38 | return p.Layout 39 | } 40 | 41 | func ScaleInset(gtx C, ins layout.Inset) (pt, pb, pl, pr int) { 42 | pt = gtx.Metric.Dp(ins.Top) 43 | pb = gtx.Metric.Dp(ins.Bottom) 44 | pl = gtx.Metric.Dp(ins.Left) 45 | pr = gtx.Metric.Dp(ins.Right) 46 | return 47 | } 48 | 49 | func (p *ProgressBarDef) Layout(gtx C) D { 50 | pt, pb, pl, pr := ScaleInset(gtx, p.padding) 51 | progressBarWidth := gtx.Constraints.Min.X - pl - pr 52 | GuiLock.RLock() 53 | value := *p.Progress 54 | GuiLock.RUnlock() 55 | width := int(float32(progressBarWidth) * Clamp(value, 0, 1)) 56 | thickness := Px(gtx, p.Thickness) 57 | color := p.Fg() 58 | gtx = gtx.Disabled() 59 | rr := Px(gtx, p.cornerRadius) 60 | if p.cornerRadius > (p.width-1)/2 { 61 | rr = Px(gtx, (p.width-1)/2) 62 | } 63 | dims := image.Point{X: progressBarWidth + pl + pr, Y: thickness + pt + pb} 64 | // Fill background if bgColor is given 65 | if p.bgColor != nil && (*p.bgColor).A != 0 { 66 | paint.FillShape(gtx.Ops, *p.bgColor, clip.UniformRRect(image.Rectangle{Max: dims}, 0).Op(gtx.Ops)) 67 | } 68 | defer op.Offset(image.Pt(pl, pt)).Push(gtx.Ops).Pop() 69 | defer clip.UniformRRect(image.Rectangle{Max: image.Point{X: width, Y: thickness}}, rr).Push(gtx.Ops).Pop() 70 | paint.ColorOp{Color: color}.Add(gtx.Ops) 71 | paint.PaintOp{}.Add(gtx.Ops) 72 | return D{Size: dims} 73 | } 74 | 75 | func (p *ProgressBarDef) setThickness(t unit.Dp) { 76 | p.Thickness = t 77 | } 78 | -------------------------------------------------------------------------------- /wid/fit.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image" 7 | 8 | "gioui.org/f32" 9 | "gioui.org/layout" 10 | ) 11 | 12 | // Fit scales a widget to fit and clip to the constraints. 13 | type Fit uint8 14 | 15 | const ( 16 | // Unscaled does not alter the scale of a widget. 17 | Unscaled Fit = iota 18 | // Contain scales widget as large as possible without cropping, 19 | // and it preserves aspect-ratio. 20 | Contain 21 | // Cover scales the widget to cover the constraint area and 22 | // preserves aspect-ratio. 23 | Cover 24 | // ScaleDown scales the widget smaller without cropping, 25 | // when it exceeds the constraint area. 26 | // It preserves aspect-ratio. 27 | ScaleDown 28 | // Fill stretches the widget to the constraints and does not 29 | // preserve aspect-ratio. 30 | Fill 31 | ) 32 | 33 | // scale computes the new dimensions and transformation required to fit dims to cs, given the position. 34 | func (fit Fit) scale(cs layout.Constraints, pos layout.Direction, dims layout.Dimensions) (layout.Dimensions, f32.Affine2D) { 35 | widgetSize := dims.Size 36 | 37 | if fit == Unscaled || dims.Size.X == 0 || dims.Size.Y == 0 { 38 | dims.Size = cs.Constrain(dims.Size) 39 | offset := pos.Position(widgetSize, dims.Size) 40 | dims.Baseline += offset.Y 41 | return dims, f32.Affine2D{}.Offset(layout.FPt(offset)) 42 | } 43 | if cs.Min.X > 0 { 44 | cs.Max.X = cs.Min.X 45 | } 46 | scale := f32.Point{ 47 | X: float32(cs.Max.X) / float32(dims.Size.X), 48 | Y: float32(cs.Max.Y) / float32(dims.Size.Y), 49 | } 50 | 51 | switch fit { 52 | case Contain: 53 | if scale.Y < scale.X { 54 | scale.X = scale.Y 55 | } else { 56 | scale.Y = scale.X 57 | } 58 | case Cover: 59 | if scale.Y > scale.X { 60 | scale.X = scale.Y 61 | } else { 62 | scale.Y = scale.X 63 | } 64 | case ScaleDown: 65 | if scale.Y < scale.X { 66 | scale.X = scale.Y 67 | } else { 68 | scale.Y = scale.X 69 | } 70 | 71 | // The widget would need to be scaled up, no change needed. 72 | if scale.X >= 1 { 73 | dims.Size = cs.Constrain(dims.Size) 74 | offset := pos.Position(widgetSize, dims.Size) 75 | dims.Baseline += offset.Y 76 | return dims, f32.Affine2D{}.Offset(layout.FPt(offset)) 77 | } 78 | default: 79 | } 80 | 81 | var scaledSize image.Point 82 | scaledSize.X = int(float32(widgetSize.X) * scale.X) 83 | scaledSize.Y = int(float32(widgetSize.Y) * scale.Y) 84 | dims.Size = cs.Constrain(scaledSize) 85 | dims.Baseline = int(float32(dims.Baseline) * scale.Y) 86 | 87 | offset := pos.Position(scaledSize, dims.Size) 88 | trans := f32.Affine2D{}. 89 | Scale(f32.Point{}, scale). 90 | Offset(layout.FPt(offset)) 91 | 92 | dims.Baseline += offset.Y 93 | 94 | return dims, trans 95 | } 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project is provided under the terms of the UNLICENSE or 2 | the MIT license denoted by the following SPDX identifier: 3 | 4 | SPDX-License-Identifier: Unlicense OR MIT 5 | 6 | You may use the project under the terms of either license. 7 | 8 | Both licenses are reproduced below. 9 | 10 | ---- 11 | The MIT License (MIT) 12 | 13 | Copyright (c) 2019 The Gio authors 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 31 | THE SOFTWARE. 32 | --- 33 | 34 | 35 | 36 | --- 37 | The UNLICENSE 38 | 39 | This is free and unencumbered software released into the public domain. 40 | 41 | Anyone is free to copy, modify, publish, use, compile, sell, or 42 | distribute this software, either in source code form or as a compiled 43 | binary, for any purpose, commercial or non-commercial, and by any 44 | means. 45 | 46 | In jurisdictions that recognize copyright laws, the author or authors 47 | of this software dedicate any and all copyright interest in the 48 | software to the public domain. We make this dedication for the benefit 49 | of the public at large and to the detriment of our heirs and 50 | successors. We intend this dedication to be an overt act of 51 | relinquishment in perpetuity of all present and future rights to this 52 | software under copyright law. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 55 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 56 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 57 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 58 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 59 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 60 | OTHER DEALINGS IN THE SOFTWARE. 61 | 62 | For more information, please refer to 63 | --- 64 | -------------------------------------------------------------------------------- /wid/values.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | ) 8 | 9 | // ValueToSTring converts a value to string. 10 | // Accepts both pointers to values and values. 11 | // If the value is numeric, it is converted to string. 12 | func ValueToString(v interface{}, dp int) string { 13 | if v == nil { 14 | return "nil" 15 | } else if x, ok := v.(*int); ok { 16 | if *x == math.MinInt { 17 | return "---" 18 | } else { 19 | return fmt.Sprintf("%d", *x) 20 | } 21 | } else if x, ok := v.(int); ok { 22 | if x == math.MinInt { 23 | return "---" 24 | } else { 25 | return fmt.Sprintf("%d", x) 26 | } 27 | } else if x, ok := v.(*int32); ok { 28 | if *x == math.MinInt32 { 29 | return "---" 30 | } else { 31 | return fmt.Sprintf("%d", *x) 32 | } 33 | } else if x, ok := v.(int32); ok { 34 | if x == math.MinInt32 { 35 | return "---" 36 | } else { 37 | return fmt.Sprintf("%d", x) 38 | } 39 | } else if x, ok := v.(*int64); ok { 40 | if *x == math.MinInt64 { 41 | return "---" 42 | } else { 43 | return fmt.Sprintf("%d", *x) 44 | } 45 | } else if x, ok := v.(int64); ok { 46 | if x == math.MinInt { 47 | return "---" 48 | } else { 49 | return fmt.Sprintf("%d", x) 50 | } 51 | } else if x, ok := v.(*float32); ok { 52 | if *x == math.MaxFloat32 { 53 | return "---" 54 | } else { 55 | return fmt.Sprintf("%.*f", dp, *x) 56 | } 57 | } else if x, ok := v.(*float64); ok { 58 | if *x == math.MaxFloat64 { 59 | return "---" 60 | } else { 61 | return fmt.Sprintf("%.*f", dp, *x) 62 | } 63 | } else if x, ok := v.(*string); ok { 64 | return *x 65 | } else if x, ok := v.(string); ok { 66 | return x 67 | } 68 | return "" 69 | } 70 | 71 | // StringToValue will convert a string to a numeric value 72 | // and store it at the address supplied. Will also accept string pointers 73 | func StringToValue(p interface{}, current string) { 74 | if _, ok := p.(*int); ok { 75 | x, err := strconv.Atoi(current) 76 | if err == nil { 77 | *p.(*int) = x 78 | } 79 | } else if _, ok := p.(*int32); ok { 80 | x, err := strconv.Atoi(current) 81 | if err == nil { 82 | *p.(*int) = x 83 | } 84 | } else if _, ok := p.(*int64); ok { 85 | x, err := strconv.ParseInt(current, 10, 64) 86 | if err == nil { 87 | *p.(*int64) = x 88 | } 89 | } else if _, ok := p.(*float32); ok { 90 | f, err := strconv.ParseFloat(current, 32) 91 | if err == nil { 92 | *p.(*float32) = float32(f) 93 | } 94 | } else if _, ok := p.(*float64); ok { 95 | f, err := strconv.ParseFloat(current, 64) 96 | if err == nil { 97 | *p.(*float64) = f 98 | } 99 | } else if _, ok := p.(*string); ok { 100 | *p.(*string) = current 101 | } else { 102 | panic("Edit value should be pointer to value") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /wid/col.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "image" 9 | "math" 10 | ) 11 | 12 | // Col makes a column of widgets. It is not scrollable, but 13 | // weights are used to split the available area. 14 | // Set weight to 0 for fixed height widgets, and 1 for flexible widgets (like lists) 15 | func Col(weights []float32, widgets ...Wid) Wid { 16 | // Interpret the constant SpaceDistribute as many 1.0 weights 17 | if len(weights) == 1 && weights[0] == 1.0 { 18 | weights = make([]float32, len(widgets)) 19 | for i := 0; i < len(widgets); i++ { 20 | weights[i] = 1.0 21 | } 22 | } 23 | // Make weigths[] as long as widgets[] 24 | for len(weights) < len(widgets) { 25 | weights = append(weights, 0.0) 26 | } 27 | return func(gtx C) D { 28 | size := 0 29 | cgtx := gtx 30 | cgtx.Constraints.Min.Y = 0 31 | calls := make([]op.CallOp, len(widgets)) 32 | dims := make([]D, len(widgets)) 33 | minY := gtx.Constraints.Min.Y 34 | // Lay out Rigid children. (with weight==0.0) 35 | var totalWeight float32 36 | remaining := gtx.Constraints.Max.Y 37 | cgtx.Constraints.Min.Y = 0 38 | for i, child := range widgets { 39 | totalWeight += weights[i] 40 | if weights[i] == 0 { 41 | macro := op.Record(gtx.Ops) 42 | cgtx.Constraints.Max.Y = remaining 43 | dims[i] = child(cgtx) 44 | calls[i] = macro.Stop() 45 | size += dims[i].Size.Y 46 | remaining = Max(0, remaining-dims[i].Size.Y) 47 | } 48 | } 49 | // fraction is the rounding error from a Flex weighting. 50 | var fraction float32 51 | flexTotal := remaining 52 | // Lay out Flexed children (with weight>0) 53 | for i, child := range widgets { 54 | // Note that flexed children are dropt if there are no remaining spaace 55 | if weights[i] > 0 && remaining > 0 { 56 | childSize := float32(flexTotal) * weights[i] / totalWeight 57 | flexSize := int(childSize + fraction + .5) 58 | fraction = childSize - float32(flexSize) 59 | flexSize = Min(flexSize, remaining) 60 | macro := op.Record(gtx.Ops) 61 | cgtx.Constraints = layout.Constraints{ 62 | Min: image.Pt(gtx.Constraints.Min.X, 0), 63 | Max: image.Pt(gtx.Constraints.Max.X, flexSize)} 64 | // Layout flex rows 65 | dims[i] = child(cgtx) 66 | calls[i] = macro.Stop() 67 | size += dims[i].Size.Y 68 | remaining = Max(0, remaining-dims[i].Size.Y) 69 | } 70 | } 71 | space := Max(0, minY-size) 72 | maxX := gtx.Constraints.Min.X 73 | for i := range widgets { 74 | if c := dims[i].Size.X; c > maxX { 75 | maxX = c 76 | } 77 | } 78 | var y float32 79 | // Now do the actual drawing, with offsets 80 | for i := range widgets { 81 | trans := op.Offset(image.Pt(0, int(math.Round(float64(y))))).Push(gtx.Ops) 82 | calls[i].Add(gtx.Ops) 83 | trans.Pop() 84 | if weights[i] == 0 { 85 | y += float32(dims[i].Size.Y) 86 | } else { 87 | y += float32(dims[i].Size.Y) + float32(space)/float32(len(widgets)) 88 | if y >= float32(gtx.Constraints.Max.Y) { 89 | break 90 | } 91 | } 92 | } 93 | sz := gtx.Constraints.Constrain(image.Pt(maxX, int(y))) 94 | return D{Size: sz, Baseline: sz.Y} 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= 2 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= 3 | gioui.org v0.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI= 4 | gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng= 5 | gioui.org v0.7.1 h1:l7OVj47n1z8acaszQ6Wlu+Rxme+HqF3q8b+Fs68+x3w= 6 | gioui.org v0.7.1/go.mod h1:5Kw/q7R1BWc5MKStuTNvhCgSrRqbfHc9Dzfjs4IGgZo= 7 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 8 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= 9 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 10 | gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= 11 | gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 12 | github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo= 13 | github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI= 14 | github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY= 15 | github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 16 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= 17 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 18 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= 19 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 20 | golang.org/x/exp/shiny v0.0.0-20240409090435-93d18d7e34b8 h1:hYHwiFyEh3Z8hNGBbrVTT3FjsHiJ0ZqjHRH8sQgopUc= 21 | golang.org/x/exp/shiny v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= 22 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= 23 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= 24 | golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= 25 | golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 26 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= 27 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= 28 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 29 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 30 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 31 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 32 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 33 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 34 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 35 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 36 | -------------------------------------------------------------------------------- /wid/label.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/font" 7 | "gioui.org/layout" 8 | "gioui.org/op" 9 | "gioui.org/op/clip" 10 | "gioui.org/op/paint" 11 | "gioui.org/text" 12 | "gioui.org/unit" 13 | "gioui.org/widget" 14 | "image" 15 | ) 16 | 17 | // LabelDef is the setup for a label. 18 | type LabelDef struct { 19 | Base 20 | // Face defines the text style. 21 | Font font.Font 22 | // Alignment specify the text alignment. 23 | Alignment text.Alignment 24 | // MaxLines limits the number of lines. Zero means no limit. 25 | MaxLines int 26 | value interface{} 27 | } 28 | 29 | // LabelOption is options specific to Edits. 30 | type LabelOption func(w *LabelDef) 31 | 32 | // Bold is an option parameter to set the widget hint (tooltip). 33 | func Bold() LabelOption { 34 | return func(d *LabelDef) { 35 | d.Font.Weight = font.Bold 36 | } 37 | } 38 | 39 | // Weight sets the font weight. 40 | func Weight(weight font.Weight) LabelOption { 41 | return func(d *LabelDef) { 42 | d.Font.Weight = weight 43 | } 44 | } 45 | 46 | func (e LabelOption) apply(cfg interface{}) { 47 | e(cfg.(*LabelDef)) 48 | } 49 | 50 | // Label returns a widget for a value of any type 51 | func Label[V Value](th *Theme, v V, options ...Option) layout.Widget { 52 | w := LabelDef{ 53 | Base: Base{ 54 | th: th, 55 | role: Surface, 56 | padding: th.DefaultPadding, 57 | margin: layout.Inset{Top: -1, Bottom: -1, Left: -1, Right: -1}, 58 | FontScale: 1.0, 59 | Alignment: text.Start, 60 | }, 61 | Font: th.DefaultFont, 62 | MaxLines: 0, 63 | value: v, 64 | } 65 | // Apply options after initialization of LabelDef 66 | for _, option := range options { 67 | option.apply(&w) 68 | } 69 | if w.margin.Top != -1 { 70 | panic("Label does not use margin") 71 | } 72 | return w.Layout 73 | } 74 | 75 | func (w *LabelDef) Layout(gtx C) D { 76 | pt, pb, pl, pr := ScaleInset(gtx, w.padding) 77 | c := gtx 78 | if w.MaxLines == 1 { 79 | // This is a hack to avoid splitting the line when only one line is allowed 80 | c.Constraints.Max.X = inf 81 | } 82 | GuiLock.RLock() 83 | var str string 84 | if w.DpNo != nil { 85 | str = ValueToString(w.value, *w.DpNo) 86 | } else { 87 | str = ValueToString(w.value, 0) 88 | } 89 | GuiLock.RUnlock() 90 | defer op.Offset(image.Pt(pl, pt)).Push(gtx.Ops).Pop() 91 | tl := widget.Label{Alignment: w.Alignment, MaxLines: w.MaxLines} 92 | c.Constraints.Min.X = Max(c.Constraints.Min.X-pl-pr, 0) 93 | c.Constraints.Max.X -= pl + pr 94 | c.Constraints.Min.Y = Max(0, c.Constraints.Min.Y-pt-pb) 95 | // Fill background if bgColor is given 96 | if w.bgColor != nil && (*w.bgColor).A != 0 { 97 | paint.FillShape(gtx.Ops, *w.bgColor, clip.UniformRRect(image.Rectangle{Max: c.Constraints.Max}, 0).Op(gtx.Ops)) 98 | } 99 | // Macro for the text drawing color 100 | colMacro := op.Record(gtx.Ops) 101 | paint.ColorOp{Color: w.Fg()}.Add(gtx.Ops) 102 | // Then lay out the text 103 | dims := tl.Layout(c, w.th.Shaper, w.Font, unit.Sp(w.FontScale)*w.th.TextSize, str, colMacro.Stop()) 104 | dims.Size.X += pl + pr 105 | dims.Size.Y += pb + pt 106 | return dims 107 | } 108 | -------------------------------------------------------------------------------- /wid/resizer.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/gesture" 7 | "gioui.org/io/pointer" 8 | "gioui.org/layout" 9 | "gioui.org/op/clip" 10 | "gioui.org/op/paint" 11 | ) 12 | 13 | // Resize provides a draggable handle in between two widgets for resizing their area. 14 | type Resize struct { 15 | // ratio defines how much space is available to the first widget. 16 | axis layout.Axis 17 | th *Theme 18 | ratio float32 19 | Length float32 20 | drag gesture.Drag 21 | pos float32 22 | start float32 23 | } 24 | 25 | // SplitHorizontal is used to layout two widgets with a vertical splitter between. 26 | func SplitHorizontal(th *Theme, ratio float32, w1 layout.Widget, w2 layout.Widget) layout.Widget { 27 | rs := Resize{th: th, ratio: ratio, axis: layout.Horizontal} 28 | return func(gtx C) D { 29 | return rs.Layout(gtx, w1, w2) 30 | } 31 | } 32 | 33 | // SplitVertical is used to layout two widgets with a vertical splitter between. 34 | func SplitVertical(th *Theme, ratio float32, w1 layout.Widget, w2 layout.Widget) layout.Widget { 35 | rs := Resize{th: th, ratio: ratio, axis: layout.Vertical} 36 | return func(gtx C) D { 37 | return rs.Layout(gtx, w1, w2) 38 | } 39 | } 40 | 41 | func (rs *Resize) get(r image.Point) float32 { 42 | if rs.axis == layout.Horizontal { 43 | return float32(r.X) 44 | } 45 | return float32(r.Y) 46 | } 47 | 48 | // Layout displays w1 and w2 with handle in between. 49 | func (rs *Resize) Layout(gtx C, w1 layout.Widget, w2 layout.Widget) D { 50 | maxPos := rs.get(gtx.Constraints.Max) 51 | if rs.pos != 0 { 52 | rs.ratio = rs.pos / maxPos 53 | } 54 | // Clamp the handle position, leaving it always visible. 55 | rs.ratio = Clamp(rs.ratio, 0, 1) 56 | rs.pos = rs.ratio * maxPos 57 | f := layout.Flex{ 58 | Axis: rs.axis, 59 | }.Layout(gtx, 60 | layout.Flexed(rs.ratio, w1), 61 | layout.Rigid(func(gtx C) D { 62 | return D{Size: rs.drawSash(gtx)} 63 | }), 64 | layout.Flexed(1-rs.ratio, w2), 65 | ) 66 | // Handle drag events 67 | for { 68 | e, ok := rs.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(rs.axis)) 69 | if !ok { 70 | break 71 | } 72 | p := e.Position.X 73 | if rs.axis == layout.Vertical { 74 | p = e.Position.Y 75 | } 76 | if e.Kind == pointer.Press { 77 | rs.start = p - rs.ratio*maxPos 78 | } else { 79 | rs.pos = p - rs.start 80 | } 81 | } 82 | // Add drag gesture capture 83 | d := Px(gtx, rs.th.SashWidth)/2 + 1 84 | p := int(rs.get(f.Size) * rs.ratio) 85 | if rs.axis == layout.Vertical { 86 | defer clip.Rect(image.Rect(0, p-d, f.Size.X, p+d)).Push(gtx.Ops).Pop() 87 | } else { 88 | defer clip.Rect(image.Rect(p-d, 0, p+d, f.Size.X)).Push(gtx.Ops).Pop() 89 | } 90 | rs.drag.Add(gtx.Ops) 91 | // Setup cursor for sash 92 | if rs.axis == layout.Horizontal { 93 | pointer.CursorColResize.Add(gtx.Ops) 94 | } else { 95 | pointer.CursorRowResize.Add(gtx.Ops) 96 | } 97 | return f 98 | } 99 | 100 | func (rs *Resize) drawSash(gtx C) image.Point { 101 | dims := gtx.Constraints.Max 102 | if rs.axis == layout.Horizontal { 103 | dims.X = Px(gtx, rs.th.SashWidth) 104 | } else { 105 | dims.Y = Px(gtx, rs.th.SashWidth) 106 | } 107 | defer clip.Rect{Max: dims}.Push(gtx.Ops).Pop() 108 | paint.ColorOp{Color: rs.th.SashColor}.Add(gtx.Ops) 109 | paint.PaintOp{}.Add(gtx.Ops) 110 | return dims 111 | } 112 | -------------------------------------------------------------------------------- /wid/dialog.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/op" 6 | "gioui.org/op/clip" 7 | "gioui.org/op/paint" 8 | "image" 9 | "math" 10 | "time" 11 | ) 12 | 13 | var ( 14 | dialog layout.Widget 15 | dialogStartTime time.Time 16 | startX int 17 | startY int 18 | ) 19 | 20 | func Show(d layout.Widget) { 21 | dialog = d 22 | dialogStartTime = time.Now() 23 | startX = int(mouseX) 24 | startY = int(mouseY) 25 | } 26 | 27 | func Hide() { 28 | dialog = nil 29 | dialogStartTime = time.Time{} 30 | } 31 | 32 | func ConfirmDialog(th *Theme, heading string, text string, lbl1 string, on1 func()) layout.Widget { 33 | return Container(th, TransparentSurface, 0, FlexInset, NoInset, 34 | Dialog(th, PrimaryContainer, 35 | Label(th, heading, Heading(), Middle()), 36 | Label(th, text, Middle()), 37 | Separator(th, 0, Pads(10)), 38 | Row(th, nil, SpaceRightAdjust, 39 | TextButton(th, lbl1, Do(on1)), 40 | ), 41 | ), 42 | ) 43 | } 44 | 45 | func YesNoDialog(th *Theme, heading string, text string, lbl1, lbl2 string, on1, on2 func()) layout.Widget { 46 | return Dialog(th, PrimaryContainer, 47 | Label(th, heading, Heading(), Middle()), 48 | Label(th, text, Middle()), 49 | Separator(th, 0, Pads(10)), 50 | Row(th, nil, SpaceRightAdjust, 51 | TextButton(th, lbl1, Do(on1)), 52 | TextButton(th, lbl2, Do(on2)), 53 | ), 54 | ) 55 | } 56 | 57 | func Dialog(th *Theme, role UIRole, widgets ...Wid) Wid { 58 | return func(gtx C) D { 59 | pt := Px(gtx, th.DialogPadding.Top) 60 | pb := Px(gtx, th.DialogPadding.Bottom) 61 | pl := Px(gtx, th.DialogPadding.Left) 62 | pr := Px(gtx, th.DialogPadding.Right) 63 | f := Min(1.0, float64(time.Since(dialogStartTime))/float64(time.Second/4)) 64 | // Shade underlying form 65 | // Draw surface all over the underlying form with the transparent surface color 66 | outline := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y) 67 | defer clip.Rect(outline).Push(gtx.Ops).Pop() 68 | paint.Fill(gtx.Ops, WithAlpha(Black, uint8(f*200))) 69 | // Calculate dialog constraints 70 | ctx := gtx 71 | ctx.Constraints.Min.Y = 0 72 | // Margins left and right for a constant maximum dialog size 73 | margin := Max(12, (ctx.Constraints.Max.X - pl - pr - Px(gtx, th.DialogTextWidth))) 74 | ctx.Constraints.Max.X = gtx.Constraints.Max.X - margin - pl - pr 75 | calls := make([]op.CallOp, len(widgets)) 76 | dims := make([]D, len(widgets)) 77 | size := 0 78 | for i, child := range widgets { 79 | macro := op.Record(gtx.Ops) 80 | dims[i] = child(ctx) 81 | calls[i] = macro.Stop() 82 | size += dims[i].Size.Y 83 | } 84 | mt := (gtx.Constraints.Max.Y - size) / 2 85 | // Calculate posision and size of the dialog box 86 | x := int(f*float64(margin/2) + (1-f)*float64(startX)) 87 | y := int(f*float64(mt) + (1-f)*float64(startY)) 88 | outline = image.Rect(0, 0, int(f*float64(gtx.Constraints.Max.X-margin)), int(f*float64(size+pt+pb))) 89 | // Draw the dialog surface with caclculated margins 90 | defer op.Offset(image.Pt(x, y)).Push(gtx.Ops).Pop() 91 | defer clip.UniformRRect(outline, Px(gtx, th.DialogCorners)).Push(gtx.Ops).Pop() 92 | paint.Fill(gtx.Ops, th.Bg[role]) 93 | if f < 1.0 { 94 | // While animating, no widgets are drawn, but we invalidate to force a new redraw 95 | Invalidate() 96 | } else { 97 | // Now do the actual drawing of the widgets, with offsets 98 | y := pt 99 | for i := range widgets { 100 | trans := op.Offset(image.Pt(pl, int(math.Round(float64(y))))).Push(gtx.Ops) 101 | calls[i].Add(gtx.Ops) 102 | trans.Pop() 103 | y += dims[i].Size.Y 104 | } 105 | } 106 | sz := gtx.Constraints.Constrain(image.Pt(gtx.Constraints.Max.X, size+pb+pt)) 107 | return D{Size: sz, Baseline: sz.Y} 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gio-v demo 2 | 3 | This is a small demonstration of the package, with a scrollable grid and a few other widgets. 4 | 5 | ![Demo.go](https://github.com/jkvatne/gio-v/blob/main/grid.png) 6 | 7 | # Extension of Gio 8 | 9 | See [gioui.org](https://gioui.org). 10 | 11 | This is a set of widgets made for my own use. They replace (or complements) gioui.org/widget and 12 | gioui.org/widget/material but the rest of the gio code is imported without modifications. 13 | 14 | The code is copied extensively from the following sources: 15 | 16 | * https://github.com/gioui/gio 17 | * https://github.com/gioui/gio-x.git 18 | * https://github.com/gioui/gio-example.git 19 | 20 | THIS IS WORK IN PROGRESS - ANYTHING CAN CHANGE AT ANY TIME 21 | 22 | Now updated to Gio V0.4.1 23 | # Features 24 | 25 | ## Material design 26 | 27 | The design follows closely Google Material 3, where a few primary colors are used to generate all the other 28 | colors. Most other design elements can be tuned by modifying the default theme. 29 | 30 | ## Keyboard only operation 31 | 32 | All widgets handle keyboard only operation. Focus is moved py TAB/SHIFT-TAB keys using standard gio 33 | 34 | ## Extended theming 35 | 36 | The theme is very much extended, with default values for all colors and sizes. You can set up several themes for 37 | different types of buttons etc, and use the themes when declaring the widgets. 38 | 39 | Dark and Light mode are both supported, and can be easily selected from a widget. 40 | 41 | ## Dynamic resizing 42 | 43 | Everything scales with the text size, and the text size can be set automatically as a fraction of the window size. This 44 | makes it easy to write programs that are maximized to fill the screen, or are operating mostly in full-screen mode 45 | 46 | ## Importing gio directly 47 | 48 | The gio module itself does not need to be modified. The excellent work by Elias Naur and Chris Waldon is used without 49 | modifications. My widgets are only a high-level extensions, replacing the material widgets in gio. 50 | 51 | ## Widget configuration by optional arguments 52 | 53 | All widget functions can have any number of options, as ```func(options ...Option)``` . See example below 54 | 55 | ## Easy setup of forms 56 | 57 | Here is an example from /examples/hello. The widgets can take a variable number of options for things like width and 58 | hints. 59 | Otherwise, default fallbacks are used. The defaults are mostly defined in the theme. 60 | 61 | ``` 62 | package main 63 | 64 | import ( 65 | "gio-v/wid" 66 | 67 | "gioui.org/app" 68 | "gioui.org/font/gofont" 69 | "gioui.org/layout" 70 | "gioui.org/unit" 71 | ) 72 | 73 | func main() { 74 | go wid.Run( 75 | app.NewWindow(app.Title("Hello Gio demo"), app.Size(unit.Dp(900), unit.Dp(500))), 76 | wid.NewTheme(gofont.Collection(), 14), 77 | hello, 78 | ) 79 | app.Main() 80 | } 81 | 82 | func hello(th *wid.Theme) layout.Widget { 83 | return wid.List(th, wid.Overlay, 84 | wid.Label(th, "Hello gio..", wid.Middle(), wid.Heading(), wid.Bold()), 85 | wid.Label(th, "A small demo program using 25 lines toal"), 86 | ) 87 | } 88 | ``` 89 | 90 | ![hello.go](https://github.com/jkvatne/gio-v/blob/main/hello.png) 91 | 92 | # Immediate mode? 93 | 94 | This implementation does not follow the gio recommendations fully. The widgets are fully persistent, and callbacks and 95 | pointers are 96 | used extensively. This is done to make it much more user-friendly, and it is primarily intended for 97 | desktop applications, where resources are plentiful. 98 | 99 | Switches and edits modify the corresponding variables directly, via pointers. When the variable is 100 | modified, the corresponding widget is immediately updated without any action from the program. 101 | This is typically done from another go-routine. 102 | 103 | Note that the program is not yet protected from race conditions. 104 | The plan is to include a global lock. 105 | 106 | # License 107 | 108 | Dual MIT/Unlicense; same as Gio 109 | 110 | # Demo 111 | 112 | ![Demo.go](https://github.com/jkvatne/gio-v/blob/main/demo.png) 113 | -------------------------------------------------------------------------------- /wid/shadow.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image" 7 | "image/color" 8 | "math" 9 | 10 | "gioui.org/f32" 11 | "gioui.org/op" 12 | 13 | "gioui.org/op/clip" 14 | "gioui.org/op/paint" 15 | ) 16 | 17 | // ShadowStyle defines a shadow cast by a rounded rectangle. 18 | type ShadowStyle struct { 19 | CornerRadius int 20 | Elevation int 21 | } 22 | 23 | var alpha = [7]byte{0, 82, 62, 42, 32, 14, 13} 24 | 25 | func DrawShadow(gtx C, outline image.Rectangle, rr int, elevation int) { 26 | for i := 6; i > 0; i-- { 27 | ofs := elevation * i / 10 28 | rr := rr + ofs/2 29 | a := alpha[i] 30 | paint.FillShape(gtx.Ops, color.NRGBA{A: a}, RrOp(clip.UniformRRect(outline, rr), ofs, gtx.Ops)) 31 | } 32 | } 33 | 34 | // RrOp returns the op for the rounded rectangle. 35 | func RrOp(rr clip.RRect, d int, ops *op.Ops) clip.Op { 36 | return clip.Outline{Path: ShadowPath(rr, d, ops)}.Op() 37 | } 38 | 39 | type Rectangle struct { 40 | Min, Max f32.Point 41 | } 42 | 43 | func FPt(p image.Point) f32.Point { 44 | return f32.Point{ 45 | X: float32(p.X), Y: float32(p.Y), 46 | } 47 | } 48 | 49 | func FRect(r image.Rectangle) Rectangle { 50 | return Rectangle{ 51 | Min: FPt(r.Min), Max: FPt(r.Max), 52 | } 53 | } 54 | 55 | // ShadowPath returns the PathSpec for the shadow 56 | // This is a border around a rounded rectangle with width d 57 | func ShadowPath(rr clip.RRect, d int, ops *op.Ops) clip.PathSpec { 58 | var p clip.Path 59 | p.Begin(ops) 60 | const iq = 1 - 4*(math.Sqrt2-1)/3 61 | se, sw, nw, ne := float32(rr.SE), float32(rr.SW), float32(rr.NW), float32(rr.NE) 62 | rrf := FRect(rr.Rect) 63 | w, n, e, s := rrf.Min.X, rrf.Min.Y, rrf.Max.X, rrf.Max.Y 64 | 65 | p.MoveTo(f32.Point{X: w + nw, Y: n}) 66 | p.LineTo(f32.Point{X: e - ne, Y: n}) // N 67 | p.CubeTo( // NE 68 | f32.Point{X: e - ne*iq, Y: n}, 69 | f32.Point{X: e, Y: n + ne*iq}, 70 | f32.Point{X: e, Y: n + ne}) 71 | p.LineTo(f32.Point{X: e, Y: s - se}) // E 72 | p.CubeTo( // SE 73 | f32.Point{X: e, Y: s - se*iq}, 74 | f32.Point{X: e - se*iq, Y: s}, 75 | f32.Point{X: e - se, Y: s}) 76 | p.LineTo(f32.Point{X: w + sw, Y: s}) // S 77 | p.CubeTo( // SW 78 | f32.Point{X: w + sw*iq, Y: s}, 79 | f32.Point{X: w, Y: s - sw*iq}, 80 | f32.Point{X: w, Y: s - sw}) 81 | p.LineTo(f32.Point{X: w, Y: n + nw}) // W 82 | p.CubeTo( // NW 83 | f32.Point{X: w, Y: n + nw*iq}, 84 | f32.Point{X: w + nw*iq, Y: n}, 85 | f32.Point{X: w + nw, Y: n}) 86 | 87 | df := float32(d) 88 | se += df 89 | sw += df 90 | nw += df 91 | ne += df 92 | w -= df 93 | n -= df 94 | e += df 95 | s += df 96 | 97 | p.LineTo(f32.Point{X: w + nw, Y: n}) // Start W 98 | p.CubeTo( // NW 99 | f32.Point{X: w + nw*iq, Y: n}, 100 | f32.Point{X: w, Y: n + nw*iq}, 101 | f32.Point{X: w, Y: n + nw}) 102 | p.LineTo(f32.Point{X: w, Y: s - sw}) // W 103 | p.CubeTo( // SW 104 | f32.Point{X: w, Y: s - sw*iq}, 105 | f32.Point{X: w + sw*iq, Y: s}, 106 | f32.Point{X: w + sw, Y: s}) 107 | p.LineTo(f32.Point{X: e - sw, Y: s}) // S 108 | p.CubeTo( // SE 109 | f32.Point{X: e - se*iq, Y: s}, 110 | f32.Point{X: e, Y: s - se*iq}, 111 | f32.Point{X: e, Y: s - se}) 112 | p.LineTo(f32.Point{X: e, Y: n + ne}) // E 113 | p.CubeTo( // NE 114 | f32.Point{X: e, Y: n + ne*iq}, 115 | f32.Point{X: e - ne*iq, Y: n}, 116 | f32.Point{X: e - ne, Y: n}) 117 | p.LineTo(f32.Point{X: nw, Y: n}) // N 118 | p.LineTo(f32.Point{X: w + nw, Y: n}) // To start 119 | 120 | return p.End() 121 | } 122 | 123 | // Layout renders the shadow into the gtx. The shadow's size will assume 124 | // that the rectangle casting the shadow is of size gtx.Constraints.Min. 125 | func (s ShadowStyle) Layout(gtx C) D { 126 | sz := gtx.Constraints.Min 127 | for i := 6; i > 0; i-- { 128 | ofs := s.Elevation * i / 10 129 | rr := s.CornerRadius + ofs/2 130 | a := alpha[i] 131 | paint.FillShape(gtx.Ops, color.NRGBA{A: a}, clip.RRect{ 132 | Rect: image.Rect(-ofs/2, -ofs/4, sz.X+ofs/2, sz.Y+ofs), 133 | SE: rr, SW: rr, NW: rr, NE: rr, 134 | }.Op(gtx.Ops)) 135 | } 136 | return D{Size: sz} 137 | } 138 | -------------------------------------------------------------------------------- /wid/switch.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image" 7 | "image/color" 8 | 9 | "gioui.org/op" 10 | 11 | "gioui.org/widget" 12 | 13 | "gioui.org/layout" 14 | "gioui.org/op/clip" 15 | "gioui.org/op/paint" 16 | "gioui.org/unit" 17 | ) 18 | 19 | // SwitchDef is the parameters for a slider 20 | type SwitchDef struct { 21 | Base 22 | sw widget.Bool 23 | StatePtr *bool 24 | trackColorOn color.NRGBA 25 | trackColorOff color.NRGBA 26 | trackOutline color.NRGBA 27 | thumbColorOn color.NRGBA 28 | thumbColorOff color.NRGBA 29 | hoverShadow color.NRGBA 30 | trackWidth unit.Dp 31 | trackStroke unit.Dp 32 | trackLength unit.Dp 33 | btnOnSize unit.Dp 34 | btnOffSize unit.Dp 35 | } 36 | 37 | // DefaultSwitchDef returs a Switchdef with values from the theme 38 | func DefaultSwitchDef(th *Theme) *SwitchDef { 39 | return &SwitchDef{ 40 | Base: Base{ 41 | th: th, 42 | margin: th.DefaultMargin, 43 | }, 44 | trackWidth: unit.Dp(th.TextSize) * 1.5, 45 | trackLength: unit.Dp(th.TextSize) * 2.4, 46 | btnOnSize: unit.Dp(th.TextSize) * 1.15, 47 | btnOffSize: unit.Dp(th.TextSize) * 0.8, 48 | trackColorOn: th.Bg[Primary], 49 | trackColorOff: th.Bg[SurfaceVariant], 50 | trackOutline: th.Fg[Outline], 51 | thumbColorOn: th.Fg[Primary], 52 | thumbColorOff: th.Fg[Outline], 53 | trackStroke: th.BorderThickness, 54 | } 55 | } 56 | 57 | // Switch returns a widget for a switch 58 | func Switch(th *Theme, statePtr *bool, options ...Option) layout.Widget { 59 | // The pointer to a variable receiving switch on/off state 60 | s := DefaultSwitchDef(th) 61 | s.StatePtr = statePtr 62 | for _, option := range options { 63 | option.apply(s) 64 | } 65 | return s.Layout 66 | } 67 | 68 | // Layout updates the switch and displays it. 69 | func (s *SwitchDef) Layout(gtx C) D { 70 | mt, mb, ml, mr := ScaleInset(gtx, s.margin) 71 | if *s.StatePtr != s.sw.Value { 72 | GuiLock.Lock() 73 | *s.StatePtr = s.sw.Value 74 | if s.onUserChange != nil { 75 | s.onUserChange() 76 | } 77 | GuiLock.Unlock() 78 | } else { 79 | GuiLock.RLock() 80 | s.sw.Value = *s.StatePtr 81 | GuiLock.RUnlock() 82 | } 83 | 84 | // Offset by margin 85 | defer op.Offset(image.Pt(ml, mt)).Push(gtx.Ops).Pop() 86 | 87 | width := Px(gtx, s.trackLength) 88 | height := Px(gtx, s.trackWidth) 89 | offSize := Px(gtx, s.btnOffSize) 90 | onSize := Px(gtx, s.btnOnSize) 91 | stroke := float32(Px(gtx, s.trackStroke)) 92 | r := Px(gtx, s.trackWidth/4) 93 | trackRect := image.Rect(0, 0, width, height) 94 | if gtx.Focused(&s.sw) && s.sw.Hovered() { 95 | s.hoverShadow = MulAlpha(s.th.Bg[Primary], 120) 96 | } else if gtx.Focused(&s.sw) { 97 | s.hoverShadow = MulAlpha(s.th.Bg[Primary], 95) 98 | } else if s.sw.Hovered() { 99 | s.hoverShadow = MulAlpha(s.th.Bg[Primary], 75) 100 | } else { 101 | s.hoverShadow = MulAlpha(s.th.Bg[Primary], 0) 102 | } 103 | 104 | if s.sw.Value { 105 | // Draw track in on position, filled rounded 106 | paint.FillShape(gtx.Ops, s.trackColorOn, clip.UniformRRect(trackRect, height/2).Op(gtx.Ops)) 107 | // Draw thumb, 108 | paint.FillShape(gtx.Ops, s.thumbColorOn, 109 | clip.Ellipse{Min: image.Point{X: 3 * r, Y: r / 2}, Max: image.Point{X: onSize + 3*r, Y: onSize + r/2}}.Op(gtx.Ops)) 110 | // Draw hover/focus shade. 111 | paint.FillShape( 112 | gtx.Ops, 113 | s.hoverShadow, 114 | clip.Ellipse{Min: image.Point{X: 2 * r, Y: -r / 2}, Max: image.Point{X: 7 * r, Y: 9 * r / 2}}.Op(gtx.Ops), 115 | ) 116 | // TODO: Draw icon 117 | } else { 118 | // First draw track in OFF position, outlined 119 | paint.FillShape(gtx.Ops, s.trackColorOff, clip.UniformRRect(trackRect, height/2).Op(gtx.Ops)) 120 | paint.FillShape(gtx.Ops, s.trackOutline, 121 | clip.Stroke{Path: clip.UniformRRect(trackRect, height/2).Path(gtx.Ops), Width: stroke}.Op()) 122 | // Draw thumb 123 | paint.FillShape(gtx.Ops, s.thumbColorOff, 124 | clip.Ellipse{Min: image.Point{X: +r, Y: +r}, Max: image.Point{X: offSize + r, Y: offSize + r}}.Op(gtx.Ops)) 125 | // Draw hover/focus shade. 126 | paint.FillShape(gtx.Ops, s.hoverShadow, 127 | clip.Ellipse{Min: image.Point{X: -r / 2, Y: -r / 2}, Max: image.Point{X: 9 * r / 2, Y: 9 * r / 2}}.Op(gtx.Ops)) 128 | // TODO: Draw icon 129 | } 130 | // Set up click area. 131 | defer op.Offset(image.Point{X: -10, Y: -10}).Push(gtx.Ops).Pop() 132 | sz := image.Pt(width+20, height+20) 133 | clickRect := image.Rect(0, 0, width+20, height+20) 134 | defer clip.UniformRRect(clickRect, height/2).Push(gtx.Ops).Pop() 135 | s.sw.Layout(gtx, func(gtx C) D { 136 | if s.hint != "" { 137 | // semantic.DescriptionOp(s.hint).Add(gtx.Ops) 138 | } 139 | // semantic.Switch.Add(gtx.Ops) 140 | return layout.Dimensions{Size: sz} 141 | }) 142 | 143 | return layout.Dimensions{Size: image.Point{X: width + ml + mr, Y: height + mt + mb}} 144 | } 145 | -------------------------------------------------------------------------------- /examples/my-kitchen/kitchen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jkvatne/gio-v/wid" 5 | "image" 6 | "image/color" 7 | "time" 8 | 9 | "gioui.org/op/clip" 10 | 11 | "gioui.org/app" 12 | "gioui.org/font/gofont" 13 | "gioui.org/op/paint" 14 | "gioui.org/unit" 15 | "golang.org/x/exp/shiny/materialdesign/icons" 16 | 17 | "gioui.org/layout" 18 | ) 19 | 20 | var ( 21 | topLabel = "Hello, Gio" 22 | thb *wid.Theme 23 | theme *wid.Theme 24 | addIcon *wid.Icon 25 | checkIcon *wid.Icon 26 | group string 27 | sliderValue float32 = 0.1 28 | win app.Window 29 | progress float32 = 0.1 30 | form layout.Widget 31 | enabledText = "Disabled" 32 | enabled bool 33 | blue = true 34 | homeBg = wid.RGB(0x1288F2) 35 | homeFg = wid.RGB(0xFFFFFF) 36 | btnText = "Blue" 37 | SomeText = "" 38 | ) 39 | 40 | func main() { 41 | checkIcon, _ = wid.NewIcon(icons.NavigationCheck) 42 | addIcon, _ = wid.NewIcon(icons.ContentAdd) 43 | theme = wid.NewTheme(gofont.Collection(), 14) 44 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(900), unit.Dp(650))) 45 | form = kitchen(theme) 46 | go ticker() 47 | wid.Run(&win, &form, theme) 48 | app.Main() 49 | } 50 | 51 | func ticker() { 52 | for { 53 | time.Sleep(time.Millisecond * 16) 54 | wid.GuiLock.Lock() 55 | progress = float32(int32((progress*1000)+5)%1000) / 1000.0 56 | wid.GuiLock.Unlock() 57 | wid.Invalidate() 58 | } 59 | } 60 | 61 | func onClick() { 62 | blue = !blue 63 | if blue { 64 | btnText = "Blue" 65 | homeBg = wid.RGB(0x1288F2) 66 | homeFg = wid.RGB(0xFFFFFF) 67 | } else { 68 | btnText = "Green" 69 | homeBg = wid.RGB(0x02F812) 70 | homeFg = wid.RGB(0x000000) 71 | } 72 | } 73 | 74 | func onDisable() { 75 | if enabled { 76 | enabledText = "Enabled" 77 | } else { 78 | enabledText = "Disabled" 79 | } 80 | } 81 | 82 | func colorBar(gtx wid.C) wid.D { 83 | gtx.Constraints.Min.Y = wid.Px(gtx, unit.Dp(50)) 84 | gtx.Constraints.Max.Y = gtx.Constraints.Min.Y 85 | 86 | dr := image.Rectangle{Max: gtx.Constraints.Min} 87 | paint.LinearGradientOp{ 88 | Stop1: layout.FPt(dr.Min), 89 | Stop2: layout.FPt(dr.Max), 90 | Color1: color.NRGBA{R: 0x10, G: 0xff, B: 0x10, A: 0xFF}, 91 | Color2: color.NRGBA{R: 0x10, G: 0x10, B: 0xff, A: 0xFF}, 92 | }.Add(gtx.Ops) 93 | defer clip.Rect(dr).Push(gtx.Ops).Pop() 94 | paint.PaintOp{}.Add(gtx.Ops) 95 | return layout.Dimensions{ 96 | Size: gtx.Constraints.Max, 97 | } 98 | } 99 | 100 | func kitchen(th *wid.Theme) layout.Widget { 101 | thb = th 102 | return wid.Col(nil, 103 | wid.Label(th, topLabel, wid.Middle(), wid.FontSize(2.1)), 104 | 105 | wid.Label(th, longText), 106 | 107 | wid.Edit(th, &SomeText, wid.Hint("Value 1")), 108 | 109 | wid.Row(th, nil, wid.SpaceClose, 110 | wid.RoundButton(th, addIcon, wid.Hint("This is another dummy button"), wid.Role(wid.Primary)), 111 | wid.Button(th, "Icon", wid.BtnIcon(checkIcon), wid.Role(wid.Primary)), 112 | wid.Button(thb, "Click me!", wid.W(200), wid.Do(onClick), wid.Role(wid.Secondary)), 113 | wid.Button(thb, &btnText, wid.Fg(&homeFg), wid.Bg(&homeBg)), 114 | wid.TextButton(thb, "Flat"), 115 | ), 116 | 117 | wid.ProgressBar(th, &progress), 118 | 119 | func(gtx wid.C) wid.D { 120 | return layout.UniformInset(unit.Dp(16)).Layout(gtx, colorBar) 121 | }, 122 | 123 | wid.Row(th, nil, wid.SpaceClose, 124 | wid.Switch(th, &enabled, wid.Do(onDisable)), 125 | wid.Button(th, &enabledText, wid.En(&enabled)), 126 | ), 127 | 128 | wid.Row(th, nil, wid.SpaceDistribute, 129 | wid.RadioButton(th, &group, "RadioButton1", "RadioButton1"), 130 | wid.RadioButton(th, &group, "RadioButton2", "RadioButton2"), 131 | wid.RadioButton(th, &group, "RadioButton3", "RadioButton3"), 132 | ), 133 | 134 | wid.Row(th, nil, []float32{0.9, 0.1}, 135 | wid.Slider(th, &sliderValue, 0, 100), 136 | ), 137 | ) 138 | } 139 | 140 | const longText = `1. I learned from my grandfather, Verus, to use good manners, and to put restraint on anger. 141 | 2. In the famous memory of my father I had a pattern of modesty and manliness. 142 | 3. Of my mother I learned to be pious and generous; to keep myself not only from evil deeds, but even from evil thoughts; and to live with a simplicity which is far from customary among the rich. 143 | 4. I owe it to my great-grandfather that I did not attend public lectures and discussions, but had good and able teachers at home; and I owe him also the knowledge that for things of this nature a man should count no expense too great. 144 | 5. My tutor taught me not to favour either green or blue at the chariot races, nor, in the contests of gladiators, to be a supporter either of light or heavy armed. He taught me also to endure labour; not to need many things; to serve myself without troubling others; not to intermeddle in the affairs of others, and not easily to listen to slanders against them. 145 | ` 146 | -------------------------------------------------------------------------------- /wid/clickable.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "gioui.org/io/event" 5 | "image" 6 | "time" 7 | 8 | "gioui.org/gesture" 9 | "gioui.org/io/key" 10 | "gioui.org/io/pointer" 11 | "gioui.org/op/clip" 12 | ) 13 | 14 | // Clickable represents a clickable area. 15 | type Clickable struct { 16 | click gesture.Click 17 | clicks []Click 18 | // prevClicks is the index into clicks that marks the clicks 19 | // from the most recent Layout call. prevClicks is used to keep 20 | // clicks bounded. 21 | prevClicks int 22 | history []Press 23 | focused bool 24 | requestClicks int 25 | pressed bool 26 | pressedKey key.Name 27 | // ClickMovesFocus can be set true if you want clicking on a button 28 | // to move focus. If false, only Tab will move focus. 29 | // Dropdowns must have this set to true 30 | ClickMovesFocus bool 31 | index *int 32 | maxIndex int 33 | } 34 | 35 | // Click represents a click. 36 | type Click struct { 37 | Modifiers key.Modifiers 38 | NumClicks int 39 | } 40 | 41 | // Press represents a past pointer press. 42 | type Press struct { 43 | // Position of the press. 44 | Position image.Point 45 | // Start is when the press began. 46 | Start time.Time 47 | // End is when the press was ended by a release or cancel. 48 | // A zero End means it hasn't ended yet. 49 | End time.Time 50 | // Cancelled is true for cancelled presses. 51 | Cancelled bool 52 | } 53 | 54 | // Clicked reports whether there are pending clicks as would be 55 | // reported by Clicks. If so, Clicked removes the earliest click. 56 | func (b *Clickable) Clicked() bool { 57 | if len(b.clicks) == 0 { 58 | return false 59 | } 60 | n := copy(b.clicks, b.clicks[1:]) 61 | b.clicks = b.clicks[:n] 62 | if b.prevClicks > 0 { 63 | b.prevClicks-- 64 | } 65 | return true 66 | } 67 | 68 | // Hovered reports whether a pointer is over the element. 69 | func (b *Clickable) Hovered() bool { 70 | return b.click.Hovered() 71 | } 72 | 73 | // Pressed reports whether a pointer is pressing the element. 74 | func (b *Clickable) Pressed() bool { 75 | return b.click.Pressed() 76 | } 77 | 78 | // History is the past pointer presses useful for drawing markers. 79 | // History is retained for a short duration (about a second). 80 | func (b *Clickable) History() []Press { 81 | return b.history 82 | } 83 | 84 | func (b *Clickable) SetupEventHandlers(gtx C, size image.Point) { 85 | defer clip.Rect(image.Rectangle{Max: size}).Push(gtx.Ops).Pop() 86 | b.click.Add(gtx.Ops) 87 | event.Op(gtx.Ops, b) 88 | } 89 | 90 | // HandleEvents the button state by processing events. 91 | func (b *Clickable) HandleEvents(gtx C) { 92 | for len(b.history) > 0 { 93 | c := b.history[0] 94 | if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second { 95 | break 96 | } 97 | n := copy(b.history, b.history[1:]) 98 | b.history = b.history[:n] 99 | } 100 | for { 101 | e, ok := b.click.Update(gtx.Source) 102 | if !ok { 103 | break 104 | } 105 | switch e.Kind { 106 | case gesture.KindClick: 107 | b.clicks = append(b.clicks, Click{ 108 | Modifiers: e.Modifiers, 109 | NumClicks: e.NumClicks, 110 | }) 111 | if l := len(b.history); l > 0 { 112 | b.history[l-1].End = gtx.Now 113 | } 114 | case gesture.KindCancel: 115 | for i := range b.history { 116 | b.history[i].Cancelled = true 117 | if b.history[i].End.IsZero() { 118 | b.history[i].End = gtx.Now 119 | } 120 | } 121 | case gesture.KindPress: 122 | if e.Source == pointer.Mouse { 123 | gtx.Execute(key.FocusCmd{Tag: b}) 124 | } 125 | b.history = append(b.history, Press{ 126 | Position: e.Position, 127 | Start: gtx.Now, 128 | }) 129 | } 130 | } 131 | 132 | for { 133 | e, ok := gtx.Event( 134 | key.FocusFilter{Target: b}, 135 | key.Filter{Focus: b, Name: key.NameReturn}, 136 | key.Filter{Focus: b, Name: key.NameSpace}, 137 | key.Filter{Focus: b, Name: key.NameDownArrow}, 138 | key.Filter{Focus: b, Name: key.NameUpArrow}, 139 | key.Filter{Focus: b, Name: key.NameLeftArrow}, 140 | key.Filter{Focus: b, Name: key.NameRightArrow}, 141 | key.Filter{Focus: b, Name: key.NameEscape}, 142 | ) 143 | if !ok { 144 | break 145 | } 146 | switch e := e.(type) { 147 | case key.FocusEvent: 148 | if e.Focus { 149 | b.pressedKey = "" 150 | } 151 | case key.Event: 152 | if !gtx.Focused(b) { 153 | break 154 | } 155 | switch e.State { 156 | case key.Release: 157 | // Clicking via keyboard 158 | if e.Name == key.NameSpace || e.Name == key.NameReturn || e.Name == key.NameEscape { 159 | b.clicks = append(b.clicks, Click{Modifiers: e.Modifiers, NumClicks: 1}) 160 | } else if e.Name == key.NameDownArrow || e.Name == key.NameRightArrow { 161 | GuiLock.Lock() 162 | *b.index++ 163 | GuiLock.Unlock() 164 | } else if e.Name == key.NameUpArrow || e.Name == key.NameLeftArrow { 165 | GuiLock.Lock() 166 | *b.index-- 167 | if *b.index < 0 { 168 | *b.index = 0 169 | } 170 | GuiLock.Unlock() 171 | } 172 | } 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /wid/container.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/io/event" 7 | "gioui.org/io/pointer" 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | "gioui.org/op/clip" 11 | "gioui.org/op/paint" 12 | "gioui.org/unit" 13 | "image" 14 | "math" 15 | ) 16 | 17 | // Container makes a container widget with background color according to role/theme 18 | // func Container(th *Theme, role UIRole, rr unit.Dp, padding layout.Inset, margin layout.Inset, widgets ...Wid) Wid { 19 | func Container(th *Theme, options ...any) Wid { 20 | tag := 0 21 | n := 0 22 | collapsable := false 23 | collapsed := false 24 | role := PrimaryContainer 25 | margin := th.DefaultPadding 26 | padding := th.DefaultMargin 27 | rr := th.DialogCorners 28 | var description Wid 29 | var widgets []Wid 30 | // Read in all options to change from default values to something else. 31 | for _, option := range options { 32 | if v, ok := option.(UIRole); ok { 33 | role = v 34 | } else if v, ok := option.(layout.Inset); ok { 35 | if n == 0 { 36 | padding = v 37 | n++ 38 | } else { 39 | margin = v 40 | } 41 | } else if v, ok := option.(bool); ok { 42 | collapsable = v 43 | } else if v, ok := option.(string); ok { 44 | description = Label(th, v) 45 | } else if v, ok := option.(unit.Dp); ok { 46 | rr = v 47 | } else if v, ok := option.(int); ok { 48 | rr = unit.Dp(v) 49 | } else if v, ok := option.(Wid); ok { 50 | widgets = append(widgets, v) 51 | } else if v, ok := option.([]Wid); ok { 52 | widgets = append(widgets, v...) 53 | } else { 54 | panic("Unknown argument to Container()") 55 | } 56 | } 57 | 58 | return func(gtx C) D { 59 | size := 0 60 | // Scale margins and paddings 61 | mt, mb, ml, mr := ScaleInset(gtx, margin) 62 | pt, pb, pl, pr := ScaleInset(gtx, padding) 63 | c := gtx 64 | c.Constraints.Min.Y = 0 65 | c.Constraints.Max.X -= pl + pr + pl + mr 66 | c.Constraints.Min.X = Min(c.Constraints.Min.X, c.Constraints.Max.X) 67 | calls := make([]op.CallOp, len(widgets)) 68 | dims := make([]D, len(widgets)) 69 | remaining := gtx.Constraints.Max.Y 70 | // Create macros and dimensions for all widgets in the container 71 | for i, child := range widgets { 72 | macro := op.Record(gtx.Ops) 73 | c.Constraints.Max.Y = remaining 74 | dims[i] = child(c) 75 | calls[i] = macro.Stop() 76 | size += dims[i].Size.Y 77 | remaining = Max(0, remaining-dims[i].Size.Y) 78 | } 79 | // Offset by margin 80 | defer op.Offset(image.Pt(ml, mt)).Push(gtx.Ops).Pop() 81 | 82 | if collapsable && collapsed { 83 | if len(widgets) > 1 { 84 | if description == nil { 85 | size := max(gtx.Sp(th.TextSize), gtx.Dp(rr*2)) 86 | outline := image.Rect(0, 0, gtx.Constraints.Max.X-mr-ml, size+pt+pb) 87 | c := clip.UniformRRect(outline, Px(gtx, rr)).Push(gtx.Ops) 88 | paint.Fill(gtx.Ops, th.Bg[role]) 89 | c.Pop() 90 | } else { 91 | macro := op.Record(gtx.Ops) 92 | size = description(gtx).Size.Y 93 | call := macro.Stop() 94 | // Draw surface 95 | outline := image.Rect(0, 0, gtx.Constraints.Max.X-mr-ml, size+pt+pb) 96 | c := clip.UniformRRect(outline, Px(gtx, rr)).Push(gtx.Ops) 97 | paint.Fill(gtx.Ops, th.Bg[role]) 98 | trans := op.Offset(image.Pt(pl, pt)).Push(gtx.Ops) 99 | call.Add(gtx.Ops) 100 | trans.Pop() 101 | c.Pop() 102 | } 103 | } 104 | } else { 105 | // Negative padding is used for flexible insets 106 | // The container will center the contents 107 | if free := gtx.Constraints.Max.Y - size; free > 0 && pt < 0 { 108 | pt = free / 2 109 | pb = free / 2 110 | } 111 | // Draw surface 112 | outline := image.Rect(0, 0, gtx.Constraints.Max.X-mr-ml, size+pt+pb) 113 | defer clip.UniformRRect(outline, Px(gtx, rr)).Push(gtx.Ops).Pop() 114 | paint.Fill(gtx.Ops, th.Bg[role]) 115 | // Now do the actual drawing of widgets in the container, with offsets 116 | y := pt 117 | for i := range widgets { 118 | trans := op.Offset(image.Pt(pl, int(math.Round(float64(y))))).Push(gtx.Ops) 119 | calls[i].Add(gtx.Ops) 120 | trans.Pop() 121 | y += dims[i].Size.Y 122 | if y >= gtx.Constraints.Max.Y { 123 | break 124 | } 125 | } 126 | } 127 | sz := gtx.Constraints.Constrain(image.Pt(gtx.Constraints.Max.X, size+mt+mb+pt+pb)) 128 | defer op.Offset(image.Pt(sz.X-50-pr-mr, gtx.Dp(rr/2-5))).Push(gtx.Ops).Pop() 129 | c.Constraints.Max = image.Pt(50, 50) 130 | c.Constraints.Min = c.Constraints.Max 131 | if collapsable { 132 | if collapsed { 133 | dropDownIcon.Layout(c, th.Fg[role]) 134 | } else { 135 | dropUpIcon.Layout(c, th.Fg[role]) 136 | } 137 | } 138 | // Setup event handler 139 | defer clip.UniformRRect(image.Rect(0, 0, 50, 50), 0).Push(gtx.Ops).Pop() 140 | event.Op(gtx.Ops, &tag) 141 | 142 | for { 143 | event, ok := gtx.Event(pointer.Filter{ 144 | Target: &tag, 145 | Kinds: pointer.Release, 146 | }) 147 | if !ok { 148 | break 149 | } 150 | if _, ok := event.(pointer.Event); ok { 151 | if collapsable { 152 | collapsed = !collapsed 153 | } 154 | } 155 | } 156 | return D{Size: sz, Baseline: sz.Y} 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /wid/checkbox.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/io/semantic" 7 | "gioui.org/unit" 8 | "image" 9 | "image/color" 10 | 11 | "gioui.org/op/clip" 12 | 13 | "gioui.org/op/paint" 14 | 15 | "gioui.org/io/pointer" 16 | "gioui.org/op" 17 | "gioui.org/widget" 18 | ) 19 | 20 | // CheckBoxDef defines a checkbox widget 21 | type CheckBoxDef struct { 22 | Base 23 | Clickable 24 | TooltipDef 25 | Label string 26 | StrValue *string 27 | BoolValue *bool 28 | Checked bool 29 | checkedStateIcon *Icon 30 | uncheckedStateIcon *Icon 31 | Key string 32 | } 33 | 34 | // RadioButton returns a RadioButton with a label. The key specifies the initial value for the output 35 | func RadioButton(th *Theme, value *string, key string, label string, options ...Option) Wid { 36 | r := &CheckBoxDef{ 37 | Base: Base{ 38 | th: th, 39 | FontScale: 1.0, 40 | role: Surface, 41 | Font: &th.DefaultFont, 42 | padding: th.DefaultPadding, 43 | }, 44 | TooltipDef: Tooltip(th), 45 | Label: label, 46 | StrValue: value, 47 | checkedStateIcon: th.RadioChecked, 48 | uncheckedStateIcon: th.RadioUnchecked, 49 | Key: key, 50 | } 51 | for _, option := range options { 52 | option.apply(r) 53 | } 54 | return r.Layout 55 | } 56 | 57 | // Checkbox returns a widget that can be checked, with label, initial state and handler function 58 | func Checkbox(th *Theme, label string, options ...Option) Wid { 59 | c := &CheckBoxDef{ 60 | Base: Base{ 61 | th: th, 62 | FontScale: 1.0, 63 | role: Surface, 64 | Font: &th.DefaultFont, 65 | padding: th.DefaultPadding, 66 | }, 67 | TooltipDef: Tooltip(th), 68 | Label: label, 69 | checkedStateIcon: th.CheckBoxChecked, 70 | uncheckedStateIcon: th.CheckBoxUnchecked, 71 | } 72 | for _, option := range options { 73 | option.apply(c) 74 | } 75 | return c.Layout 76 | } 77 | 78 | // Layout updates the checkBox and displays it. 79 | func (c *CheckBoxDef) Layout(gtx C) D { 80 | c.HandleEvents(gtx) 81 | for c.Clicked() { 82 | c.Checked = !c.Checked 83 | GuiLock.Lock() 84 | if c.BoolValue != nil { 85 | *c.BoolValue = c.Checked 86 | } else if c.StrValue != nil { 87 | *c.StrValue = c.Key 88 | } 89 | GuiLock.Unlock() 90 | if c.onUserChange != nil { 91 | c.onUserChange() 92 | } 93 | } 94 | GuiLock.RLock() 95 | if c.BoolValue != nil { 96 | c.Checked = *c.BoolValue 97 | } else if c.StrValue != nil { 98 | c.Checked = *c.StrValue == c.Key 99 | } 100 | GuiLock.RUnlock() 101 | 102 | icon := c.uncheckedStateIcon 103 | if c.Checked { 104 | icon = c.checkedStateIcon 105 | } 106 | 107 | iconSize := Px(gtx, c.th.TextSize*unit.Sp(c.FontScale)) 108 | macro := op.Record(gtx.Ops) 109 | gtx.Constraints.Min.Y = 0 110 | gtx.Constraints.Min.X = 0 111 | ctx := gtx 112 | ctx.Constraints.Max.X -= Px(gtx, c.padding.Right+c.padding.Left) + iconSize 113 | if ctx.Constraints.Max.X < 10 { 114 | ctx.Constraints.Max.X = 10 115 | } 116 | // Calculate color of text and checkbox 117 | fgColor := c.Fg() 118 | if !gtx.Enabled() { 119 | fgColor = Disabled(fgColor) 120 | } 121 | 122 | // Draw label into macro 123 | colMacro := op.Record(gtx.Ops) 124 | paint.ColorOp{Color: fgColor}.Add(gtx.Ops) 125 | labelDim := widget.Label{MaxLines: 1}.Layout(ctx, c.th.Shaper, *c.Font, c.th.TextSize*unit.Sp(c.FontScale), c.Label, colMacro.Stop()) 126 | drawLabel := macro.Stop() 127 | 128 | // Calculate hover/focus background color 129 | background := color.NRGBA{} 130 | if gtx.Focused(&c.Clickable) && c.Hovered() { 131 | background = MulAlpha(c.Fg(), 90) 132 | } else if gtx.Focused(&c.Clickable) { 133 | background = MulAlpha(c.Fg(), 85) 134 | } else if c.Hovered() { 135 | background = MulAlpha(c.Fg(), 65) 136 | } 137 | // The hover/focus shadow extends outside the checkbox by the padding size 138 | pt, pb, pl, pr := ScaleInset(gtx, c.padding) 139 | b := image.Rectangle{Min: image.Pt(0, 0), Max: image.Pt(iconSize*10/9+pl+pr, iconSize*10/9+pt+pb)} 140 | paint.FillShape(gtx.Ops, background, clip.Ellipse(b).Op(gtx.Ops)) 141 | 142 | // Icon layout size will be equal to the min x constraint. 143 | cgtx := gtx 144 | cgtx.Constraints.Min = image.Point{X: iconSize} 145 | // Offset for drawing icon 146 | defer op.Offset(image.Pt(pl, pt+iconSize/9)).Push(gtx.Ops).Pop() 147 | // Now draw icon 148 | iconDim := icon.Layout(cgtx, fgColor) 149 | size := image.Pt(labelDim.Size.X+pl+pl+iconDim.Size.X, labelDim.Size.Y+pt) 150 | if c.Label != "" { 151 | size.Y += iconSize / 9 152 | } 153 | // Handle events within the calculated size. Must be called before label offset 154 | c.SetupEventHandlers(gtx, size) 155 | semantic.CheckBox.Add(gtx.Ops) 156 | semantic.SelectedOp(c.Checked).Add(gtx.Ops) 157 | // Extra offset for drawing label 158 | defer op.Offset(image.Pt(iconSize+iconSize/9, -iconSize/9)).Push(gtx.Ops).Pop() 159 | drawLabel.Add(gtx.Ops) 160 | pointer.CursorPointer.Add(gtx.Ops) 161 | _ = c.TooltipDef.Layout(gtx, c.hint, c.th) 162 | return D{Size: size} 163 | } 164 | 165 | // CheckboxOption is options specific to Checkboxes 166 | type CheckboxOption func(w *CheckBoxDef) 167 | 168 | // Bool is an option parameter to set the variable updated 169 | func Bool(b *bool) CheckboxOption { 170 | return func(c *CheckBoxDef) { 171 | c.BoolValue = b 172 | } 173 | } 174 | 175 | func (e CheckboxOption) apply(cfg interface{}) { 176 | e(cfg.(*CheckBoxDef)) 177 | } 178 | -------------------------------------------------------------------------------- /wid/base.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/font" 7 | "gioui.org/io/event" 8 | "gioui.org/op" 9 | "gioui.org/op/paint" 10 | "gioui.org/text" 11 | "golang.org/x/exp/constraints" 12 | "image/color" 13 | "os" 14 | "sync" 15 | 16 | "gioui.org/app" 17 | "gioui.org/io/pointer" 18 | "gioui.org/layout" 19 | "gioui.org/unit" 20 | ) 21 | 22 | type ( 23 | // C is a shortcut for layout.Context 24 | C = layout.Context 25 | // D is a shortcut for layout.Dimensions 26 | D = layout.Dimensions 27 | Wid = layout.Widget 28 | ) 29 | 30 | // UIState is the hovered/focused etc. state 31 | type UIState uint8 32 | 33 | var ( 34 | mouseX int 35 | mouseY int 36 | WinX int 37 | WinY int 38 | startWinY int 39 | FixedFontSize bool 40 | GuiLock sync.RWMutex 41 | invalidate chan struct{} 42 | ) 43 | 44 | // Base is tha base structure for widgets. It contains variables that (almost) all widgets share 45 | type Base struct { 46 | th *Theme 47 | hint string 48 | padding layout.Inset 49 | margin layout.Inset 50 | onUserChange func() 51 | disabler *bool 52 | width unit.Dp 53 | role UIRole 54 | cornerRadius unit.Dp 55 | borderWidth unit.Dp 56 | fgColor *color.NRGBA 57 | bgColor *color.NRGBA 58 | Font *font.Font 59 | FontScale float64 60 | DpNo *int 61 | Alignment text.Alignment 62 | } 63 | 64 | // Fg returns the foreground color of a widget, either from 65 | // its role or from the widget specific fgColor field. 66 | func (wid *Base) Fg() color.NRGBA { 67 | if wid.fgColor == nil { 68 | return wid.th.Fg[wid.role] 69 | } else { 70 | return *wid.fgColor 71 | } 72 | } 73 | 74 | // Bg returns the background color of a widget, either from 75 | // its role or from the widget specific bgColor field. 76 | func (wid *Base) Bg() color.NRGBA { 77 | if wid.bgColor == nil { 78 | return wid.th.Bg[wid.role] 79 | } else { 80 | return *wid.bgColor 81 | } 82 | } 83 | 84 | // CheckDisabler is used when a variable controls the disabling of a widget. 85 | func (wid *Base) CheckDisabler(gtx C) { 86 | if wid.disabler != nil { 87 | GuiLock.RLock() 88 | if *wid.disabler { 89 | gtx = gtx.Disabled() 90 | } 91 | GuiLock.RUnlock() 92 | } 93 | } 94 | 95 | // UpdateMousePos must be called from the main program in order to get mouse 96 | // position and window size. They are needed to avoid that the tooltip 97 | // is outside the window frame 98 | func UpdateMousePos(gtx C, win *app.Window) { 99 | // Pass on all events to the widgets. 100 | p := pointer.PassOp{}.Push(gtx.Ops) 101 | event.Op(gtx.Ops, win) 102 | for { 103 | if e, ok := gtx.Event(pointer.Filter{ 104 | Target: win, 105 | Kinds: pointer.Move | pointer.Scroll, 106 | ScrollX: pointer.ScrollRange{Min: -1000, Max: 1000}, 107 | ScrollY: pointer.ScrollRange{Min: -25000, Max: 25000}, 108 | }, 109 | ); ok { 110 | if ev, ok := e.(pointer.Event); ok { 111 | // Catch current mouse position 112 | mouseX = int(ev.Position.X) 113 | mouseY = int(ev.Position.Y) 114 | /*if ev.Kind == pointer.Scroll { 115 | // Print scroll coordinates - used for debugging scrolling 116 | fmt.Printf("Scroll x=%0.0f, y=%0.0f\n", ev.Scroll.X, ev.Scroll.Y) 117 | }*/ 118 | } 119 | } else { 120 | break 121 | } 122 | } 123 | p.Pop() 124 | } 125 | 126 | // Invalidate will force a redraw of the current form 127 | func Invalidate() { 128 | invalidate <- struct{}{} 129 | } 130 | 131 | // Run is the main event handler, called with "go run" from main() 132 | func Run(win *app.Window, mainForm *layout.Widget, th *Theme) { 133 | invalidate = make(chan struct{}) 134 | go func() { 135 | for range invalidate { 136 | win.Invalidate() 137 | } 138 | }() 139 | 140 | for { 141 | switch e := win.Event().(type) { 142 | case app.DestroyEvent: 143 | os.Exit(0) 144 | case app.FrameEvent: 145 | var ops op.Ops 146 | // Save window size for use by widgets. Must be done before drawing 147 | WinX = e.Size.X 148 | WinY = e.Size.Y 149 | gtx := app.NewContext(&ops, e) 150 | 151 | if startWinY == 0 { 152 | startWinY = WinY 153 | } 154 | // Font size is in units sp (like dp but for fonts) while WinY is in pixels 155 | // So we have to rescale using PxToSp 156 | if !FixedFontSize { 157 | scale := float32(WinY) / float32(startWinY) * th.Scale 158 | gtx.Metric.PxPerDp = scale * gtx.Metric.PxPerDp 159 | gtx.Metric.PxPerSp = scale * gtx.Metric.PxPerSp 160 | } 161 | // Draw background color 162 | paint.ColorOp{Color: th.Bg[Surface]}.Add(gtx.Ops) 163 | paint.PaintOp{}.Add(gtx.Ops) 164 | // Disable main form if a dialog is shown 165 | ctx := gtx 166 | if dialog != nil { 167 | ctx = gtx.Disabled() 168 | } 169 | // Catch mouse position from current event. This is a hack to fetch mouse 170 | // position, so we can avoid tooltips going outside the main window area 171 | UpdateMousePos(gtx, win) 172 | // Call all the widgets in the current form 173 | (*mainForm)(ctx) 174 | // Draw dialog (if any exist) on top of the current form 175 | if dialog != nil { 176 | dialog(gtx) 177 | } 178 | // Signal the library to do the actual drawing 179 | e.Frame(gtx.Ops) 180 | 181 | } 182 | } 183 | } 184 | 185 | // Min is a generic minimum function. Can be removed when go includes it 186 | func Min[T constraints.Ordered](x, y T) T { 187 | if x < y { 188 | return x 189 | } 190 | return y 191 | } 192 | 193 | // Max is a generic maximum function. Can be removed when go includes it 194 | func Max[T constraints.Ordered](x, y T) T { 195 | if x >= y { 196 | return x 197 | } 198 | return y 199 | } 200 | 201 | // Clamp will return the first argument clamped between argument 2 and 3. 202 | func Clamp[T constraints.Ordered](v T, lo T, hi T) T { 203 | if v < lo { 204 | return lo 205 | } else if v > hi { 206 | return hi 207 | } 208 | return v 209 | } 210 | -------------------------------------------------------------------------------- /examples/calculator/calculator.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | // A Gio program that demonstrates gio-v widgets. 6 | // See https://gioui.org for information on the gio 7 | // gio-v is maintained by Jan Kåre Vatne (jkvatne@online.no) 8 | 9 | import ( 10 | "fmt" 11 | "gioui.org/font/gofont" 12 | "github.com/jkvatne/gio-v/wid" 13 | "image/color" 14 | "math" 15 | "strconv" 16 | "strings" 17 | 18 | "gioui.org/app" 19 | "gioui.org/layout" 20 | "gioui.org/unit" 21 | ) 22 | 23 | var ( 24 | theme *wid.Theme // the theme selected 25 | win app.Window // The main window 26 | form layout.Widget 27 | entry float64 28 | operator rune 29 | dpNo int 30 | operand float64 31 | dpPressed = false 32 | ) 33 | 34 | func main() { 35 | theme = wid.NewTheme(gofont.Collection(), 12) 36 | theme.Scale = 2.0 37 | theme.DarkMode = false 38 | theme.SecondaryColor = color.NRGBA{100, 200, 100, 255} 39 | theme.UpdateColors() 40 | win.Option(app.Title("Gio-v Calculator"), app.Size(unit.Dp(450), unit.Dp(700))) 41 | form = demo(theme) 42 | go wid.Run(&win, &form, theme) 43 | app.Main() 44 | } 45 | 46 | func NumDecPlaces(v float64) int { 47 | s := strconv.FormatFloat(v, 'f', -1, 64) 48 | i := strings.IndexByte(s, '.') 49 | if i <= -1 { 50 | return 0 51 | } 52 | return len(s) - i - 1 53 | } 54 | 55 | func clearAcc() { 56 | entry = 0 57 | operand = 0 58 | dpNo = 0 59 | dpPressed = false 60 | } 61 | 62 | func addDigt(x float64) { 63 | if !dpPressed { 64 | entry = 10*entry + x 65 | } else { 66 | dpNo++ 67 | entry = entry + float64(x)/math.Pow(10, float64(dpNo)) 68 | } 69 | } 70 | 71 | func setOp(ch rune) { 72 | operator = ch 73 | operand = entry 74 | entry = 0 75 | dpNo = 0 76 | dpPressed = false 77 | fmt.Printf("entry=%0.3f operand=%0.3f op=%d\n", entry, operand, operator) 78 | } 79 | 80 | // Demo setup. Called from Setup(), only once - at start of showing it. 81 | // Returns a widget - i.e. a function: func(gtx C) D 82 | func demo(th *wid.Theme) layout.Widget { 83 | return wid.Col(wid.SpaceClose, 84 | wid.Container(th, wid.PrimaryContainer, 0, layout.Inset{}, layout.Inset{}, 85 | wid.Label(th, "Calculator demo", wid.Middle(), wid.Heading(), wid.Bold(), wid.Role(wid.PrimaryContainer)), 86 | ), 87 | wid.Col(wid.SpaceClose, 88 | wid.Edit(th, &entry, &dpNo, wid.FontSize(1.8)), 89 | wid.Row(th, nil, wid.SpaceClose, 90 | wid.Button(th, "AC", wid.RR(999), wid.FontSize(1.4), wid.Pads(12, 1), wid.Role(wid.PrimaryContainer), 91 | wid.Do(func() { clearAcc() })), 92 | wid.Button(th, "C", wid.RR(999), wid.FontSize(2), wid.Pads(7, 3), wid.Role(wid.SecondaryContainer)), 93 | wid.Button(th, "%", wid.RR(999), wid.FontSize(2), wid.Pads(8, 1), wid.Role(wid.SecondaryContainer), 94 | wid.Do(func() { 95 | entry = entry / 100 96 | })), 97 | wid.Button(th, "/", wid.RR(999), wid.FontSize(2), wid.Pads(8, 7), wid.Role(wid.SecondaryContainer), 98 | wid.Do(func() { setOp('/') })), 99 | ), 100 | wid.Row(th, nil, wid.SpaceClose, 101 | wid.Button(th, "7", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 102 | wid.Do(func() { addDigt(7) })), 103 | wid.Button(th, "8", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 104 | wid.Do(func() { addDigt(8) })), 105 | wid.Button(th, "9", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 106 | wid.Do(func() { addDigt(9) })), 107 | wid.Button(th, "x", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SecondaryContainer), 108 | wid.Do(func() { setOp('*') })), 109 | ), 110 | wid.Row(th, nil, wid.SpaceClose, 111 | wid.Button(th, "4", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 112 | wid.Do(func() { addDigt(4) })), 113 | wid.Button(th, "5", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 114 | wid.Do(func() { addDigt(5) })), 115 | wid.Button(th, "6", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 116 | wid.Do(func() { addDigt(6) })), 117 | wid.Button(th, "-", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SecondaryContainer), 118 | wid.Do(func() { setOp('-') })), 119 | ), 120 | wid.Row(th, nil, wid.SpaceClose, 121 | wid.Button(th, "1", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 122 | wid.Do(func() { addDigt(1) })), 123 | wid.Button(th, "2", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 124 | wid.Do(func() { addDigt(2) })), 125 | wid.Button(th, "3", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 126 | wid.Do(func() { addDigt(3) })), 127 | wid.Button(th, "+", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SecondaryContainer), 128 | wid.Do(func() { setOp('+') })), 129 | ), 130 | wid.Row(th, nil, wid.SpaceClose, 131 | wid.Button(th, "0", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.SurfaceContainer), 132 | wid.Do(func() { addDigt(0) })), 133 | wid.Button(th, "\u2022", wid.RR(999), wid.FontSize(2), wid.Pads(8, 7), wid.Role(wid.SurfaceContainer), 134 | wid.Do(func() { dpNo = 0; dpPressed = true })), 135 | wid.Button(th, " = ", wid.RR(999), wid.FontSize(2), wid.Pads(8, 5), wid.Role(wid.TertiaryContainer), 136 | wid.Do(func() { 137 | fmt.Printf("entry=%0.3f operand=%0.3f op=%d\n", entry, operand, operator) 138 | switch operator { 139 | case '+': 140 | entry = entry + operand 141 | case '-': 142 | entry = entry - operand 143 | case '*': 144 | entry = entry * operand 145 | case '/': 146 | entry = operand / entry 147 | default: 148 | panic("Invalid operand") 149 | } 150 | operand = 0 151 | dpNo = NumDecPlaces(entry) 152 | dpPressed = false 153 | fmt.Printf("entry=%0.3f operand=%0.3f\n", entry, operand) 154 | })), 155 | ), 156 | ), 157 | ) 158 | } 159 | -------------------------------------------------------------------------------- /examples/material/material.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | // A Gio program that demonstrates gio-v widgets. 6 | // See https://gioui.org for information on the gio 7 | // gio-v is maintained by Jan Kåre Vatne (jkvatne@online.no) 8 | 9 | import ( 10 | "gioui.org/font/gofont" 11 | "github.com/jkvatne/gio-v/wid" 12 | "golang.org/x/exp/shiny/materialdesign/icons" 13 | "strconv" 14 | "sync/atomic" 15 | "time" 16 | 17 | "gioui.org/app" 18 | "gioui.org/layout" 19 | "gioui.org/unit" 20 | ) 21 | 22 | var ( 23 | SmallFont bool 24 | FixedFont bool 25 | theme *wid.Theme // the theme selected 26 | win app.Window // The main window 27 | form layout.Widget 28 | homeIcon *wid.Icon 29 | saveIcon *wid.Icon 30 | otherPallete = false 31 | d layout.Widget 32 | no atomic.Int32 33 | ) 34 | 35 | func main() { 36 | homeIcon, _ = wid.NewIcon(icons.ActionHome) 37 | saveIcon, _ = wid.NewIcon(icons.ContentSave) 38 | theme = wid.NewTheme(gofont.Collection(), 16) 39 | d = wid.YesNoDialog(theme, 40 | "Save data?", 41 | "Click yes if you want to save the data to persitant memory or disk", 42 | "No", "Yes", 43 | onNo, onYes) 44 | theme.Scale = 1 45 | go ticker() 46 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(500), unit.Dp(600))) 47 | form = demo(theme) 48 | go wid.Run(&win, &form, theme) 49 | app.Main() 50 | } 51 | 52 | func ticker() { 53 | for { 54 | time.Sleep(time.Second) 55 | no.Add(1) 56 | wid.Invalidate() 57 | } 58 | } 59 | 60 | func onYes() { 61 | wid.Hide() 62 | } 63 | 64 | func onNo() { 65 | wid.Hide() 66 | } 67 | 68 | func myDialog(th *wid.Theme) layout.Widget { 69 | return wid.Container(th, wid.TransparentSurface, 0, wid.FlexInset, wid.NoInset, 70 | wid.Col(wid.SpaceDistribute, 71 | wid.Container(th, wid.PrimaryContainer, 20, layout.Inset{22, 22, 22, 22}, layout.Inset{62, 62, 62, 62}, 72 | wid.Label(th, "Confirm", wid.Heading(), wid.Middle()), 73 | wid.Label(th, "Do you want to save data?", wid.Middle()), 74 | wid.Separator(th, 0, wid.Pads(10)), 75 | wid.Row(th, nil, wid.SpaceDistribute, 76 | wid.TextButton(th, "Yes", wid.Do(onYes), wid.Right(), wid.Margin(11)), 77 | wid.TextButton(th, "No", wid.Do(onNo), wid.Margin(11)), 78 | ), 79 | ), 80 | ), 81 | ) 82 | } 83 | 84 | func onSave() { 85 | wid.Show(d) 86 | } 87 | 88 | func onClick() { 89 | if otherPallete { 90 | theme.PrimaryColor = wid.RGB(0x17624E) 91 | theme.SecondaryColor = wid.RGB(0x17624E) 92 | theme.TertiaryColor = wid.RGB(0x136669) 93 | theme.ErrorColor = wid.RGB(0xAF2535) 94 | theme.NeutralColor = wid.RGB(0x1D4D7D) 95 | theme.NeutralVariantColor = wid.RGB(0x356057) 96 | theme.NeutralVariantColor = wid.RGB(0x356057) 97 | } else { 98 | // Set up the default pallete 99 | theme.PrimaryColor = wid.RGB(0x45682A) 100 | theme.SecondaryColor = wid.RGB(0x57624E) 101 | theme.TertiaryColor = wid.RGB(0x336669) 102 | theme.ErrorColor = wid.RGB(0xAF2525) 103 | theme.NeutralColor = wid.RGB(0x5D5D5D) 104 | } 105 | theme.UpdateColors() 106 | } 107 | 108 | func onSwitchFontSize() { 109 | if SmallFont { 110 | theme.TextSize = 11 111 | } else { 112 | theme.TextSize = 20 113 | } 114 | wid.FixedFontSize = FixedFont 115 | } 116 | 117 | func onSwitchMode() { 118 | theme.UpdateColors() 119 | } 120 | 121 | var entries = []string{"Classic", "Jazz", "Rock", "Hiphop", ""} 122 | 123 | // Menu demonstrates how to show a list that is generated while drawing it. 124 | func Menu(th *wid.Theme) wid.Wid { 125 | return func(gtx wid.C) wid.D { 126 | widgets := make([]wid.Wid, len(entries)) 127 | // Note that "no" has to be atomic because it is concurrently updated in "ticker" 128 | entries[4] = strconv.Itoa(int(no.Load())) 129 | for i, s := range entries { 130 | widgets[i] = wid.TextButton(th, s, wid.BtnIcon(homeIcon)) 131 | } 132 | return wid.Container(th, wid.SurfaceContainerHigh, 15, th.DefaultPadding, th.DefaultMargin, widgets)(gtx) 133 | } 134 | } 135 | 136 | func Items(th *wid.Theme) wid.Wid { 137 | return wid.Col(wid.SpaceClose, 138 | wid.Container(th, true, wid.PrimaryContainer, 8, th.DefaultPadding, th.DefaultMargin, 139 | "What Buttons are Artists Pushing...", 140 | wid.Label(th, "Music", wid.FontSize(0.66), wid.Fg(th.PrimaryColor)), 141 | wid.Label(th, "What Buttons are Artists Pushing When They Perform Live", wid.FontSize(1.5), wid.PrimCont()), 142 | wid.Container(th, wid.PrimaryContainer, 15, layout.Inset{}, layout.Inset{0, 10, 0, 0}, 143 | wid.ImageFromJpgFile("music.jpg", wid.Contain)), 144 | wid.Row(th, nil, wid.SpaceDistribute, 145 | wid.Label(th, "12 hrs ago", wid.FontSize(0.66), wid.Fg(th.PrimaryColor)), 146 | wid.Button(th, "Save", wid.Do(onSave), wid.BtnIcon(saveIcon), wid.RR(99), wid.Right()), 147 | ), 148 | ), 149 | wid.Container(th, wid.PrimaryContainer, 15, th.DefaultPadding, th.DefaultMargin, 150 | wid.Label(th, "Click Save button to test the confirmation dialog", wid.FontSize(1.0), wid.PrimCont()), 151 | ), 152 | ) 153 | } 154 | 155 | // Demo setup. Called from Setup(), only once - at start of showing it. 156 | // Returns a widget - i.e. a function: func(gtx C) D 157 | func demo(th *wid.Theme) layout.Widget { 158 | return wid.Col(wid.SpaceClose, 159 | wid.Label(th, "Material demo", wid.Middle(), wid.Heading(), wid.Bold(), wid.Role(wid.PrimaryContainer)), 160 | wid.Separator(th, unit.Dp(1.0)), 161 | wid.Row(th, nil, []float32{.5, .8, .5, .5}, 162 | wid.Checkbox(th, "Dark mode", wid.Bool(&th.DarkMode), wid.Do(onSwitchMode), wid.Hint("Select light or dark mode")), 163 | wid.Checkbox(th, "Alt.pallete", wid.Bool(&otherPallete), wid.Do(onClick), wid.Hint("Select an alternative font")), 164 | wid.Checkbox(th, "Small font", wid.Bool(&SmallFont), wid.Do(onSwitchFontSize), wid.Hint("Select normal or small font size")), 165 | wid.Checkbox(th, "Fixed font", wid.Bool(&FixedFont), wid.Do(onSwitchFontSize), wid.Hint("Keep font size when resizing window height")), 166 | ), 167 | wid.Separator(th, unit.Dp(1.0)), 168 | wid.Row(th, nil, []float32{0.3, 0.7}, 169 | // Menu column 170 | Menu(th), 171 | // Items 172 | Items(th), 173 | ), 174 | ) 175 | } 176 | -------------------------------------------------------------------------------- /wid/tooltip.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "gioui.org/io/event" 5 | "image" 6 | "image/color" 7 | "time" 8 | 9 | "gioui.org/font" 10 | 11 | "gioui.org/io/pointer" 12 | "gioui.org/op" 13 | "gioui.org/op/clip" 14 | "gioui.org/op/paint" 15 | "gioui.org/unit" 16 | "gioui.org/widget" 17 | ) 18 | 19 | const ( 20 | tipAreaHoverDelay = time.Millisecond * 900 21 | tipAreaFadeDuration = time.Millisecond * 500 22 | longPressDelay = time.Millisecond * 500 23 | CursorSizeX = 16 24 | CursorSizeY = 32 25 | ) 26 | 27 | // Tooltip implements a material design tool tip as defined at: 28 | // https://material.io/components/tooltips#specs 29 | type TooltipDef struct { 30 | VisibilityAnimation 31 | // MaxWidth is the maximum width of the tool-tip box. Should be less than form width. 32 | MaxWidth unit.Dp 33 | // Position of the last mouse pointer event 34 | position image.Point 35 | Hover InvalidateDeadline 36 | Fgc color.NRGBA 37 | Bgc color.NRGBA 38 | TooltipRR unit.Dp 39 | TextSize unit.Sp 40 | init bool 41 | font font.Font 42 | } 43 | 44 | // DesktopTooltip constructs a tooltip suitable for use on desktop devices. 45 | func Tooltip(th *Theme) TooltipDef { 46 | return TooltipDef{ 47 | Fgc: th.TooltipOnBackground, 48 | Bgc: th.TooltipBackground, 49 | MaxWidth: th.TooltipWidth, 50 | TooltipRR: th.TooltipCornerRadius, 51 | font: font.Font{Weight: font.Medium}, 52 | TextSize: th.TextSize, 53 | } 54 | 55 | } 56 | 57 | // InvalidateDeadline helps to ensure that a frame is generated at a specific 58 | // point in time in the future. It does this by always requesting a future 59 | // invalidation at its target time until it reaches its target time. This 60 | // makes animating delays much cleaner. 61 | type InvalidateDeadline struct { 62 | // The time at which a frame needs to be drawn. 63 | Target time.Time 64 | // Whether the deadline is active. 65 | Active bool 66 | } 67 | 68 | // SetTarget configures a specific time in the future at which a frame should 69 | // be rendered. 70 | func (i *InvalidateDeadline) SetTarget(t time.Time) { 71 | i.Active = true 72 | i.Target = t 73 | } 74 | 75 | // Process checks the current frame time and either requests a future invalidation 76 | // or does nothing. It returns whether the current frame is the frame requested 77 | // by the last call to SetTarget. 78 | func (i *InvalidateDeadline) Process(gtx C) bool { 79 | if !i.Active { 80 | return false 81 | } 82 | if gtx.Now.Before(i.Target) { 83 | Invalidate() 84 | // TODO op.InvalidateOp{At: i.Target}.Add(gtx.Ops) 85 | return false 86 | } 87 | i.Active = false 88 | return true 89 | } 90 | 91 | // ClearTarget cancels a request to invalidate in the future. 92 | func (i *InvalidateDeadline) ClearTarget() { 93 | i.Active = false 94 | } 95 | 96 | // Layout renders the provided widget with the provided tooltip. The tooltip 97 | // will be summoned if the widget is hovered or long-pressed. 98 | func (t *TooltipDef) Layout(gtx C, hint string, th *Theme) D { 99 | if hint == "" { 100 | return D{} 101 | } 102 | if !t.init { 103 | t.init = true 104 | t.VisibilityAnimation.State = Invisible 105 | t.VisibilityAnimation.Duration = tipAreaFadeDuration 106 | } 107 | for { 108 | event, ok := gtx.Event(pointer.Filter{ 109 | Target: t, 110 | Kinds: pointer.Release | pointer.Enter | pointer.Leave | pointer.Move, 111 | }) 112 | if !ok { 113 | break 114 | } 115 | e, ok := event.(pointer.Event) 116 | if !ok { 117 | continue 118 | } 119 | if !t.Visible() { 120 | t.position.X = mouseX - int(e.Position.X) 121 | t.position.Y = mouseY - int(e.Position.Y) 122 | } 123 | switch e.Kind { 124 | case pointer.Enter: 125 | t.Hover.SetTarget(gtx.Now.Add(tipAreaHoverDelay)) 126 | case pointer.Leave: 127 | t.VisibilityAnimation.Disappear(gtx.Now) 128 | t.Hover.ClearTarget() 129 | case pointer.Press: 130 | t.Hover.ClearTarget() 131 | t.Hover.SetTarget(gtx.Now.Add(longPressDelay)) 132 | case pointer.Release: 133 | case pointer.Cancel: 134 | t.Hover.ClearTarget() 135 | default: 136 | } 137 | } 138 | if t.Hover.Process(gtx) { 139 | t.VisibilityAnimation.Appear(gtx.Now) 140 | } 141 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 142 | // Detect pointer movement within gtx.Constraints.Min 143 | r := clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops) 144 | event.Op(gtx.Ops, t) 145 | r.Pop() 146 | extHeight := gtx.Constraints.Min.Y 147 | if t.Visible() { 148 | // p is the inside padding of the tooltip 149 | p := Px(gtx, th.TextSize) / 2 150 | tooltipMacro := op.Record(gtx.Ops) 151 | // Calculate colors according to visibility 152 | v := t.VisibilityAnimation.Revealed(gtx) 153 | bg := WithAlpha(t.Bgc, uint8(v*255)) 154 | t.Fgc = WithAlpha(t.Fgc, uint8(v*255)) 155 | gtx.Constraints.Min = image.Point{} 156 | gtx.Constraints.Max = image.Point{gtx.Metric.Dp(t.MaxWidth), 99999} 157 | rr := Px(gtx, t.TooltipRR) 158 | textMacro := op.Record(gtx.Ops) 159 | textOffset := op.Offset(image.Pt(p, p)).Push(gtx.Ops) 160 | fgCol := op.Record(gtx.Ops) 161 | // Draw text 162 | paint.ColorOp{Color: t.Fgc}.Add(gtx.Ops) 163 | dims := widget.Label{}.Layout(gtx, th.Shaper, t.font, t.TextSize, hint, fgCol.Stop()) 164 | textOffset.Pop() 165 | drawTextOp := textMacro.Stop() 166 | outline := image.Rectangle{Max: image.Pt(gtx.Metric.Dp(t.MaxWidth)+p, dims.Size.Y+p*2)} 167 | // Move the location to the left so it does not go outside the right edge of the form 168 | dx := Min(0, WinX-t.position.X-outline.Max.X-10) 169 | var dy int 170 | if WinY-t.position.Y > extHeight+outline.Max.Y { 171 | dy = Min(extHeight, WinY-t.position.Y) 172 | } else { 173 | dy = -outline.Max.Y - 5 174 | } 175 | op.Offset(image.Pt(dx, dy)).Add(gtx.Ops) 176 | // Fill background and border 177 | paint.FillShape(gtx.Ops, bg, clip.UniformRRect(outline, rr).Op(gtx.Ops)) 178 | paintBorder(gtx, outline, t.Fgc, float32(Px(gtx, unit.Dp(0.75))), Px(gtx, t.TooltipRR)) 179 | // Then actually draw the text 180 | drawTextOp.Add(gtx.Ops) 181 | // End the tooltipMacro and defer drawing so they appear on top of everything else 182 | op.Defer(gtx.Ops, tooltipMacro.Stop()) 183 | } 184 | return D{} 185 | 186 | } 187 | -------------------------------------------------------------------------------- /wid/slider.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/io/event" 7 | "image" 8 | 9 | "gioui.org/io/key" 10 | 11 | "gioui.org/io/semantic" 12 | 13 | "gioui.org/gesture" 14 | "gioui.org/io/pointer" 15 | "gioui.org/layout" 16 | "gioui.org/op" 17 | "gioui.org/op/clip" 18 | "gioui.org/op/paint" 19 | "gioui.org/unit" 20 | ) 21 | 22 | // SliderStyle is the parameters for a slider 23 | type SliderStyle struct { 24 | Base 25 | focused bool 26 | hovered bool 27 | axis layout.Axis 28 | drag gesture.Drag 29 | pos float32 // position normalized to [0, 1] 30 | length float32 31 | min, max float32 32 | Value *float32 33 | keyTag struct{} 34 | } 35 | 36 | // Slider is for selecting a value in a range. 37 | func Slider(th *Theme, value *float32, minV, maxV float32, options ...Option) layout.Widget { 38 | s := SliderStyle{ 39 | min: minV, 40 | max: maxV, 41 | Value: value, 42 | } 43 | s.th = th 44 | s.width = unit.Dp(99999) 45 | for _, option := range options { 46 | option.apply(s) 47 | } 48 | return s.Layout 49 | } 50 | 51 | func (s *SliderStyle) Layout(gtx C) D { 52 | s.handleKeys(gtx) 53 | m := op.Record(gtx.Ops) 54 | dims := s.layout(gtx) 55 | c := m.Stop() 56 | defer clip.Rect{Max: dims.Size}.Push(gtx.Ops).Pop() 57 | event.Op(gtx.Ops, s) 58 | c.Add(gtx.Ops) 59 | return dims 60 | } 61 | 62 | func (s *SliderStyle) handleKeys(gtx C) { 63 | for { 64 | e, ok := gtx.Event( 65 | key.FocusFilter{Target: s}, 66 | key.Filter{Focus: s, Name: key.NameUpArrow, Optional: key.ModCtrl}, 67 | key.Filter{Focus: s, Name: key.NameDownArrow, Optional: key.ModCtrl}, 68 | key.Filter{Focus: s, Name: key.NameLeftArrow, Optional: key.ModCtrl}, 69 | key.Filter{Focus: s, Name: key.NameRightArrow, Optional: key.ModCtrl}, 70 | ) 71 | if !ok { 72 | break 73 | } 74 | if !ok { 75 | continue 76 | } 77 | switch e := e.(type) { 78 | case key.FocusEvent: 79 | s.focused = e.Focus 80 | case key.Event: 81 | if e.State == key.Press { 82 | d := float32(0.01) 83 | if e.Modifiers.Contain(key.ModCtrl) { 84 | d = 0.1 85 | } 86 | switch e.Name { 87 | case key.NameUpArrow, key.NameLeftArrow: 88 | s.pos -= d 89 | case key.NameDownArrow, key.NameRightArrow: 90 | s.pos += d 91 | } 92 | s.setValue() 93 | } 94 | } 95 | } 96 | } 97 | 98 | // Layout will draw the slider 99 | func (s *SliderStyle) layout(gtx C) D { 100 | w := Px(gtx, s.width) 101 | if w < gtx.Constraints.Min.X { 102 | gtx.Constraints.Min.X = w 103 | } 104 | thumbRadius := Px(gtx, s.th.TextSize*0.5) 105 | trackWidth := thumbRadius 106 | 107 | // Keep a minimum length so that the track is always visible. 108 | minLength := thumbRadius + 3*thumbRadius + thumbRadius 109 | // Try to expand to finger size, but only if the constraints allow for it. 110 | touchSizePx := Min(Px(gtx, s.th.FingerSize), s.axis.Convert(gtx.Constraints.Max).Y) 111 | sizeMain := Max(s.axis.Convert(gtx.Constraints.Min).X, minLength) 112 | sizeCross := Max(2*thumbRadius, touchSizePx) 113 | 114 | o := s.axis.Convert(image.Pt(thumbRadius, 0)) 115 | op.Offset(o).Add(gtx.Ops) 116 | gtx.Constraints.Min = s.axis.Convert(image.Pt(sizeMain-2*thumbRadius, sizeCross)) 117 | 118 | disabled := !gtx.Enabled() 119 | semantic.EnabledOp(disabled).Add(gtx.Ops) 120 | semantic.Switch.Add(gtx.Ops) 121 | 122 | size := gtx.Constraints.Min 123 | s.length = float32(s.axis.Convert(size).X) 124 | 125 | for { 126 | e, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(s.axis)) 127 | if !ok { 128 | break 129 | } 130 | if s.length > 0 && (e.Kind == pointer.Press || e.Kind == pointer.Drag) { 131 | xy := e.Position.X 132 | if s.axis == layout.Vertical { 133 | xy = s.length - e.Position.Y 134 | } 135 | s.pos = xy / (float32(thumbRadius) + s.length) 136 | s.setValue() 137 | } else if e.Kind == pointer.Leave { 138 | s.hovered = false 139 | } else if e.Kind == pointer.Enter { 140 | s.hovered = true 141 | } 142 | } 143 | 144 | margin := s.axis.Convert(image.Pt(thumbRadius, 0)) 145 | rect := image.Rectangle{ 146 | Min: margin.Mul(-1), 147 | Max: size.Add(margin), 148 | } 149 | defer clip.Rect(rect).Push(gtx.Ops).Pop() 150 | s.drag.Add(gtx.Ops) 151 | 152 | gtx.Constraints.Min = gtx.Constraints.Min.Add(s.axis.Convert(image.Pt(0, sizeCross))) 153 | thumbPos := thumbRadius + int(s.pos*(float32(sizeMain-thumbRadius*5))) 154 | 155 | color := WithAlpha(s.th.Fg[Canvas], 175) 156 | if !gtx.Enabled() { 157 | color = Disabled(color) 158 | } 159 | 160 | // Draw track before thumb. 161 | track := image.Rectangle{ 162 | Min: s.axis.Convert(image.Pt(thumbRadius, sizeCross/2-trackWidth/2)), 163 | Max: s.axis.Convert(image.Pt(thumbPos, sizeCross/2+trackWidth/2)), 164 | } 165 | paint.FillShape(gtx.Ops, color, clip.RRect{ 166 | Rect: image.Rect(track.Min.X, track.Min.Y, track.Max.X, track.Max.Y), 167 | SW: 5, NW: 5, NE: 5, SE: 5, 168 | }.Op(gtx.Ops)) 169 | 170 | // Draw track after thumb. 171 | track = image.Rectangle{ 172 | Min: s.axis.Convert(image.Pt(thumbPos, s.axis.Convert(track.Min).Y)), 173 | Max: s.axis.Convert(image.Pt(sizeMain-2*thumbRadius, s.axis.Convert(track.Max).Y)), 174 | } 175 | paint.FillShape(gtx.Ops, WithAlpha(color, 80), clip.RRect{ 176 | Rect: image.Rect(track.Min.X, track.Min.Y, track.Max.X, track.Max.Y), 177 | SW: 5, NW: 5, NE: 5, SE: 5, 178 | }.Op(gtx.Ops)) 179 | 180 | // Draw thumb. 181 | pt := s.axis.Convert(image.Pt(thumbPos, sizeCross/2)) 182 | if s.hovered || gtx.Focused(s) { 183 | r := int(float32(thumbRadius) * 1.35) 184 | ul := image.Pt(pt.X-r, pt.Y-r) 185 | lr := image.Pt(pt.X+r, pt.Y+r) 186 | paint.FillShape(gtx.Ops, MulAlpha(s.th.Fg[Canvas], 88), clip.Ellipse{Min: ul, Max: lr}.Op(gtx.Ops)) 187 | } 188 | r := thumbRadius 189 | ul := image.Pt(pt.X-r, pt.Y-r) 190 | lr := image.Pt(pt.X+r, pt.Y+r) 191 | paint.FillShape(gtx.Ops, s.th.Fg[Canvas], clip.Ellipse{Min: ul, Max: lr}.Op(gtx.Ops)) 192 | 193 | return layout.Dimensions{Size: size} 194 | } 195 | 196 | func (s *SliderStyle) setValue() { 197 | if s.pos < 0 { 198 | s.pos = 0 199 | } 200 | if s.pos > 1.0 { 201 | s.pos = 1.0 202 | } 203 | GuiLock.Lock() 204 | *s.Value = s.pos*(s.max-s.min) + s.min 205 | GuiLock.Unlock() 206 | } 207 | -------------------------------------------------------------------------------- /examples/buttons/buttons.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "gioui.org/app" 7 | "gioui.org/font/gofont" 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | "github.com/jkvatne/gio-v/wid" 11 | "golang.org/x/exp/shiny/materialdesign/icons" 12 | "image/color" 13 | ) 14 | 15 | var ( 16 | theme *wid.Theme // the theme selected 17 | form layout.Widget 18 | win app.Window 19 | homeIcon *wid.Icon 20 | checkIcon *wid.Icon 21 | greenFlag = false // the state variable for the button color 22 | homeBg = wid.RGB(0xF288F2) 23 | homeFg = wid.RGB(0x0902200) 24 | ) 25 | 26 | func main() { 27 | homeIcon, _ = wid.NewIcon(icons.ActionHome) 28 | checkIcon, _ = wid.NewIcon(icons.NavigationCheck) 29 | theme = wid.NewTheme(gofont.Collection(), 14) 30 | onClick() 31 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(1100), unit.Dp(520))) 32 | wid.Run(&win, &form, theme) 33 | app.Main() 34 | } 35 | 36 | func onClick() { 37 | greenFlag = !greenFlag 38 | if greenFlag { 39 | theme.PrimaryColor = color.NRGBA{A: 0xff, R: 0x20, G: 0x7d, B: 0x20} 40 | } else { 41 | theme.PrimaryColor = color.NRGBA{A: 0xff, R: 0x10, G: 0x10, B: 0xff} 42 | } 43 | theme.UpdateColors() 44 | form = demo(theme) 45 | } 46 | 47 | // Demo setup. Called from Setup(), only once - at start of showing it. 48 | // Returns a widget -- i.e. a function: func(gtx C) D 49 | func demo(th *wid.Theme) layout.Widget { 50 | return wid.Col(wid.SpaceClose, 51 | wid.Label(th, "Buttons demo page", wid.Middle(), wid.Heading(), wid.Bold(), wid.Role(wid.PrimaryContainer), 52 | wid.Role(wid.PrimaryContainer), wid.Pads(10)), 53 | wid.Label(th, "Buttons with fixed length and large font, close together at left side, using wid.SpaceClose"), 54 | wid.Row(th, wid.SpaceClose, 55 | wid.Button(th, "Change color 1", wid.Do(onClick), wid.W(450), wid.FontSize(2.5)), 56 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.FontSize(2.5), wid.Role(wid.PrimaryContainer)), 57 | ), 58 | wid.Separator(th, unit.Dp(1.0)), 59 | wid.Label(th, "Button spaced closely, left adjusted"), 60 | wid.Row(th, wid.SpaceClose, 61 | wid.RoundButton(th, homeIcon, 62 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 63 | wid.Button(th, "Home", wid.BtnIcon(homeIcon), wid.Bg(&homeBg), wid.Fg(&homeFg), 64 | wid.Hint("This is another hint")), 65 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.Role(wid.Secondary)), 66 | wid.Button(th, "Change color 2", wid.Do(onClick)), 67 | wid.TextButton(th, "Text button"), 68 | wid.OutlineButton(th, "Outline button", wid.Hint("An outlined button")), 69 | ), 70 | wid.Separator(th, unit.Dp(1.0)), 71 | wid.Label(th, "Buttons distributed, equal space to each button"), 72 | wid.Row(th, wid.SpaceDistribute, 73 | wid.RoundButton(th, homeIcon, 74 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 75 | wid.Button(th, "Home", wid.BtnIcon(homeIcon), wid.Bg(&homeBg), wid.Fg(&homeFg), 76 | wid.Hint("This is another hint")), 77 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.Role(wid.Secondary)), 78 | wid.Button(th, "Change color 3", wid.Do(onClick)), 79 | wid.TextButton(th, "Text button"), 80 | wid.OutlineButton(th, "Outline button", wid.Hint("An outlined button")), 81 | ), 82 | wid.Separator(th, unit.Dp(1.0)), 83 | wid.Label(th, "Buttons with fixed spacing given by em sizes 7,20,20,20,20,20,"), 84 | wid.Row(th, []float32{7, 20, 20, 20, 20, 20}, 85 | wid.RoundButton(th, homeIcon, 86 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 87 | wid.Button(th, "Home", wid.BtnIcon(homeIcon), wid.Bg(&homeBg), wid.Fg(&homeFg), 88 | wid.Hint("This is another hint")), 89 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.W(150), wid.Role(wid.Secondary)), 90 | wid.Button(th, "Change color 4", wid.Do(onClick), wid.W(150)), 91 | wid.TextButton(th, "Text button"), 92 | wid.OutlineButton(th, "Outline button", wid.Hint("An outlined button")), 93 | ), 94 | wid.Separator(th, unit.Dp(1.0)), 95 | 96 | wid.Label(th, "Buttons with relative spacing given by weights 0.2, 0.4, 0.4, 0.4, 0.4, 0.4"), 97 | wid.Row(th, []float32{0.2, .4, .4, .4, .4, .4}, 98 | wid.RoundButton(th, homeIcon, 99 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 100 | wid.Button(th, "Home", wid.BtnIcon(homeIcon), wid.Bg(&homeBg), wid.Fg(&homeFg), 101 | wid.Hint("This is another hint")), 102 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.W(150), wid.Role(wid.Secondary)), 103 | wid.Button(th, "Change color 5", wid.Do(onClick), wid.W(150)), 104 | wid.TextButton(th, "Text button"), 105 | wid.OutlineButton(th, "Outline button", wid.Hint("An outlined button")), 106 | ), 107 | wid.Separator(th, unit.Dp(1.0)), 108 | wid.Label(th, "Two buttons, aligned center, using wid.SpaceCenter"), 109 | wid.Row(th, wid.SpaceCenter, 110 | wid.Button(th, "Save", wid.W(150), wid.Sec(), 111 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 112 | wid.Button(th, "Cancel", wid.W(150), (wid.Prim()), 113 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 114 | ), 115 | wid.Separator(th, unit.Dp(1.0)), 116 | wid.Label(th, "Two buttons, aligned right, using wid.SpaceRightAdjust"), 117 | wid.Row(th, wid.SpaceRightAdjust, 118 | wid.Button(th, "Save", wid.W(150), wid.Sec(), 119 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 120 | wid.Button(th, "Cancel", wid.W(150), (wid.Prim()), 121 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 122 | ), 123 | wid.Separator(th, unit.Dp(1.0)), 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /wid/rgba.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "fmt" 7 | "image/color" 8 | "math" 9 | ) 10 | 11 | // Some default colors 12 | var ( 13 | Red = RGB(0xFF0000) 14 | Yellow = RGB(0xFFFF00) 15 | Green = RGB(0x00FF00) 16 | Blue = RGB(0x0000FF) 17 | White = RGB(0xFFFFFF) 18 | Black = RGB(0x000000) 19 | ) 20 | 21 | const inf = 5000 22 | 23 | // DeEmphasis will change a color to a less prominent color 24 | // In light mode, colors will be lighter, in dark mode, colors will be darker 25 | // The amount of darkening is greater than the amount of lightening 26 | func DeEmphasis(c color.NRGBA, amount uint8) color.NRGBA { 27 | if Luminance(c) < 128 { 28 | return MulAlpha(c, 255-amount) 29 | } 30 | return MulAlpha(c, amount) 31 | } 32 | 33 | // Disabled blends color towards the luminance and multiplies alpha. 34 | // Blending towards luminance will desaturate the color. 35 | // Multiplying alpha blends the color together more with the background. 36 | func Disabled(c color.NRGBA) (d color.NRGBA) { 37 | const r = 80 // blend ratio 38 | lum := Luminance(c) 39 | return color.NRGBA{ 40 | R: byte((int(c.R)*r + int(lum)*(256-r)) / 256), 41 | G: byte((int(c.G)*r + int(lum)*(256-r)) / 256), 42 | B: byte((int(c.B)*r + int(lum)*(256-r)) / 256), 43 | A: byte(int(c.A) * (128 + 32) / 256), 44 | } 45 | } 46 | 47 | // ColDisabled returns the disabled color of c, depending on the disabled flag. 48 | func ColDisabled(c color.NRGBA, disabled bool) color.NRGBA { 49 | if disabled { 50 | return Disabled(c) 51 | } 52 | return c 53 | } 54 | 55 | // Hovered blends color towards a brighter color. 56 | func Hovered(c color.NRGBA) (d color.NRGBA) { 57 | const r = 0x40 // lighten ratio 58 | return color.NRGBA{ 59 | R: byte(255 - int(255-c.R)*(255-r)/256), 60 | G: byte(255 - int(255-c.G)*(255-r)/256), 61 | B: byte(255 - int(255-c.B)*(255-r)/256), 62 | A: c.A, 63 | } 64 | } 65 | 66 | // Interpolate returns a color in between given colors a and b, depending on progress from 0.0 to 1.0 67 | func Interpolate(a, b color.NRGBA, progress float32) color.NRGBA { 68 | var out color.NRGBA 69 | out.R = uint8(int16(a.R) - int16(float32(int16(a.R)-int16(b.R))*progress)) 70 | out.G = uint8(int16(a.G) - int16(float32(int16(a.G)-int16(b.G))*progress)) 71 | out.B = uint8(int16(a.B) - int16(float32(int16(a.B)-int16(b.B))*progress)) 72 | out.A = uint8(int16(a.A) - int16(float32(int16(a.A)-int16(b.A))*progress)) 73 | return out 74 | } 75 | 76 | // Gray returns a NRGBA color with the same luminance as the parameter 77 | func Gray(c color.NRGBA) color.NRGBA { 78 | l := Luminance(c) 79 | return color.NRGBA{R: l, G: l, B: l, A: c.A} 80 | } 81 | 82 | // RGB creates a NRGBA color from its hex code, with alpha=255 83 | func RGB(c uint32) color.NRGBA { 84 | return ARGB(0xff000000 | c) 85 | } 86 | 87 | // ARGB creates a NRGBA color from its hex code 88 | func ARGB(c uint32) color.NRGBA { 89 | return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} 90 | } 91 | 92 | // WithAlpha returns the input color with the new alpha value. 93 | func WithAlpha(c color.NRGBA, alpha uint8) color.NRGBA { 94 | c.A = alpha 95 | return c 96 | } 97 | 98 | // MulAlpha applies the alpha to the color. 99 | func MulAlpha(c color.NRGBA, alpha uint8) color.NRGBA { 100 | c.A = uint8(uint32(c.A) * uint32(alpha) / 0xFF) 101 | return c 102 | } 103 | 104 | // Luminance is a fast approximate version of RGBA.Luminance. 105 | func Luminance(c color.NRGBA) byte { 106 | const ( 107 | r = 13933 // 0.2126 * 256 * 256 108 | g = 46871 // 0.7152 * 256 * 256 109 | b = 4732 // 0.0722 * 256 * 256 110 | t = r + g + b 111 | ) 112 | return byte((r*int(c.R) + g*int(c.G) + b*int(c.B)) / t) 113 | } 114 | 115 | // Rgb2hsl is internal implementation converting RGB to HSL, HSV, or HSI. 116 | // Basically a direct implementation of this: https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach 117 | func Rgb2hsl(c color.NRGBA) (float64, float64, float64) { 118 | var h, s, lvi float64 119 | var huePrime float64 120 | r := float64(c.R) / 256.0 121 | g := float64(c.G) / 256.0 122 | b := float64(c.B) / 256.0 123 | maxCol := math.Max(math.Max(r, g), b) 124 | minCOl := math.Min(math.Min(r, g), b) 125 | chroma := maxCol - minCOl 126 | if chroma == 0 { 127 | h = 0 128 | } else { 129 | if r == maxCol { 130 | huePrime = math.Mod((g-b)/chroma, 6) 131 | } else if g == maxCol { 132 | huePrime = ((b - r) / chroma) + 2 133 | 134 | } else if b == maxCol { 135 | huePrime = ((r - g) / chroma) + 4 136 | 137 | } 138 | 139 | h = huePrime * 60 140 | } 141 | if r == g && g == b { 142 | lvi = r 143 | } else { 144 | lvi = (maxCol + minCOl) / 2 145 | } 146 | if lvi == 1 { 147 | s = 0 148 | } else { 149 | s = chroma / (1 - math.Abs(2*lvi-1)) 150 | } 151 | 152 | if math.IsNaN(s) { 153 | s = 0 154 | } 155 | 156 | if h < 0 { 157 | h = 360 + h 158 | } 159 | 160 | return h, s, lvi 161 | } 162 | 163 | // Hsl2rgb is internal HSV->RGB function for doing conversions using float inputs (saturation, value) and 164 | // outputs (for R, G, and B). 165 | // Basically a direct implementation of this: https://en.wikipedia.org/wiki/HSL_and_HSV#Converting_to_RGB 166 | func Hsl2rgb(hueDegrees float64, saturation float64, light float64) color.NRGBA { 167 | var r, g, b float64 168 | hueDegrees = math.Mod(hueDegrees, 360) 169 | if saturation == 0 { 170 | r = light 171 | g = light 172 | b = light 173 | } else { 174 | var chroma float64 175 | var m float64 176 | chroma = (1 - math.Abs((2*light)-1)) * saturation 177 | hueSector := hueDegrees / 60 178 | intermediate := chroma * (1 - math.Abs(math.Mod(hueSector, 2)-1)) 179 | switch { 180 | case hueSector >= 0 && hueSector <= 1: 181 | r = chroma 182 | g = intermediate 183 | b = 0 184 | case hueSector > 1 && hueSector <= 2: 185 | r = intermediate 186 | g = chroma 187 | b = 0 188 | case hueSector > 2 && hueSector <= 3: 189 | r = 0 190 | g = chroma 191 | b = intermediate 192 | case hueSector > 3 && hueSector <= 4: 193 | r = 0 194 | g = intermediate 195 | b = chroma 196 | case hueSector > 4 && hueSector <= 5: 197 | r = intermediate 198 | g = 0 199 | b = chroma 200 | case hueSector > 5 && hueSector <= 6: 201 | r = chroma 202 | g = 0 203 | b = intermediate 204 | default: 205 | panic(fmt.Errorf("hue input %v yielded sector %v", hueDegrees, hueSector)) 206 | } 207 | m = light - (chroma / 2) 208 | r += m 209 | g += m 210 | b += m 211 | } 212 | return color.NRGBA{R: uint8(r*255 + 0.4), G: uint8(g*255 + 0.4), B: uint8(b*255 + 0.4), A: 255} 213 | } 214 | -------------------------------------------------------------------------------- /wid/animation.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "gioui.org/op" 8 | ) 9 | 10 | // VisibilityAnimation holds the animation state for animations that transition between a 11 | // "visible" and "invisible" state for a fixed duration of time. 12 | type VisibilityAnimation struct { 13 | // How long does the animation last 14 | time.Duration 15 | State VisibilityAnimationState 16 | Started time.Time 17 | } 18 | 19 | // Revealed returns the fraction of the animated entity that should be revealed at the current 20 | // time in the animation. This fraction is computed with linear interpolation. 21 | // 22 | // Revealed should be invoked during every frame that v.Animating() returns true. 23 | // 24 | // If the animation reaches its end this frame, Revealed will transition it to a non-animating 25 | // state automatically. 26 | // 27 | // If the animation is in the process of animating, calling Revealed will automatically add 28 | // an InvalidateOp to the provided Context to ensure that the next frame will be generated 29 | // promptly. 30 | func (v *VisibilityAnimation) Revealed(gtx C) float32 { 31 | if v.Animating() { 32 | gtx.Execute(op.InvalidateCmd{}) 33 | } 34 | if v.Duration == time.Duration(0) { 35 | v.Duration = time.Second 36 | } 37 | progress := float32(gtx.Now.Sub(v.Started).Milliseconds()) / float32(v.Milliseconds()) 38 | if progress >= 1 { 39 | if v.State == Appearing { 40 | v.State = Visible 41 | } else if v.State == Disappearing { 42 | v.State = Invisible 43 | } 44 | } 45 | switch v.State { 46 | case Visible: 47 | return 1 48 | case Invisible: 49 | return 0 50 | case Appearing: 51 | return progress 52 | case Disappearing: 53 | return 1 - progress 54 | } 55 | return progress 56 | } 57 | 58 | // Visible returns whether any part of the animated entity should be visible during the 59 | // current animation frame. 60 | func (v *VisibilityAnimation) Visible() bool { 61 | return v.State != Invisible 62 | } 63 | 64 | // Animating returns whether the animation is either in the process of appearing or 65 | // disappearing. 66 | func (v *VisibilityAnimation) Animating() bool { 67 | return v.State == Appearing || v.State == Disappearing 68 | } 69 | 70 | // Appear triggers the animation to begin becoming visible at the provided time. It is 71 | // a no-op if the animation is already visible. 72 | func (v *VisibilityAnimation) Appear(now time.Time) { 73 | if !v.Visible() && !v.Animating() { 74 | v.State = Appearing 75 | v.Started = now 76 | } 77 | } 78 | 79 | // Disappear triggers the animation to begin becoming invisible at the provided time. 80 | // It is a no-op if the animation is already invisible. 81 | func (v *VisibilityAnimation) Disappear(now time.Time) { 82 | if v.Visible() { 83 | if v.Animating() { 84 | v.Duration = now.Sub(v.Started) 85 | } 86 | v.State = Disappearing 87 | v.Started = now 88 | } 89 | } 90 | 91 | // ToggleVisibility will make an invisible animation begin the process of becoming 92 | // visible and a visible animation begin the process of disappearing. 93 | func (v *VisibilityAnimation) ToggleVisibility(now time.Time) { 94 | if v.Visible() { 95 | v.Disappear(now) 96 | } else { 97 | v.Appear(now) 98 | } 99 | } 100 | 101 | func (v *VisibilityAnimation) String(gtx C) string { 102 | return fmt.Sprintf( 103 | "State: %v, Revealed: %f, Duration: %v, Started: %v", 104 | v.State, 105 | v.Revealed(gtx), 106 | v.Duration, 107 | v.Started.Local(), 108 | ) 109 | } 110 | 111 | // VisibilityAnimationState represents possible states that a VisibilityAnimation can 112 | // be in. 113 | type VisibilityAnimationState int 114 | 115 | // Visibility constants 116 | const ( 117 | Visible VisibilityAnimationState = iota 118 | Disappearing 119 | Appearing 120 | Invisible 121 | ) 122 | 123 | func (v VisibilityAnimationState) String() string { 124 | switch v { 125 | case Visible: 126 | return "visible" 127 | case Disappearing: 128 | return "disappearing" 129 | case Appearing: 130 | return "appearing" 131 | case Invisible: 132 | return "invisible" 133 | default: 134 | return "invalid VisibilityAnimationState" 135 | } 136 | } 137 | 138 | // Progress is an animation primitive that tracks progress of time over a fixed 139 | // duration as a float between [0, 1]. 140 | // 141 | // Progress is reversible. 142 | // 143 | // Widgets map async UI events to state changes: stop, forward, reverse. 144 | // Widgets then interpolate visual data based on progress value. 145 | // 146 | // Update method must be called every tick to HandleEvents the progress value. 147 | type Progress struct { 148 | progress float32 149 | duration time.Duration 150 | began time.Time 151 | direction ProgressDirection 152 | active bool 153 | } 154 | 155 | // ProgressDirection specifies how to HandleEvents progress every tick. 156 | type ProgressDirection int 157 | 158 | const ( 159 | // Forward progresses from 0 to 1. 160 | Forward ProgressDirection = iota 161 | // Reverse progresses from 1 to 0. 162 | Reverse 163 | ) 164 | 165 | // Progress reports the current progress as a float between [0, 1]. 166 | func (p *Progress) Progress() float32 { 167 | if p.progress < 0.0 { 168 | return 0.0 169 | } 170 | if p.progress > 1.0 { 171 | return 1.0 172 | } 173 | return p.progress 174 | } 175 | 176 | // Absolute reports the absolute progress, ignoring direction. 177 | func (p *Progress) Absolute() float32 { 178 | if p.direction == Forward { 179 | return p.Progress() 180 | } 181 | return 1 - p.Progress() 182 | } 183 | 184 | // Direction reports the current direction. 185 | func (p *Progress) Direction() ProgressDirection { 186 | return p.direction 187 | } 188 | 189 | // Started reports true if progression has started. 190 | func (p *Progress) Started() bool { 191 | return p.active 192 | } 193 | 194 | // Finished is true when animation is done 195 | func (p *Progress) Finished() bool { 196 | switch p.direction { 197 | case Forward: 198 | return p.progress >= 1.0 199 | case Reverse: 200 | return p.progress <= 0.0 201 | } 202 | return false 203 | } 204 | 205 | // Start the progress in the given direction over the given duration. 206 | func (p *Progress) Start(began time.Time, direction ProgressDirection, duration time.Duration) { 207 | if !p.active { 208 | p.active = true 209 | p.began = began 210 | p.direction = direction 211 | p.duration = duration 212 | p.Update(began) 213 | } 214 | } 215 | 216 | // Stop the progress. 217 | func (p *Progress) Stop() { 218 | p.active = false 219 | } 220 | 221 | // Update will do HandleEvents now 222 | func (p *Progress) Update(now time.Time) { 223 | if !p.Started() || p.Finished() { 224 | p.Stop() 225 | return 226 | } 227 | var ( 228 | elapsed = now.Sub(p.began).Milliseconds() 229 | total = p.duration.Milliseconds() 230 | ) 231 | switch p.direction { 232 | case Forward: 233 | p.progress = float32(elapsed) / float32(total) 234 | case Reverse: 235 | p.progress = 1 - float32(elapsed)/float32(total) 236 | } 237 | } 238 | 239 | func (d ProgressDirection) String() string { 240 | switch d { 241 | case Forward: 242 | return "forward" 243 | case Reverse: 244 | return "reverse" 245 | } 246 | return "unknown" 247 | } 248 | -------------------------------------------------------------------------------- /wid/row.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/op/clip" 7 | "gioui.org/op/paint" 8 | "gioui.org/unit" 9 | "image" 10 | "image/color" 11 | 12 | "gioui.org/widget" 13 | 14 | "gioui.org/layout" 15 | "gioui.org/op" 16 | ) 17 | 18 | type rowDef struct { 19 | widget.Clickable 20 | th *Theme 21 | padTop unit.Dp 22 | padBtm unit.Dp 23 | gridLineWidth unit.Dp 24 | gridColor color.NRGBA 25 | } 26 | 27 | // SpaceClose is a shortcut for specifying that the row elements are placed close together, left to right 28 | var SpaceClose []float32 29 | 30 | // SpaceDistribute should disribute the widgets on a row evenly, with equal space for each 31 | var SpaceDistribute = []float32{1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0} 32 | var SpaceRightAdjust = []float32{-1.0} 33 | var SpaceCenter = []float32{-2.0} 34 | var FlexInset = layout.Inset{-1, -1, -1, -1} 35 | var NoInset = layout.Inset{} 36 | 37 | // calcWidths will calculate widths 38 | func calcWidths(gtx C, textSize unit.Sp, weights []float32, count int) (widths []int) { 39 | w := make([]float32, count) 40 | widths = make([]int, count) 41 | if len(weights) >= 1 && weights[0] == -1 { 42 | widths[0] = -1 43 | return widths 44 | } 45 | for i := 0; i < len(w); i++ { 46 | if len(weights) == 0 { 47 | // If weights is nil, place all widgets as close as possible 48 | w[i] = 0.0 49 | } else if len(weights) == 1 && weights[0] == 1.0 { 50 | // If weights are {1.0} then distribute equaly (like equaly1,1,1,1...) 51 | w[i] = 1.0 52 | } else if i < len(weights) && weights[i] > 1.0 { 53 | // Weights > 1 is given in characters, do rescale to pixels 54 | w[i] = float32(Px(gtx, textSize*unit.Sp(weights[i])/2)) 55 | } else if i < len(weights) { 56 | w[i] = weights[i] 57 | } 58 | if w[i] == 0.0 { 59 | w[i] = float32(widths[i]) 60 | } 61 | } 62 | fracSum := float32(0.0) 63 | fixSum := float32(0.0) 64 | for i, w := range w { 65 | if w <= 1.0 { 66 | fracSum += w 67 | } else { 68 | if widths[i] > 0 { 69 | fixSum += float32(widths[i]) 70 | } else { 71 | fixSum += w 72 | } 73 | } 74 | } 75 | scale := float32(1.0) 76 | if fracSum > 0 { 77 | scale = (float32(gtx.Constraints.Min.X) - fixSum) / fracSum 78 | } 79 | for i := range w { 80 | if w[i] <= 1.0 { 81 | widths[i] = Max(0, int(w[i]*scale)) 82 | } else { 83 | widths[i] = Min(int(w[i]), gtx.Constraints.Min.X) 84 | } 85 | } 86 | return widths 87 | } 88 | 89 | // GridRow returns a widget grid row with a grid separating columns and rows 90 | func GridRow(th *Theme, option ...interface{}) layout.Widget { 91 | return Row(th, option...) 92 | } 93 | 94 | // Row returns a widget grid row with selectable color. 95 | // func Row(th *Theme, pbgColor *color.NRGBA, weights []float32, widgets ...layout.Widget) layout.Widget { 96 | func Row(th *Theme, option ...interface{}) layout.Widget { 97 | r := rowDef{ 98 | th: th, 99 | padTop: th.RowPadTop, 100 | padBtm: th.RowPadBtm, 101 | } 102 | bgColor := color.NRGBA{} 103 | var weights []float32 104 | var widgets []layout.Widget 105 | i := 0 106 | for ; i < len(option); i++ { 107 | if v, ok := option[i].(*color.NRGBA); ok { 108 | bgColor = *v 109 | } else if v, ok := option[i].([]float32); ok { 110 | weights = v 111 | } else if v, ok := option[i].(unit.Dp); ok { 112 | r.gridLineWidth = v 113 | } else if v, ok := option[i].(layout.Widget); ok { 114 | widgets = append(widgets, v) 115 | } 116 | } 117 | return func(gtx C) D { 118 | return r.rowLayout(gtx, th.TextSize, bgColor, weights, widgets...) 119 | } 120 | } 121 | 122 | func (r *rowDef) rowLayout(gtx C, textSize unit.Sp, bgColor color.NRGBA, weights []float32, widgets ...layout.Widget) D { 123 | call := make([]op.CallOp, len(widgets)) 124 | dim := make([]D, len(widgets)) 125 | w := make([]float32, len(widgets)) 126 | copy(w, weights) 127 | if len(weights) == 1 && w[0] == -2 { 128 | w[0] = 0 129 | } 130 | // Calculate fixed-width columns (with weight=0) 131 | for i, child := range widgets { 132 | if i < len(w) && w[i] == 0 { 133 | macro := op.Record(gtx.Ops) 134 | c := gtx 135 | c.Constraints.Min.X = 0 136 | dim[i] = child(c) 137 | // Back calculate equivalent width in char widths. Add 1 to avoid rounding errors. 138 | w[i] = 1 + 2*float32(dim[i].Size.X)/float32(Px(gtx, textSize)) 139 | call[i] = macro.Stop() 140 | } 141 | } 142 | widths := calcWidths(gtx, textSize, w, len(widgets)) 143 | // Check child sizes and make macros for each widget in a row 144 | yMax := 0 145 | totSize := 0 146 | c := gtx 147 | pos := make([]int, len(widgets)+1) 148 | // For each column in the row, make macros to draw the widget 149 | for i, child := range widgets { 150 | if len(widths) >= 1 && widths[0] <= -1.0 { 151 | c.Constraints.Min.X = 0 152 | } else if len(widths) > i { 153 | c.Constraints.Max.X = widths[i] 154 | if widths[i] == 0 { 155 | c.Constraints.Max.X = inf 156 | } 157 | c.Constraints.Min.X = widths[i] 158 | } else { 159 | if widths[i] == 0 { 160 | c.Constraints.Max.X = inf 161 | } 162 | c.Constraints.Max.X = widths[i] 163 | c.Constraints.Min.X = 0 164 | } 165 | macro := op.Record(c.Ops) 166 | dim[i] = child(c) 167 | if widths[i] < dim[i].Size.X { 168 | pos[i+1] = pos[i] + dim[i].Size.X 169 | } else { 170 | pos[i+1] = pos[i] + widths[i] 171 | } 172 | totSize = pos[i+1] 173 | call[i] = macro.Stop() 174 | if yMax < dim[i].Size.Y { 175 | yMax = dim[i].Size.Y 176 | } 177 | } 178 | if len(widths) >= 1 && widths[0] == -1.0 { 179 | delta := Max(gtx.Constraints.Max.X-totSize, 0) 180 | for i := 0; i < len(pos); i++ { 181 | pos[i] += delta 182 | } 183 | } else if len(weights) == 1 && weights[0] == -2.0 { 184 | delta := Max(gtx.Constraints.Max.X-totSize, 0) / 2 185 | for i := 0; i < len(pos); i++ { 186 | pos[i] += delta 187 | } 188 | } 189 | macro := op.Record(gtx.Ops) 190 | // Generate all the rendering commands for the children, 191 | // translated to correct location. 192 | yMax += Px(gtx, r.padBtm+r.padTop) 193 | for i := range widgets { 194 | trans := op.Offset(image.Pt(pos[i], 0)).Push(gtx.Ops) 195 | call[i].Add(gtx.Ops) 196 | // Draw a vertical separator 197 | if r.gridLineWidth > 0 { 198 | gw := Px(gtx, r.gridLineWidth) 199 | outline := image.Rect(gw/2, gw/2, pos[i+1]-pos[i]-gw/2, yMax) 200 | paint.FillShape(gtx.Ops, 201 | Black, 202 | clip.Stroke{ 203 | Path: clip.Rect(outline).Path(), 204 | Width: float32(gw), 205 | }.Op(), 206 | ) 207 | } 208 | trans.Pop() 209 | } 210 | // The row width is now the position after the last drawn widget + padBtm 211 | dims := D{Size: image.Pt(pos[len(widgets)], yMax)} 212 | drawAll := macro.Stop() 213 | // Draw background. 214 | defer clip.Rect{Max: image.Pt(dims.Size.X /*gtx.Constraints.Max.X*/, dims.Size.Y)}.Push(gtx.Ops).Pop() 215 | paint.ColorOp{Color: bgColor}.Add(gtx.Ops) 216 | paint.PaintOp{}.Add(gtx.Ops) 217 | // Skip the top padding by offseting distance padTop 218 | defer op.Offset(image.Pt(0, Px(gtx, r.padTop))).Push(gtx.Ops).Pop() 219 | // Then play the macro to draw all the children. 220 | drawAll.Add(gtx.Ops) 221 | return dims 222 | } 223 | -------------------------------------------------------------------------------- /wid/scrollbar.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image" 7 | 8 | "gioui.org/gesture" 9 | "gioui.org/io/key" 10 | "gioui.org/io/pointer" 11 | "gioui.org/layout" 12 | "gioui.org/op" 13 | ) 14 | 15 | // Scrollbar holds the persistent state for an area that can 16 | // display a scrollbar. In particular, it tracks the position of a 17 | // viewport along a one-dimensional region of content. The viewport's 18 | // position can be adjusted by drag operations along the display area, 19 | // or by clicks within the display area. 20 | // 21 | // Scrollbar additionally detects when a scroll indicator region is 22 | // hovered. 23 | type Scrollbar struct { 24 | track, indicator gesture.Click 25 | drag gesture.Drag 26 | delta float32 27 | dragging bool 28 | oldDragPos float32 29 | } 30 | 31 | // Layout updates the internal state of the scrollbar based on events 32 | // since the previous call to Layout. The provided axis will be used to 33 | // normalize input event coordinates and constraints into an axis- 34 | // independent format. viewportStart is the position of the beginning 35 | // of the scrollable viewport relative to the underlying content expressed 36 | // as a value in the range [0,1]. viewportEnd is the position of the end 37 | // of the viewport relative to the underlying content, also expressed 38 | // as a value in the range [0,1]. For example, if viewportStart is 0.25 39 | // and viewportEnd is .5, the viewport described by the scrollbar is 40 | // currently showing the second quarter of the underlying content. 41 | func (s *Scrollbar) Layout(gtx C, axis layout.Axis, viewportStart, viewportEnd float32) D { 42 | // Calculate the length of the major axis of the scrollbar. This is 43 | // the length of the track within which pointer events occur, and is 44 | // used to scale those interactions. 45 | trackHeight := float32(axis.Convert(gtx.Constraints.Max).X) 46 | s.delta = 0 47 | 48 | centerOnClick := func(normalizedPos float32) { 49 | // When the user clicks on the scrollbar we center on that point, respecting the limits of the beginning and end 50 | // of the scrollbar. 51 | // 52 | // Centering gives a consistent experience whether the user clicks above or below the indicator. 53 | target := normalizedPos - (viewportEnd-viewportStart)/2 54 | s.delta += target - viewportStart 55 | if s.delta < -viewportStart { 56 | s.delta = -viewportStart 57 | } else if s.delta > 1-viewportEnd { 58 | s.delta = 1 - viewportEnd 59 | } 60 | } 61 | 62 | // Jump to a click in the track. 63 | for { 64 | event, ok := s.track.Update(gtx.Source) 65 | if !ok { 66 | break 67 | } 68 | if event.Kind != gesture.KindClick || 69 | event.Modifiers != key.Modifiers(0) || 70 | event.NumClicks > 1 { 71 | continue 72 | } 73 | pos := axis.Convert(image.Point{ 74 | X: event.Position.X, 75 | Y: event.Position.Y, 76 | }) 77 | normalizedPos := float32(pos.X) / trackHeight 78 | // Clicking on the indicator should not jump to that position on the track. The user might've just intended to 79 | // drag and changed their mind. 80 | if !(normalizedPos >= viewportStart && normalizedPos <= viewportEnd) { 81 | centerOnClick(normalizedPos) 82 | } 83 | } 84 | 85 | // Offset to account for any drags. 86 | for { 87 | event, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(axis)) 88 | if !ok { 89 | break 90 | } 91 | switch event.Kind { 92 | case pointer.Drag: 93 | case pointer.Release: 94 | case pointer.Cancel: 95 | s.dragging = false 96 | continue 97 | default: 98 | continue 99 | } 100 | dragOffset := axis.FConvert(event.Position).X 101 | // The user can drag outside the constraints, or even the window. Limit dragging to within the scrollbar. 102 | if dragOffset < 0 { 103 | dragOffset = 0 104 | } else if dragOffset > trackHeight { 105 | dragOffset = trackHeight 106 | } 107 | normalizedDragOffset := dragOffset / trackHeight 108 | 109 | if !s.dragging { 110 | s.dragging = true 111 | s.oldDragPos = normalizedDragOffset 112 | 113 | if normalizedDragOffset < viewportStart || normalizedDragOffset > viewportEnd { 114 | // The user started dragging somewhere on the track that isn't covered by the indicator. Consider this a 115 | // click in addition to a drag and jump to the clicked point. 116 | // 117 | // TODO(dh): this isn't perfect. We only get the pointer.Drag event once the user has actually dragged, 118 | // which means that if the user presses the mouse button and neither releases it nor drags it, nothing 119 | // will happen. 120 | pos := axis.Convert(image.Point{ 121 | X: int(event.Position.X), 122 | Y: int(event.Position.Y), 123 | }) 124 | normalizedPos := float32(pos.X) / trackHeight 125 | centerOnClick(normalizedPos) 126 | } 127 | } else { 128 | s.delta += normalizedDragOffset - s.oldDragPos 129 | 130 | if viewportStart+s.delta < 0 { 131 | // Adjust normalizedDragOffset - and thus the future s.oldDragPos - so that futile dragging up has to be 132 | // countered with dragging down again. Otherwise, dragging up would have no effect, but dragging down would 133 | // immediately start scrolling. We want the user to undo their ineffective drag first. 134 | normalizedDragOffset -= viewportStart + s.delta 135 | // Limit s.delta to the maximum amount scrollable 136 | s.delta = -viewportStart 137 | } else if viewportEnd+s.delta > 1 { 138 | normalizedDragOffset += (1 - viewportEnd) - s.delta 139 | s.delta = 1 - viewportEnd 140 | } 141 | s.oldDragPos = normalizedDragOffset 142 | } 143 | } 144 | 145 | // Process events from the indicator so that hover is 146 | // detected properly. 147 | // TODO _ , _ = s.indicator.Update(gtx) 148 | 149 | return D{} 150 | } 151 | 152 | // AddTrack configures the track click listener for the scrollbar to use 153 | // the current clip area. 154 | func (s *Scrollbar) AddTrack(ops *op.Ops) { 155 | s.track.Add(ops) 156 | } 157 | 158 | // AddIndicator configures the indicator click listener for the scrollbar to use 159 | // the current clip area. 160 | func (s *Scrollbar) AddIndicator(ops *op.Ops) { 161 | s.indicator.Add(ops) 162 | } 163 | 164 | // AddDrag configures the drag listener for the scrollbar to use 165 | // the current clip area. 166 | func (s *Scrollbar) AddDrag(ops *op.Ops) { 167 | s.drag.Add(ops) 168 | } 169 | 170 | // IndicatorHovered reports whether the scroll indicator is currently being 171 | // hovered by the pointer. 172 | func (s *Scrollbar) IndicatorHovered() bool { 173 | return s.indicator.Hovered() 174 | } 175 | 176 | // TrackHovered reports whether the scroll track is being hovered by the 177 | // pointer. 178 | func (s *Scrollbar) TrackHovered() bool { 179 | return s.track.Hovered() 180 | } 181 | 182 | // ScrollDistance returns the normalized distance that the scrollbar 183 | // moved during the last call to Layout as a value in the range [-1,1]. 184 | func (s *Scrollbar) ScrollDistance() float32 { 185 | return s.delta 186 | } 187 | 188 | // Dragging reports whether the user is currently performing a drag gesture 189 | // on the indicator. Note that this can return false while ScrollDistance is nonzero 190 | // if the user scrolls using a different control than the scrollbar (like a mouse 191 | // wheel). 192 | func (s *Scrollbar) Dragging() bool { 193 | return s.dragging 194 | } 195 | -------------------------------------------------------------------------------- /wid/edit.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "gioui.org/io/event" 7 | "gioui.org/io/pointer" 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | "gioui.org/op/clip" 11 | "gioui.org/op/paint" 12 | "gioui.org/text" 13 | "gioui.org/unit" 14 | "gioui.org/widget" 15 | "image" 16 | "image/color" 17 | ) 18 | 19 | type Value interface { 20 | int | float64 | float32 | string | *int | *float64 | *float32 | *string 21 | } 22 | 23 | // EditDef is the parameters for the text editor 24 | type EditDef struct { 25 | Base 26 | widget.Editor 27 | hovered bool 28 | outlineColor color.NRGBA 29 | selectionColor color.NRGBA 30 | label string 31 | value interface{} 32 | labelSize float32 33 | borderThickness unit.Dp 34 | wasFocused bool 35 | } 36 | 37 | func DefaultEditDef(th *Theme) EditDef { 38 | return EditDef{ 39 | Base: Base{ 40 | th: th, 41 | Font: &th.DefaultFont, 42 | FontScale: 1.0, 43 | margin: th.DefaultMargin, 44 | padding: th.DefaultPadding, 45 | }, 46 | Editor: widget.Editor{ 47 | SingleLine: true, 48 | }, 49 | borderThickness: th.BorderThickness, 50 | labelSize: th.LabelSplit, 51 | outlineColor: th.Fg[Outline], 52 | selectionColor: MulAlpha(th.Bg[Primary], 60), 53 | } 54 | } 55 | 56 | // Edit will return a widget (layout function) for a text editor 57 | func Edit(th *Theme, options ...any) layout.Widget { 58 | e := DefaultEditDef(th) 59 | // The first option should be the value. Will panic if no option is used. 60 | e.value = options[0] 61 | // Option 2 can be the number of decimals (if integer or pointer to integer) 62 | i := 0 63 | e.DpNo = &i 64 | if len(options) >= 2 { 65 | if v, ok := options[1].(int); ok { 66 | i := v 67 | e.DpNo = &i 68 | } else if v, ok := options[1].(*int); ok { 69 | e.DpNo = v 70 | } 71 | } 72 | 73 | // Read in all options to change from default values to something else. 74 | for _, option := range options { 75 | if v, ok := option.(UIRole); ok { 76 | e.role = v 77 | } else if v, ok := option.(layout.Inset); ok { 78 | e.padding = v 79 | } else if v, ok := option.(Option); ok { 80 | b := &e 81 | v.apply(b) 82 | } else if v, ok := option.(rune); ok { 83 | e.Mask = v 84 | } 85 | } 86 | 87 | return func(gtx C) D { 88 | return e.Layout(gtx) 89 | } 90 | } 91 | 92 | func (e *EditDef) updateValue(gtx C) { 93 | if !gtx.Focused(&e.Editor) && e.value != nil { 94 | current := e.Text() 95 | if e.wasFocused { 96 | // When the edit is loosing focus, we must update the underlying variable 97 | GuiLock.Lock() 98 | StringToValue(e.value, current) 99 | GuiLock.Unlock() 100 | } else { 101 | // When the underlying variable changes, update the edit buffer 102 | GuiLock.RLock() 103 | e.SetText(ValueToString(e.value, *e.DpNo)) 104 | GuiLock.RUnlock() 105 | } 106 | } 107 | e.wasFocused = gtx.Focused(&e.Editor) 108 | } 109 | 110 | func (e *EditDef) Layout(gtx C) D { 111 | e.CheckDisabler(gtx) 112 | // Precalculate margin and pdding in pixels 113 | mt, mb, ml, mr := ScaleInset(gtx, e.margin) 114 | pt, pb, pl, pr := ScaleInset(gtx, e.padding) 115 | // Make macro for drawing text 116 | macro := op.Record(gtx.Ops) 117 | paint.ColorOp{Color: e.Fg()}.Add(gtx.Ops) 118 | textColorOps := macro.Stop() 119 | // Make macro for drawing hint 120 | macro = op.Record(gtx.Ops) 121 | paint.ColorOp{Color: MulAlpha(e.Fg(), 128)}.Add(gtx.Ops) 122 | hintColorOps := macro.Stop() 123 | // Make macro for drawing selection 124 | macro = op.Record(gtx.Ops) 125 | paint.ColorOp{Color: e.th.SelectionColor}.Add(gtx.Ops) 126 | selectionColorOps := macro.Stop() 127 | // Update value 128 | e.updateValue(gtx) 129 | // Move to offset the outside margin 130 | defer op.Offset(image.Pt(pl, pt)).Push(gtx.Ops).Pop() 131 | // And reduce the size to make space for the padding and margin 132 | gtx.Constraints.Min.X -= pl + pr + ml + mr 133 | gtx.Constraints.Max.X = Max(100, gtx.Constraints.Min.X) 134 | // Draw hint text with top/left padding offset 135 | macro = op.Record(gtx.Ops) 136 | o := op.Offset(image.Pt(pl, pt)).Push(gtx.Ops) 137 | paint.ColorOp{Color: MulAlpha(e.Fg(), 110)}.Add(gtx.Ops) 138 | tl := widget.Label{Alignment: e.Editor.Alignment, MaxLines: 1} 139 | LblDim := tl.Layout(gtx, e.th.Shaper, *e.Font, e.th.TextSize*unit.Sp(e.FontScale), e.hint, hintColorOps) 140 | o.Pop() 141 | callHint := macro.Stop() 142 | // Add outside label to the left of the edit box 143 | if e.label != "" { 144 | o := op.Offset(image.Pt(0, pt)).Push(gtx.Ops) 145 | paint.ColorOp{Color: e.Fg()}.Add(gtx.Ops) 146 | oldMaxX := gtx.Constraints.Max.X 147 | ofs := int(float32(oldMaxX) * e.labelSize) 148 | gtx.Constraints.Max.X = ofs - pl 149 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 150 | colMacro := op.Record(gtx.Ops) 151 | paint.ColorOp{Color: e.Fg()}.Add(gtx.Ops) 152 | ll := widget.Label{Alignment: text.End, MaxLines: 1} 153 | ll.Layout(gtx, e.th.Shaper, *e.Font, e.th.TextSize*unit.Sp(e.FontScale), e.label, colMacro.Stop()) 154 | o.Pop() 155 | gtx.Constraints.Max.X = oldMaxX - ofs 156 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 157 | defer op.Offset(image.Pt(ofs, 0)).Push(gtx.Ops).Pop() 158 | } 159 | // If a width is given, and it is within constraints, limit size 160 | if w := Px(gtx, e.width); w > gtx.Constraints.Min.X && w < gtx.Constraints.Max.X { 161 | gtx.Constraints.Min.X = w 162 | } 163 | // Calculate border size and fill it with white/black when focused 164 | border := image.Rectangle{Max: image.Pt(gtx.Constraints.Max.X+pl+pr, LblDim.Size.Y+pb+pt)} 165 | rr := Min(Px(gtx, e.th.BorderCornerRadius), border.Max.Y/2) 166 | if gtx.Focused(&e.Editor) { 167 | paint.FillShape(gtx.Ops, e.th.Bg[Canvas], clip.UniformRRect(border, rr).Op(gtx.Ops)) 168 | } 169 | // Move to get the padding needed 170 | o = op.Offset(image.Pt(pl, pt)).Push(gtx.Ops) 171 | // Now layout the editor itself 172 | e.Editor.Layout(gtx, e.th.Shaper, *e.Font, e.th.TextSize*unit.Sp(e.FontScale), textColorOps, selectionColorOps) 173 | o.Pop() 174 | // If the editor is empty, we display the hint text 175 | if e.Editor.Len() == 0 { 176 | callHint.Add(gtx.Ops) 177 | } 178 | // Draw the border, if present 179 | if e.borderThickness > 0 { 180 | w := float32(Px(gtx, e.borderThickness)) 181 | if gtx.Focused(&e.Editor) { 182 | paintBorder(gtx, border, e.outlineColor, w*2, rr) 183 | } else if e.hovered { 184 | paintBorder(gtx, border, e.outlineColor, w*3/2, rr) 185 | } else { 186 | paintBorder(gtx, border, e.Fg(), w, rr) 187 | } 188 | } 189 | // Setup the pointer event handling 190 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 191 | eventArea := clip.Rect(border).Push(gtx.Ops) 192 | event.Op(gtx.Ops, e) 193 | eventArea.Pop() 194 | for { 195 | event, ok := gtx.Event(pointer.Filter{ 196 | Target: e, 197 | Kinds: pointer.Enter | pointer.Leave, 198 | }) 199 | if !ok { 200 | break 201 | } 202 | ev, ok := event.(pointer.Event) 203 | if !ok { 204 | continue 205 | } 206 | switch ev.Kind { 207 | case pointer.Leave: 208 | e.hovered = false 209 | case pointer.Enter: 210 | e.hovered = true 211 | } 212 | } 213 | 214 | // Calculate size, including margins 215 | dim := image.Pt(gtx.Constraints.Max.X, border.Max.Y+mb+mt) 216 | return D{Size: dim} 217 | } 218 | 219 | // EditOption is options specific to Edits 220 | type EditOption func(w *EditDef) 221 | 222 | // Var is an option parameter to set the variable to be updated 223 | func Var[V Value](s *V) EditOption { 224 | return func(w *EditDef) { 225 | w.value = s 226 | } 227 | } 228 | 229 | func (e *EditDef) setBorder(w unit.Dp) { 230 | e.borderThickness = w 231 | } 232 | 233 | func (e EditOption) apply(cfg interface{}) { 234 | if o, ok := cfg.(*EditDef); ok { 235 | e(o) 236 | } 237 | } 238 | 239 | func (e *EditDef) setLabel(s string) { 240 | e.label = s 241 | } 242 | 243 | func (e *EditDef) setLabelSize(w float32) { 244 | e.labelSize = w 245 | } 246 | 247 | func paintBorder(gtx C, outline image.Rectangle, col color.NRGBA, width float32, rr int) { 248 | paint.FillShape(gtx.Ops, 249 | col, 250 | clip.Stroke{ 251 | Path: clip.UniformRRect(outline, rr).Path(gtx.Ops), 252 | Width: width, 253 | }.Op(), 254 | ) 255 | } 256 | -------------------------------------------------------------------------------- /examples/simple-demo/simple-demo.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | // A Gio program that demonstrates gio-v widgets. 6 | // See https://gioui.org for information on the gio 7 | // gio-v is maintained by Jan Kåre Vatne (jkvatne@online.no) 8 | 9 | import ( 10 | "gioui.org/font" 11 | "gioui.org/font/gofont" 12 | "github.com/jkvatne/gio-v/wid" 13 | "image/color" 14 | "time" 15 | 16 | "golang.org/x/exp/shiny/materialdesign/icons" 17 | 18 | "gioui.org/app" 19 | "gioui.org/layout" 20 | "gioui.org/unit" 21 | ) 22 | 23 | var ( 24 | SmallFont bool 25 | FixedFont bool 26 | theme *wid.Theme // the theme selected 27 | win app.Window // The main window 28 | form layout.Widget 29 | name = "Jan Kåre Vatne" 30 | age = 35 31 | homeIcon *wid.Icon 32 | checkIcon *wid.Icon 33 | greenFlag = false // the state variable for the button color 34 | otherPallete = false 35 | dropDownValue1 = 1 36 | dropDownValue2 = 1 37 | dropDownValue3 = 1 38 | progress float32 = 0.1 39 | sliderValue float32 = 0.1 40 | WindowMode string 41 | homeBg = wid.RGB(0xF288F2) 42 | homeFg = wid.RGB(0x0902200) 43 | list1 = []string{"Option 1 with very very very very very very very very very very very long text", "Option 2", "Option3"} 44 | list2 = []string{"Many options", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17"} 45 | list3 = []string{"Many options", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17"} 46 | ) 47 | 48 | func main() { 49 | checkIcon, _ = wid.NewIcon(icons.NavigationCheck) 50 | homeIcon, _ = wid.NewIcon(icons.ActionHome) 51 | theme = wid.NewTheme(gofont.Collection(), 20) 52 | theme.DarkMode = false 53 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(1200), unit.Dp(700))) 54 | form = demo(theme) 55 | go wid.Run(&win, &form, theme) 56 | go ticker() 57 | app.Main() 58 | } 59 | 60 | func ticker() { 61 | for { 62 | time.Sleep(time.Millisecond * 160) 63 | wid.GuiLock.Lock() 64 | progress = float32(int32((progress*1000)+5)%1000) / 1000.0 65 | wid.GuiLock.Unlock() 66 | wid.Invalidate() 67 | } 68 | } 69 | 70 | func onSwitchFontSize() { 71 | if SmallFont { 72 | theme.TextSize = 11 73 | } else { 74 | theme.TextSize = 20 75 | } 76 | wid.FixedFontSize = FixedFont 77 | } 78 | 79 | func onSwitchMode() { 80 | theme.UpdateColors() 81 | } 82 | 83 | func onClick() { 84 | otherPallete = !otherPallete 85 | if otherPallete { 86 | theme.PrimaryColor = wid.RGB(0x17624E) 87 | theme.SecondaryColor = wid.RGB(0x17624E) 88 | theme.TertiaryColor = wid.RGB(0x136669) 89 | theme.ErrorColor = wid.RGB(0xAF2535) 90 | theme.NeutralColor = wid.RGB(0x1D4D7D) 91 | theme.NeutralVariantColor = wid.RGB(0x356057) 92 | theme.NeutralVariantColor = wid.RGB(0x356057) 93 | } else { 94 | // Set up the default pallete 95 | theme.PrimaryColor = wid.RGB(0x45682A) 96 | theme.SecondaryColor = wid.RGB(0x57624E) 97 | theme.TertiaryColor = wid.RGB(0x336669) 98 | theme.ErrorColor = wid.RGB(0xAF2525) 99 | theme.NeutralColor = wid.RGB(0x5D5D5D) 100 | } 101 | theme.UpdateColors() 102 | } 103 | 104 | func swColor() { 105 | if greenFlag { 106 | theme.PrimaryColor = color.NRGBA{A: 0xff, R: 0x00, G: 0x9d, B: 0x00} 107 | } else { 108 | theme.PrimaryColor = color.NRGBA{A: 0xff, R: 0x10, G: 0x10, B: 0xff} 109 | } 110 | theme.UpdateColors() 111 | } 112 | 113 | func onWinChange() { 114 | switch WindowMode { 115 | case "windowed": 116 | win.Option(app.Windowed.Option()) 117 | case "minimized": 118 | win.Option(app.Minimized.Option()) 119 | case "fullscreen": 120 | win.Option(app.Fullscreen.Option()) 121 | case "maximized": 122 | win.Option(app.Maximized.Option()) 123 | } 124 | } 125 | 126 | // Demo setup. Called from Setup(), only once - at start of showing it. 127 | // Returns a widget - i.e. a function: func(gtx C) D 128 | func demo(th *wid.Theme) layout.Widget { 129 | // Use an auxilary font in some widgets 130 | ff := &font.Font{Typeface: "gomono"} 131 | return wid.List(th, wid.Occupy, 132 | wid.Label(th, "Demo", wid.Middle(), wid.Heading(), wid.Bold(), wid.Role(wid.PrimaryContainer)), 133 | wid.Separator(th, unit.Dp(1.0)), 134 | wid.Row(th, nil, []float32{.5, .5, .8, .5, .5, .5}, 135 | wid.Checkbox(th, "Dark mode", wid.Bool(&th.DarkMode), wid.Do(onSwitchMode), wid.Hint("Select dark mode")), 136 | wid.Checkbox(th, "Small font", wid.Bool(&SmallFont), wid.Do(onSwitchFontSize), wid.Hint("Use a small font")), 137 | wid.Checkbox(th, "Fixed font", wid.Bool(&FixedFont), wid.Do(onSwitchFontSize), wid.Hint("Do not scale font size when resizing window")), 138 | wid.RadioButton(th, &WindowMode, "windowed", "Windowed", wid.Do(onWinChange)), 139 | wid.RadioButton(th, &WindowMode, "fullscreen", "Fullscreen", wid.Do(onWinChange)), 140 | wid.RadioButton(th, &WindowMode, "maximized", "Maximized", wid.Do(onWinChange)), 141 | ), 142 | wid.Separator(th, unit.Dp(1.0)), 143 | wid.Label(th, "Buttons with fixed size"), 144 | wid.Row(th, nil, wid.SpaceDistribute, 145 | wid.Button(th, "Big Check", wid.BtnIcon(checkIcon), wid.FontSize(2), wid.Sec(), wid.W(500)), 146 | wid.Button(th, "Change palette", wid.Do(onClick), wid.SecCont(), wid.W(500), wid.Large()), 147 | ), 148 | wid.Label(th, "Buttons scaled to fill the row width"), 149 | wid.Row(th, nil, wid.SpaceDistribute, 150 | wid.Button(th, "Change palette", wid.BtnIcon(checkIcon), wid.Do(onClick), wid.SecCont(), wid.Large(), wid.W(9999)), 151 | wid.Button(th, "Change palette", wid.BtnIcon(checkIcon), wid.Do(onClick), wid.SecCont(), wid.Large(), wid.W(9999)), 152 | ), 153 | wid.Row(th, nil, wid.SpaceDistribute, 154 | wid.Button(th, "Change palette", wid.BtnIcon(checkIcon), wid.Do(onClick), wid.SecCont(), wid.Large(), wid.W(9999)), 155 | wid.Button(th, "Change palette", wid.BtnIcon(checkIcon), wid.Do(onClick), wid.SecCont(), wid.Large(), wid.W(9999)), 156 | ), 157 | wid.Separator(th, unit.Dp(1.0)), 158 | wid.Label(th, "Button spaced closely, left adjusted"), 159 | wid.Separator(th, unit.Dp(1.0)), 160 | wid.Row(th, nil, wid.SpaceClose, 161 | wid.RoundButton(th, homeIcon, wid.Prim(), 162 | wid.Hint("This is another dummy button - it has no function except displaying this text, testing long help texts. Perhaps breaking into several lines")), 163 | wid.Button(th, "Home", wid.BtnIcon(homeIcon), wid.Bg(&homeBg), wid.Fg(&homeFg), wid.RR(20), 164 | wid.Hint("This is another hint")), 165 | wid.Button(th, "Check", wid.BtnIcon(checkIcon), wid.Role(wid.Secondary)), 166 | wid.Button(th, "Change color", wid.Do(onClick), wid.RR(90)), 167 | wid.TextButton(th, "Text button"), 168 | wid.OutlineButton(th, "Outline button", wid.Hint("An outlined button")), 169 | wid.Label(th, "Changes color", wid.Pads(10)), 170 | wid.Switch(th, &greenFlag, wid.Do(swColor)), 171 | ), 172 | wid.Separator(th, unit.Dp(1.0)), 173 | wid.Row(th, nil, []float32{1, 1, 1}, 174 | wid.Edit(th, &name, "Name"), 175 | wid.Edit(th, &age, 2, "Age"), 176 | wid.Edit(th, &name, "Name"), 177 | ), 178 | wid.Row(th, nil, []float32{1, 1, 1}, 179 | wid.DropDown(th, &dropDownValue1, list1, wid.Hint("Value 3")), 180 | wid.DropDown(th, &dropDownValue2, list2, wid.Hint("Value 4")), 181 | wid.DropDown(th, &dropDownValue3, list3, wid.Hint("Value 5")), 182 | ), 183 | wid.Row(th, nil, []float32{1, 1, 1}, 184 | wid.DropDown(th, &dropDownValue1, list1, wid.Hint("Value 3")), 185 | wid.DropDown(th, &dropDownValue2, list2, wid.Hint("Value 4")), 186 | wid.DropDown(th, &dropDownValue3, list3, wid.Hint("Value 5")), 187 | ), 188 | wid.Row(th, nil, []float32{1, 1}, 189 | wid.DropDown(th, &dropDownValue1, list1, wid.Lbl("Dropdown 1")), 190 | wid.DropDown(th, &dropDownValue2, list2, wid.Lbl("Dropdown 2")), 191 | ), 192 | wid.Edit(th, &progress, 3, wid.Lbl("Progress"), wid.Ls(1/6.0)), 193 | wid.Slider(th, &sliderValue, 0, 100), 194 | wid.Row(th, nil, []float32{1, 1, 1, 1}, 195 | wid.Col(wid.SpaceClose, 196 | wid.Edit(th, &name, wid.Hint("Hint 6"), wid.Lbl("Label 6"), wid.Ls(0.2)), 197 | wid.Edit(th, &age, wid.Hint("Hint 7"), wid.Lbl("Label 7"), wid.Ls(0.2)), 198 | ), 199 | wid.Col(wid.SpaceClose, 200 | wid.Edit(th, &name, wid.Lbl("Name"), wid.Ls(0.5), wid.Font(ff)), 201 | wid.Edit(th, &age, wid.Lbl("Age"), wid.Ls(0.5)), 202 | ), 203 | ), 204 | wid.ProgressBar(th, &progress, wid.Pads(5.0), wid.Thick(7), wid.Bg(&color.NRGBA{200, 200, 200, 200})), 205 | wid.Separator(th, 0, wid.Pads(5.0)), 206 | wid.ImageFromJpgFile("gopher.jpg", wid.Contain), 207 | ) 208 | } 209 | -------------------------------------------------------------------------------- /examples/grid/grid.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | // This file demonstrates a simple grid, trying to follow https://material.io/components/data-tables 6 | // It scrolls vertically and horizontally and implements highlighting of rows. 7 | 8 | import ( 9 | "github.com/jkvatne/gio-v/wid" 10 | "sort" 11 | 12 | "gioui.org/op/paint" 13 | 14 | "gioui.org/app" 15 | "gioui.org/font/gofont" 16 | "golang.org/x/exp/shiny/materialdesign/icons" 17 | 18 | "gioui.org/layout" 19 | "gioui.org/unit" 20 | ) 21 | 22 | var ( 23 | form layout.Widget 24 | theme *wid.Theme 25 | win app.Window 26 | Alternative = "Fractional" 27 | fontSize = "Medium" 28 | // Column widths are given in units of approximately one average character width (en). 29 | // A width of zero means the widget's natural size should be used (f.ex. checkboxes) 30 | wideColWidth = []float32{0, 60, 60, 10, 30} 31 | smallColWidth = []float32{0, 13, 13, 12, 12} 32 | fracColWidth = []float32{0, 0.3, 0.3, .2, .2} 33 | selectAll bool 34 | doOccupy bool 35 | withoutHeader bool = false 36 | nameIcon *wid.Icon 37 | addressIcon *wid.Icon 38 | ageIcon *wid.Icon 39 | dir bool 40 | line string 41 | ) 42 | 43 | type person struct { 44 | Selected bool 45 | Name string 46 | Age float64 47 | Address string 48 | Status int 49 | } 50 | 51 | var data = []person{ 52 | {Name: "Oleg Karlsen", Age: 21, Address: "Storgata 1", Status: 1}, 53 | {Name: "Per Pedersen", Age: 22, Address: "Svenskveien 2", Selected: true, Status: 1}, 54 | {Name: "Nils Aure", Age: 23, Address: "Brogata 3"}, 55 | {Name: "Kai Oppdal", Age: 24, Address: "Soleieveien 4"}, 56 | {Name: "Gro Arneberg", Age: 25, Address: "Blomsterveien 5"}, 57 | {Name: "Ole Kolås", Age: 26, Address: "Blåklokkevikua 6"}, 58 | {Name: "Per Pedersen", Age: 27, Address: "Gamleveien 7"}, 59 | {Name: "Nils Vukubråten", Age: 28, Address: "Nygata 8"}, 60 | {Name: "Sindre Gratangen", Age: 29, Address: "Brosundet 9"}, 61 | {Name: "Gro Nilsasveen", Age: 30, Address: "Blomsterveien 10"}, 62 | {Name: "Petter Olsen", Age: 31, Address: "Katavågen 11"}, 63 | {Name: "Per Pedersen", Age: 32, Address: "Nidelva 12"}, 64 | } 65 | 66 | func main() { 67 | makePersons(12) 68 | theme = wid.NewTheme(gofont.Collection(), 16) 69 | onWinChange() 70 | win.Option(app.Title("Gio-v demo"), app.Size(unit.Dp(900), unit.Dp(300))) 71 | wid.Run(&win, &form, theme) 72 | app.Main() 73 | } 74 | 75 | func onWinChange() { 76 | var f layout.Widget 77 | theme.UpdateColors() 78 | if Alternative == "Wide" { 79 | f = GridDemo(theme, data, wideColWidth) 80 | } else if Alternative == "Narrow" { 81 | f = GridDemo(theme, data, smallColWidth) 82 | } else if Alternative == "Fractional" { 83 | f = GridDemo(theme, data, fracColWidth) 84 | } else if Alternative == "Equal" { 85 | f = GridDemo(theme, data, wid.SpaceDistribute) 86 | } else if Alternative == "Native" { 87 | f = GridDemo(theme, data, wid.SpaceClose) 88 | } else { 89 | f = GridDemo(theme, data, wid.SpaceDistribute) 90 | } 91 | wid.GuiLock.Lock() 92 | form = f 93 | defer wid.GuiLock.Unlock() 94 | } 95 | 96 | // makePersons will create a list of n persons. 97 | func makePersons(n int) { 98 | m := n - len(data) 99 | for i := 1; i < m; i++ { 100 | data[0].Age = data[0].Age + float64(i) 101 | data = append(data, data[0]) 102 | } 103 | data = data[:n] 104 | } 105 | 106 | func onNameClick() { 107 | if dir { 108 | sort.Slice(data, func(i, j int) bool { return data[i].Name >= data[j].Name }) 109 | _ = nameIcon.Update(icons.NavigationArrowDownward) 110 | } else { 111 | sort.Slice(data, func(i, j int) bool { return data[i].Name < data[j].Name }) 112 | _ = nameIcon.Update(icons.NavigationArrowUpward) 113 | } 114 | _ = addressIcon.Update(icons.NavigationUnfoldMore) 115 | _ = ageIcon.Update(icons.NavigationUnfoldMore) 116 | dir = !dir 117 | } 118 | 119 | func onAddressClick() { 120 | if dir { 121 | sort.Slice(data, func(i, j int) bool { return data[i].Address >= data[j].Address }) 122 | _ = addressIcon.Update(icons.NavigationArrowDownward) 123 | } else { 124 | sort.Slice(data, func(i, j int) bool { return data[i].Address < data[j].Address }) 125 | _ = addressIcon.Update(icons.NavigationArrowUpward) 126 | } 127 | _ = nameIcon.Update(icons.NavigationUnfoldMore) 128 | _ = ageIcon.Update(icons.NavigationUnfoldMore) 129 | dir = !dir 130 | } 131 | 132 | func onAgeClick() { 133 | if dir { 134 | sort.Slice(data, func(i, j int) bool { return data[i].Age >= data[j].Age }) 135 | _ = ageIcon.Update(icons.NavigationArrowDownward) 136 | } else { 137 | sort.Slice(data, func(i, j int) bool { return data[i].Age < data[j].Age }) 138 | _ = ageIcon.Update(icons.NavigationArrowUpward) 139 | } 140 | _ = nameIcon.Update(icons.NavigationUnfoldMore) 141 | _ = addressIcon.Update(icons.NavigationUnfoldMore) 142 | dir = !dir 143 | } 144 | 145 | // onCheck is called when the header checkbox is clicked. It will set or clear all rows. 146 | func onCheck() { 147 | for i := 0; i < len(data); i++ { 148 | data[i].Selected = selectAll 149 | } 150 | } 151 | 152 | func onFontChange() { 153 | if fontSize == "Medium" { 154 | theme.TextSize = 16 155 | } else if fontSize == "Large" { 156 | theme.TextSize = 26 157 | } else if fontSize == "Small" { 158 | theme.TextSize = 10 159 | } 160 | onWinChange() 161 | } 162 | 163 | // gw is the grid line width 164 | const gw = unit.Dp(2.0 / 1.75) 165 | 166 | // GridDemo is a widget that lays out the grid. This is all that is needed. 167 | func GridDemo(th *wid.Theme, data []person, colWidths []float32) layout.Widget { 168 | anchor := wid.Overlay 169 | if doOccupy { 170 | anchor = wid.Occupy 171 | } 172 | bgColor := th.Bg[wid.PrimaryContainer] 173 | 174 | nameIcon, _ = wid.NewIcon(icons.NavigationUnfoldMore) 175 | addressIcon, _ = wid.NewIcon(icons.NavigationUnfoldMore) 176 | ageIcon, _ = wid.NewIcon(icons.NavigationUnfoldMore) 177 | 178 | // Configure a grid with headings and several rows 179 | var gridLines []layout.Widget 180 | header := wid.Row(th, &bgColor, gw, colWidths, 181 | wid.Checkbox(th, "", wid.Bool(&selectAll), wid.Do(onCheck)), 182 | wid.HeaderButton(th, "Name", wid.Do(onNameClick), wid.PrimCont(), wid.BtnIcon(nameIcon), wid.Pads(0)), 183 | wid.HeaderButton(th, "Address", wid.Do(onAddressClick), wid.PrimCont(), wid.BtnIcon(addressIcon), wid.Pads(0)), 184 | wid.HeaderButton(th, "Age", wid.Do(onAgeClick), wid.PrimCont(), wid.BtnIcon(ageIcon), wid.Pads(0)), 185 | // When using a label, padding has to be added. It should be equal to the default button padding. 186 | wid.Label(th, "Gender", wid.PrimCont()), 187 | ) 188 | if withoutHeader { 189 | header = nil 190 | } 191 | 192 | for i := 0; i < len(data); i++ { 193 | bgColor := wid.MulAlpha(th.Bg[wid.PrimaryContainer], 50) 194 | if i%2 == 0 { 195 | bgColor = wid.MulAlpha(th.Bg[wid.SecondaryContainer], 50) 196 | } 197 | gridLines = append(gridLines, 198 | wid.Row(th, &bgColor, gw, colWidths, 199 | // One row of the grid is defined here, Name can not be edited 200 | wid.Checkbox(th, "", wid.Bool(&data[i].Selected)), 201 | wid.Label(th, &data[i].Name), 202 | wid.Edit(th, wid.Var(&data[i].Address), wid.Border(0), wid.Margin(0)), 203 | wid.Edit(th, wid.Var(&data[i].Age), wid.Border(0), wid.Margin(0)), 204 | wid.DropDown(th, &data[i].Status, []string{"Male", "Female", "Other"}, wid.Margin(0), wid.Border(0)), 205 | )) 206 | 207 | } 208 | var lines = []layout.Widget{ 209 | wid.Label(th, "GridDemo demo", wid.Middle(), wid.Heading(), wid.Bold()), 210 | wid.Row(th, nil, wid.SpaceDistribute, 211 | wid.RadioButton(th, &Alternative, "Wide", "Wide Table", wid.Do(onWinChange)), 212 | wid.RadioButton(th, &Alternative, "Narrow", "Narrow Table", wid.Do(onWinChange)), 213 | wid.RadioButton(th, &Alternative, "Fractional", "Fractional", wid.Do(onWinChange)), 214 | wid.RadioButton(th, &Alternative, "Equal", "Equal", wid.Do(onWinChange)), 215 | wid.RadioButton(th, &Alternative, "Native", "Native", wid.Do(onWinChange)), 216 | ), 217 | wid.Row(th, nil, wid.SpaceDistribute, 218 | wid.Checkbox(th, "Dark mode", wid.Bool(&th.DarkMode), wid.Do(onWinChange)), 219 | wid.Checkbox(th, "Scroll-bar occupy", wid.Bool(&doOccupy), wid.Do(onWinChange)), 220 | wid.Checkbox(th, "No header", wid.Bool(&withoutHeader), wid.Do(onWinChange)), 221 | wid.Label(th, ""), 222 | wid.RadioButton(th, &fontSize, "Large", "Large", wid.Do(onFontChange)), 223 | wid.RadioButton(th, &fontSize, "Medium", "Medium", wid.Do(onFontChange)), 224 | wid.RadioButton(th, &fontSize, "Small", "Small", wid.Do(onFontChange)), 225 | ), 226 | wid.Edit(th, &line, wid.Hint("Line editor")), 227 | wid.Table(th, anchor, header, gridLines...), 228 | wid.Separator(th, 2), 229 | // Center button that is <10 em wide. The width should be close to the native width, or the 230 | // button will not be centered. 231 | wid.Row(th, nil, []float32{1.0, 0.0, 1.0}, 232 | wid.Space(1), 233 | wid.Button(th, "Update", wid.Hint("Click to update variables")), 234 | wid.Space(1), 235 | ), 236 | } 237 | 238 | return func(gtx wid.C) wid.D { 239 | bgColor := th.Bg[wid.Surface] 240 | paint.Fill(gtx.Ops, bgColor) 241 | // Use flexible row heights. Set 1 for the grid, so it will use all available space. 242 | return wid.Col([]float32{0, 0, 0, 0, 1, 0, 0}, lines...)(gtx) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /wid/options.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "gioui.org/font" 5 | "gioui.org/layout" 6 | "gioui.org/text" 7 | "gioui.org/unit" 8 | "image/color" 9 | ) 10 | 11 | // BaseIf is the interface functions for widgets, used by options to set parameters 12 | type BaseIf interface { 13 | setWidth(width float32) 14 | setHint(hint string) 15 | setPadding(padding layout.Inset) 16 | setMargin(margin layout.Inset) 17 | setRole(role UIRole) 18 | setBgColor(c *color.NRGBA) 19 | setFgColor(c *color.NRGBA) 20 | setHandler(h func()) 21 | setFont(f *font.Font) 22 | setDisabler(b *bool) 23 | getTheme() *Theme 24 | setFontSize(f float32) 25 | setBorder(b unit.Dp) 26 | setDp(dp *int) 27 | setAlignment(x text.Alignment) 28 | } 29 | 30 | // BaseOption is a type for optional parameters when creating widgets 31 | type BaseOption func(BaseIf) 32 | 33 | // Option is the interface for optional parameters 34 | type Option interface { 35 | apply(cfg interface{}) 36 | } 37 | 38 | func (wid BaseOption) apply(cfg interface{}) { 39 | cc := cfg.(BaseIf) 40 | wid(cc) 41 | } 42 | 43 | func (wid *Base) getTheme() *Theme { 44 | return wid.th 45 | } 46 | 47 | func (wid *Base) setWidth(width float32) { 48 | wid.width = unit.Dp(width) 49 | } 50 | 51 | func (wid *Base) setHint(hint string) { 52 | wid.hint = hint 53 | } 54 | 55 | func (wid *Base) setRole(role UIRole) { 56 | wid.role = role 57 | } 58 | 59 | func (wid *Base) setFont(font *font.Font) { 60 | wid.Font = font 61 | } 62 | 63 | func (wid *Base) setPadding(padding layout.Inset) { 64 | wid.padding = padding 65 | } 66 | 67 | func (wid *Base) setMargin(margin layout.Inset) { 68 | wid.margin = margin 69 | } 70 | 71 | func (wid *Base) setFgColor(c *color.NRGBA) { 72 | wid.fgColor = c 73 | } 74 | 75 | func (wid *Base) setBgColor(c *color.NRGBA) { 76 | wid.bgColor = c 77 | } 78 | 79 | func (wid *Base) setHandler(h func()) { 80 | wid.onUserChange = h 81 | } 82 | 83 | func (wid *Base) setFontSize(h float32) { 84 | wid.FontScale = float64(h) 85 | } 86 | 87 | func (wid *Base) setDisabler(b *bool) { 88 | wid.disabler = b 89 | } 90 | 91 | func (wid *Base) setDp(dp *int) { 92 | wid.DpNo = dp 93 | } 94 | 95 | func (wid *Base) setAlignment(x text.Alignment) { 96 | wid.Alignment = x 97 | } 98 | 99 | func (wid *Base) setBorder(w unit.Dp) { 100 | wid.borderWidth = w 101 | } 102 | 103 | func Border(b unit.Dp) BaseOption { 104 | return func(w BaseIf) { 105 | w.setBorder(b) 106 | } 107 | } 108 | 109 | func Dp[V int | *int](dp V) BaseOption { 110 | if d, ok := any(dp).(int); ok { 111 | return func(w BaseIf) { 112 | i := d 113 | w.setDp(&i) 114 | } 115 | } else if d, ok := any(dp).(*int); ok { 116 | return func(w BaseIf) { 117 | w.setDp(d) 118 | } 119 | } else { 120 | panic("Option Dp() must have int or *int as argument") 121 | } 122 | } 123 | 124 | func En(b *bool) BaseOption { 125 | return func(w BaseIf) { 126 | w.setDisabler(b) 127 | } 128 | 129 | } 130 | 131 | // Pad is used to set default widget paddings (outside of widget) 132 | func (wid *Base) Pad(t, r, b, l float32) { 133 | wid.padding = layout.Inset{Top: unit.Dp(t), Bottom: unit.Dp(b), Left: unit.Dp(l), Right: unit.Dp(r)} 134 | } 135 | 136 | // Do is an optional parameter to set a callback when widget state changes 137 | func Do(f func()) BaseOption { 138 | return func(w BaseIf) { 139 | w.setHandler(f) 140 | } 141 | } 142 | 143 | // Middle will align text in the middle. 144 | func Middle() BaseOption { 145 | return func(d BaseIf) { 146 | d.setAlignment(text.Middle) 147 | } 148 | } 149 | 150 | // Right will align text to the end. 151 | func Right() BaseOption { 152 | return func(d BaseIf) { 153 | d.setAlignment(text.End) 154 | } 155 | } 156 | 157 | // W is the option parameter for setting widget width 158 | func W(width float32) BaseOption { 159 | return func(w BaseIf) { 160 | w.setWidth(width) 161 | } 162 | } 163 | 164 | // Hint is an option parameter to set the widget hint (tooltip) 165 | func Hint(hint string) BaseOption { 166 | return func(w BaseIf) { 167 | w.setHint(hint) 168 | } 169 | } 170 | 171 | type Color interface { 172 | *color.NRGBA | color.NRGBA | UIRole 173 | } 174 | 175 | // Fg is an option parameter to set widget foreground color 176 | func Fg[V Color](v V) BaseOption { 177 | if x, ok := any(v).(*color.NRGBA); ok { 178 | return func(w BaseIf) { 179 | w.setFgColor(x) 180 | } 181 | } else if x, ok := any(v).(color.NRGBA); ok { 182 | return func(w BaseIf) { 183 | w.setFgColor(&x) 184 | } 185 | } else if x, ok := any(v).(UIRole); ok { 186 | return func(w BaseIf) { 187 | c := w.getTheme().Fg[x] 188 | w.setFgColor(&c) 189 | } 190 | } else { 191 | return nil 192 | } 193 | } 194 | 195 | // Bg is an option parameter to set widget background color 196 | func Bg(c *color.NRGBA) BaseOption { 197 | return func(w BaseIf) { 198 | w.setBgColor(c) 199 | } 200 | } 201 | 202 | // Role set the theme role for the widget (Primary, Secondary etc.) 203 | func Role(r UIRole) BaseOption { 204 | return func(w BaseIf) { 205 | w.setRole(r) 206 | } 207 | } 208 | 209 | // Lbl is an option parameter to set the widget label 210 | func Lbl(s string) BaseOption { 211 | return func(w BaseIf) { 212 | if o, ok := w.(*EditDef); ok { 213 | o.setLabel(s) 214 | } 215 | if o, ok := w.(*DropDownStyle); ok { 216 | o.setLabel(s) 217 | } 218 | } 219 | } 220 | 221 | // Ls is an option parameter to set the widget label size 222 | func Ls(x float32) BaseOption { 223 | return func(w BaseIf) { 224 | if o, ok := w.(*EditDef); ok { 225 | o.setLabelSize(x) 226 | } 227 | if o, ok := w.(*DropDownStyle); ok { 228 | o.setLabelSize(x) 229 | } 230 | } 231 | } 232 | 233 | // Prim is a shortcut to set role=Primary 234 | func Prim() BaseOption { 235 | return func(w BaseIf) { 236 | w.setRole(Primary) 237 | } 238 | } 239 | 240 | // PrimCont is a shortcut to set role=PrimaryContainer 241 | func PrimCont() BaseOption { 242 | return func(w BaseIf) { 243 | w.setRole(PrimaryContainer) 244 | } 245 | } 246 | 247 | // Sec is a shortcut to set role=Secondary 248 | func Sec() BaseOption { 249 | return func(w BaseIf) { 250 | w.setRole(Secondary) 251 | } 252 | } 253 | 254 | // SecCont is a shortcut to set role=SecondaryContainer 255 | func SecCont() BaseOption { 256 | return func(w BaseIf) { 257 | w.setRole(SecondaryContainer) 258 | } 259 | } 260 | 261 | // Font set the font for text in the widget 262 | func Font(v *font.Font) BaseOption { 263 | return func(w BaseIf) { 264 | w.setFont(v) 265 | } 266 | } 267 | 268 | // FontSize set the font size for text in the widget 269 | func FontSize(v float32) BaseOption { 270 | return func(w BaseIf) { 271 | w.setFontSize(v) 272 | } 273 | } 274 | 275 | // Heading makes text 75% larger. 276 | func Heading() BaseOption { 277 | return func(w BaseIf) { 278 | w.setFontSize(1.8) 279 | } 280 | } 281 | 282 | // Large makes text 40% larger. 283 | func Large() BaseOption { 284 | return func(w BaseIf) { 285 | w.setFontSize(1.3) 286 | } 287 | } 288 | 289 | // Small makes text 20% smaller. 290 | func Small() BaseOption { 291 | return func(w BaseIf) { 292 | w.setFontSize(0.8) 293 | } 294 | } 295 | 296 | func Pad(p layout.Inset) BaseOption { 297 | return func(w BaseIf) { 298 | w.setPadding(p) 299 | } 300 | } 301 | 302 | // Pads is an option parameter to set customized padding. Noe that 1,2,3 or 4 paddings can be specified. 303 | // If 1 is supplied, it is used for left,right,top,bottom, all with the same padding 304 | // If 2 is supplied, the first is used for top/bottom, and the second for left and right padding 305 | // If 4 is supplied, it is used for top, right, bottom, left in that sequence. 306 | // All values are in Dp (float32 device independent pixels) 307 | func Pads(pads ...float32) BaseOption { 308 | return func(w BaseIf) { 309 | switch len(pads) { 310 | case 0: 311 | w.setPadding(layout.Inset{Top: unit.Dp(2), Bottom: unit.Dp(2), Left: unit.Dp(4), Right: unit.Dp(4)}) 312 | case 1: 313 | w.setPadding(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[0]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[0])}) 314 | case 2: 315 | w.setPadding(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[1])}) 316 | case 3: 317 | w.setPadding(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[2])}) 318 | case 4: 319 | w.setPadding(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[2]), Left: unit.Dp(pads[3])}) 320 | } 321 | } 322 | } 323 | 324 | // Pads is an option parameter to set customized padding. Noe that 1,2,3 or 4 paddings can be specified. 325 | // If 1 is supplied, it is used for left,right,top,bottom, all with the same padding 326 | // If 2 is supplied, the first is used for top/bottom, and the second for left and right padding 327 | // If 4 is supplied, it is used for top, right, bottom, left in that sequence. 328 | // All values are in Dp (float32 device independent pixels) 329 | func Margin(pads ...float32) BaseOption { 330 | return func(w BaseIf) { 331 | switch len(pads) { 332 | case 0: 333 | w.setMargin(layout.Inset{Top: unit.Dp(2), Bottom: unit.Dp(2), Left: unit.Dp(4), Right: unit.Dp(4)}) 334 | case 1: 335 | w.setMargin(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[0]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[0])}) 336 | case 2: 337 | w.setMargin(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[1])}) 338 | case 3: 339 | w.setMargin(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[0]), Left: unit.Dp(pads[2])}) 340 | case 4: 341 | w.setMargin(layout.Inset{Top: unit.Dp(pads[0]), Right: unit.Dp(pads[1]), Bottom: unit.Dp(pads[2]), Left: unit.Dp(pads[3])}) 342 | } 343 | } 344 | } 345 | 346 | func Thick(t unit.Dp) BaseOption { 347 | return func(w BaseIf) { 348 | if o, ok := w.(*ProgressBarDef); ok { 349 | o.setThickness(t) 350 | } 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /wid/dropdown.go: -------------------------------------------------------------------------------- 1 | package wid 2 | 3 | import ( 4 | "gioui.org/io/event" 5 | "image" 6 | "image/color" 7 | 8 | "gioui.org/io/pointer" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/op/clip" 12 | "gioui.org/op/paint" 13 | "gioui.org/text" 14 | "gioui.org/unit" 15 | "gioui.org/widget" 16 | 17 | "golang.org/x/exp/shiny/materialdesign/icons" 18 | ) 19 | 20 | // DropDownStyle is the struct for dropdown lists. 21 | type DropDownStyle struct { 22 | Base 23 | Clickable 24 | items []string 25 | itemHovered []bool 26 | outlineColor color.NRGBA 27 | listVisible bool 28 | inList bool 29 | list Wid 30 | Items []Wid 31 | label string 32 | labelSize float32 33 | above bool 34 | } 35 | 36 | var ( 37 | dropUpIcon *Icon 38 | dropDownIcon *Icon 39 | ) 40 | 41 | // DropDown returns an initiated struct with drop-dow box setup info 42 | func DropDown(th *Theme, index *int, items []string, options ...Option) layout.Widget { 43 | b := &DropDownStyle{} 44 | b.th = th 45 | b.role = Canvas 46 | b.outlineColor = th.Fg[Outline] 47 | b.Font = &th.DefaultFont 48 | b.index = index 49 | b.items = items 50 | b.labelSize = th.LabelSplit 51 | b.borderWidth = b.th.BorderThickness 52 | b.ClickMovesFocus = true 53 | for i := range items { 54 | b.Items = append(b.Items, b.optionWidget(th, i)) 55 | b.itemHovered = append(b.itemHovered, false) 56 | } 57 | b.list = List(th, Overlay, b.Items...) 58 | b.cornerRadius = th.BorderCornerRadius 59 | b.margin = th.DefaultMargin 60 | b.padding = th.DefaultPadding 61 | for _, option := range options { 62 | option.apply(b) 63 | } 64 | if b.label == "" { 65 | b.labelSize = 0 66 | } 67 | return b.Layout 68 | } 69 | 70 | func (d *DropDownStyle) setLabel(s string) { 71 | d.label = s 72 | } 73 | 74 | func (d *DropDownStyle) setLabelSize(w float32) { 75 | d.labelSize = w 76 | } 77 | 78 | func (d *DropDownStyle) Layout(gtx C) D { 79 | d.CheckDisabler(gtx) 80 | d.maxIndex = len(d.items) 81 | // Move to offset the external margin around both label and edit 82 | defer op.Offset(image.Pt( 83 | Px(gtx, d.margin.Left), 84 | Px(gtx, d.margin.Top))).Push(gtx.Ops).Pop() 85 | 86 | // If a width is given, and it is within constraints, limit size 87 | if w := Px(gtx, d.width); w > gtx.Constraints.Min.X && w < gtx.Constraints.Max.X { 88 | gtx.Constraints.Min.X = w 89 | } 90 | // And reduce the size to make space for the margin+padding 91 | gtx.Constraints.Min.X -= Px(gtx, d.padding.Left+d.padding.Right+d.margin.Left+d.margin.Right) 92 | gtx.Constraints.Max.X = gtx.Constraints.Min.X 93 | 94 | d.HandleEvents(gtx) 95 | 96 | GuiLock.RLock() 97 | idx := *d.index 98 | GuiLock.RUnlock() 99 | 100 | // Add outside label to the left of the dropdown box 101 | if d.label != "" { 102 | o := op.Offset(image.Pt(0, Px(gtx, d.padding.Top))).Push(gtx.Ops) 103 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 104 | oldMaxX := gtx.Constraints.Max.X 105 | ofs := int(float32(oldMaxX) * d.labelSize) 106 | gtx.Constraints.Max.X = ofs - Px(gtx, d.padding.Left) 107 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 108 | colMacro := op.Record(gtx.Ops) 109 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 110 | ll := widget.Label{Alignment: text.End, MaxLines: 1} 111 | ll.Layout(gtx, d.th.Shaper, *d.Font, d.th.TextSize, d.label, colMacro.Stop()) 112 | o.Pop() 113 | gtx.Constraints.Max.X = oldMaxX - ofs 114 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 115 | defer op.Offset(image.Pt(ofs, 0)).Push(gtx.Ops).Pop() 116 | } 117 | 118 | // Draw text with top/left padding offset 119 | textMacro := op.Record(gtx.Ops) 120 | o := op.Offset(image.Pt(Px(gtx, d.padding.Left), Px(gtx, d.padding.Top))).Push(gtx.Ops) 121 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 122 | tl := widget.Label{Alignment: text.Start, MaxLines: 1} 123 | colMacro := op.Record(gtx.Ops) 124 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 125 | dims := tl.Layout(gtx, d.th.Shaper, *d.Font, d.th.TextSize, d.items[*d.index], colMacro.Stop()) 126 | o.Pop() 127 | drawTextMacro := textMacro.Stop() 128 | 129 | // Calculate widget size based on text size and padding, using all available x space 130 | dims.Size.X = gtx.Constraints.Max.X 131 | 132 | border := image.Rectangle{Max: image.Pt( 133 | gtx.Constraints.Max.X+Px(gtx, d.padding.Left+d.padding.Right), 134 | dims.Size.Y+Px(gtx, d.padding.Bottom+d.padding.Top))} 135 | 136 | // Draw border. Need to undo previous top padding offset first 137 | r := Px(gtx, d.cornerRadius) 138 | if r > border.Max.Y/2 { 139 | r = border.Max.Y / 2 140 | } 141 | if d.borderWidth > 0 { 142 | w := float32(Px(gtx, d.borderWidth)) 143 | if gtx.Focused(&d.Clickable) { 144 | paintBorder(gtx, border, d.outlineColor, w*2, r) 145 | } else if d.Hovered() { 146 | paintBorder(gtx, border, d.outlineColor, w*3/2, r) 147 | } else { 148 | paintBorder(gtx, border, d.Fg(), w, r) 149 | } 150 | } 151 | drawTextMacro.Add(gtx.Ops) 152 | 153 | // Draw icon using foreground color 154 | iconSize := image.Pt(border.Max.Y, border.Max.Y) 155 | o = op.Offset(image.Pt(border.Max.X-iconSize.X, 0)).Push(gtx.Ops) 156 | c := gtx 157 | c.Constraints.Max = iconSize 158 | c.Constraints.Min = iconSize 159 | dropDownIcon.Layout(c, d.Fg()) 160 | o.Pop() 161 | 162 | oldVisible := d.listVisible 163 | if d.listVisible && !gtx.Focused(&d.Clickable) { 164 | d.listVisible = false 165 | } 166 | for d.Clicked() { 167 | d.listVisible = !d.listVisible 168 | } 169 | if d.listVisible { 170 | gtx.Constraints.Min = image.Pt(border.Max.X, dims.Size.Y) 171 | // Limit list length to 8 times the gross size of the dropdown 172 | gtx.Constraints.Max.Y = dims.Size.Y * 8 173 | gtx.Constraints.Max.X = gtx.Constraints.Min.X 174 | 175 | listMacro := op.Record(gtx.Ops) 176 | o := d.list(gtx) 177 | listClipRect := image.Rect(0, 0, border.Max.X, o.Size.Y) 178 | theListMacro := listMacro.Stop() 179 | 180 | if !oldVisible { 181 | d.above = WinY-mouseY < o.Size.Y+dims.Size.Y 182 | d.setHovered(idx) 183 | } 184 | 185 | dy := dims.Size.Y + Px(gtx, d.padding.Top) + Px(gtx, d.padding.Bottom) 186 | if d.above { 187 | dy = -o.Size.Y 188 | } 189 | 190 | for { 191 | event, ok := gtx.Event(pointer.Filter{ 192 | Target: d, 193 | Kinds: pointer.Enter | pointer.Leave, 194 | }) 195 | if !ok { 196 | break 197 | } 198 | ev, ok := event.(pointer.Event) 199 | if !ok { 200 | continue 201 | } 202 | switch ev.Kind { 203 | case pointer.Enter: 204 | d.listVisible = true 205 | case pointer.Leave: 206 | d.listVisible = false 207 | default: 208 | } 209 | } 210 | 211 | dropdownMacro := op.Record(gtx.Ops) 212 | r := op.Offset(image.Pt(0, dy)).Push(gtx.Ops) 213 | 214 | // Fill background and draw list 215 | bw := Px(gtx, unit.Dp(1.5)) 216 | cl := clip.Rect{Max: image.Pt(border.Max.X, o.Size.Y+bw)}.Push(gtx.Ops) 217 | paint.Fill(gtx.Ops, d.th.Bg[Canvas]) 218 | theListMacro.Add(gtx.Ops) 219 | cl.Pop() 220 | // Draw frame 221 | paintBorder(gtx, image.Rect(0, 0, listClipRect.Max.X, listClipRect.Max.Y), d.outlineColor, float32(bw), 0) 222 | 223 | // Handle mouse enter/leave into list area, inluding original value area 224 | cr := listClipRect 225 | cr.Min.Y = -dy 226 | clr := clip.Rect(cr).Push(gtx.Ops) 227 | pass := pointer.PassOp{}.Push(gtx.Ops) 228 | event.Op(gtx.Ops, d) 229 | pass.Pop() 230 | clr.Pop() 231 | 232 | // Draw a border around all options 233 | w := float32(Px(gtx, d.borderWidth)) 234 | paintBorder(gtx, listClipRect, d.th.Fg[Outline], w, 0) 235 | r.Pop() 236 | // Save and defer execution 237 | dropDownListCall := dropdownMacro.Stop() 238 | op.Defer(gtx.Ops, dropDownListCall) 239 | } else { 240 | d.setHovered(idx) 241 | } 242 | 243 | sz := image.Pt(gtx.Constraints.Max.X, border.Max.Y-border.Min.Y+Px(gtx, d.margin.Bottom+d.margin.Top)) 244 | d.SetupEventHandlers(gtx, dims.Size) 245 | pointer.CursorPointer.Add(gtx.Ops) 246 | 247 | return D{Size: sz} 248 | } 249 | 250 | func (d *DropDownStyle) setHovered(h int) { 251 | for i := 0; i < len(d.itemHovered); i++ { 252 | d.itemHovered[i] = false 253 | } 254 | d.itemHovered[h] = true 255 | } 256 | 257 | func (d *DropDownStyle) optionWidget(th *Theme, i int) func(gtx C) D { 258 | return func(gtx C) D { 259 | for { 260 | event, ok := gtx.Event(pointer.Filter{ 261 | Target: &d.itemHovered[i], 262 | Kinds: pointer.Release | pointer.Enter | pointer.Leave, 263 | }) 264 | if !ok { 265 | break 266 | } 267 | ev, ok := event.(pointer.Event) 268 | if !ok { 269 | continue 270 | } 271 | switch ev.Kind { 272 | case pointer.Release: 273 | GuiLock.Lock() 274 | *d.index = i 275 | GuiLock.Unlock() 276 | d.listVisible = false 277 | d.itemHovered[i] = false 278 | // Force redraw when item is clicked 279 | Invalidate() 280 | case pointer.Enter: 281 | for j := 0; j < len(d.itemHovered); j++ { 282 | d.itemHovered[j] = false 283 | } 284 | d.itemHovered[i] = true 285 | case pointer.Leave: 286 | d.itemHovered[i] = false 287 | default: 288 | } 289 | } 290 | gtx.Constraints.Max.X = gtx.Constraints.Min.X 291 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 292 | lblWidget := func(gtx C) D { 293 | m := op.Record(gtx.Ops) 294 | paint.ColorOp{Color: d.Fg()}.Add(gtx.Ops) 295 | colMacro := m.Stop() 296 | return widget.Label{Alignment: text.Start, MaxLines: 1}.Layout(gtx, th.Shaper, *d.Font, th.TextSize, d.items[i], colMacro) 297 | } 298 | dims := layout.Inset{Top: unit.Dp(4), Left: unit.Dp(th.TextSize * 0.4), Right: unit.Dp(0)}.Layout(gtx, lblWidget) 299 | defer clip.Rect(image.Rect(0, 0, dims.Size.X, dims.Size.Y)).Push(gtx.Ops).Pop() 300 | c := color.NRGBA{} 301 | if *d.index == i { 302 | c = MulAlpha(d.Fg(), 64) 303 | } else if d.itemHovered[i] { 304 | c = MulAlpha(d.Fg(), 24) 305 | } 306 | paint.ColorOp{Color: c}.Add(gtx.Ops) 307 | paint.PaintOp{}.Add(gtx.Ops) 308 | 309 | event.Op(gtx.Ops, &d.itemHovered[i]) 310 | return dims 311 | } 312 | } 313 | 314 | func init() { 315 | dropDownIcon, _ = NewIcon(icons.NavigationArrowDropDown) 316 | dropUpIcon, _ = NewIcon(icons.NavigationArrowDropUp) 317 | } 318 | -------------------------------------------------------------------------------- /examples/colors/colors.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | // A Gio program that demonstrates gio-v widgets. 6 | // See https://gioui.org for information on the gio 7 | // gio-v is maintained by Jan Kåre Vatne (jkvatne@online.no) 8 | 9 | import ( 10 | "github.com/jkvatne/gio-v/wid" 11 | "image/color" 12 | 13 | "gioui.org/app" 14 | "gioui.org/font/gofont" 15 | "gioui.org/layout" 16 | "gioui.org/unit" 17 | ) 18 | 19 | var ( 20 | theme *wid.Theme // the theme selected 21 | form layout.Widget 22 | win app.Window 23 | roles = true 24 | ) 25 | 26 | func main() { 27 | theme = wid.NewTheme(gofont.Collection(), 20) 28 | show() 29 | win.Option(app.Title("Colors"), app.Size(1024, 620)) // , app.Maximized.Option()) 30 | go wid.Run(&win, &form, theme) 31 | app.Main() 32 | } 33 | 34 | func aTone(c color.NRGBA, n int) *color.NRGBA { 35 | col := wid.Tone(c, n) 36 | return &col 37 | } 38 | 39 | func showTones(th *wid.Theme, c color.NRGBA) layout.Widget { 40 | return wid.Row(th, nil, wid.SpaceDistribute, 41 | wid.Label(th, "00", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 00))), 42 | wid.Label(th, "10", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 10))), 43 | wid.Label(th, "20", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 20))), 44 | wid.Label(th, "30", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 30))), 45 | wid.Label(th, "40", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 40))), 46 | wid.Label(th, "50", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 50))), 47 | wid.Label(th, "60", wid.Large(), wid.Fg(&wid.White), wid.Bg(aTone(c, 60))), 48 | wid.Label(th, "70", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 70))), 49 | wid.Label(th, "80", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 80))), 50 | wid.Label(th, "90", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 88))), 51 | wid.Label(th, "94", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 93))), 52 | wid.Label(th, "98", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 97))), 53 | wid.Label(th, "100", wid.Large(), wid.Fg(&wid.Black), wid.Bg(aTone(c, 100))), 54 | ) 55 | } 56 | 57 | func setDefault() { 58 | theme = wid.NewTheme(gofont.Collection(), 20) 59 | theme.NeutralVariantColor = wid.RGB(0x356057) 60 | show() 61 | } 62 | 63 | func setPalett1() { 64 | theme.PrimaryColor = wid.RGB(0x67622E) 65 | theme.SecondaryColor = wid.RGB(0x27622E) 66 | theme.TertiaryColor = wid.RGB(0x316669) 67 | theme.ErrorColor = wid.RGB(0xAF1515) 68 | theme.NeutralColor = wid.RGB(0x1D5D7D) 69 | theme.NeutralVariantColor = wid.RGB(0x756057) 70 | theme.NeutralVariantColor = wid.RGB(0x356057) 71 | show() 72 | } 73 | 74 | func setPalett2() { 75 | theme.PrimaryColor = wid.RGB(0x17624E) 76 | theme.SecondaryColor = wid.RGB(0x27624E) 77 | theme.TertiaryColor = wid.RGB(0x136669) 78 | theme.ErrorColor = wid.RGB(0xAF1505) 79 | theme.NeutralColor = wid.RGB(0x1D4D7D) 80 | theme.NeutralVariantColor = wid.RGB(0x356057) 81 | theme.NeutralVariantColor = wid.RGB(0x356057) 82 | show() 83 | } 84 | 85 | func setPalett3() { 86 | theme.PrimaryColor = wid.RGB(0x17329E) 87 | theme.SecondaryColor = wid.RGB(0x17624E) 88 | theme.TertiaryColor = wid.RGB(0x136669) 89 | theme.ErrorColor = wid.RGB(0xBF0000) 90 | theme.NeutralColor = wid.RGB(0x1D4D7D) 91 | theme.NeutralVariantColor = wid.RGB(0x356057) 92 | show() 93 | } 94 | 95 | func setColorsRoles() { 96 | roles = !roles 97 | show() 98 | } 99 | 100 | func setDarkLight() { 101 | theme.DarkMode = !theme.DarkMode 102 | show() 103 | } 104 | 105 | func show() { 106 | theme.UpdateColors() 107 | if roles == true { 108 | form = demo2(theme) 109 | } else { 110 | form = demo1(theme) 111 | } 112 | } 113 | 114 | func demo2(th *wid.Theme) layout.Widget { 115 | var ld string 116 | var cr string 117 | if theme.DarkMode { 118 | ld = "Set light" 119 | } else { 120 | ld = "Set dark" 121 | } 122 | if roles { 123 | cr = "Show Colors" 124 | } else { 125 | cr = "Show Roles" 126 | } 127 | return wid.Col(wid.SpaceClose, 128 | wid.Label(th, "Show all UI roles", wid.Middle(), wid.Heading(), wid.Bold()), 129 | wid.Row(th, nil, wid.SpaceDistribute, 130 | wid.Button(th, "Set default", wid.Do(setDefault), wid.Hint("Set the default pallete on all widgets")), 131 | wid.Button(th, "Set palette 1", wid.Do(setPalett1), wid.Hint("Use a pallete 1")), 132 | wid.Button(th, "Set palette 2", wid.Do(setPalett2), wid.Hint("Select pallete nr 2")), 133 | wid.Button(th, "Set palette 3", wid.Do(setPalett3), wid.Hint("Select pallete nr. 3")), 134 | wid.Button(th, cr, wid.Do(setColorsRoles), wid.Hint("Change between showing color tones and role pallete")), 135 | wid.Button(th, ld, wid.Do(setDarkLight), wid.Hint("Select light or dark mode")), 136 | ), 137 | wid.Separator(th, unit.Dp(1.0), wid.Pads(3.0, 0)), 138 | wid.Row(th, nil, wid.SpaceDistribute, 139 | wid.Col(wid.SpaceDistribute, 140 | wid.Container(th, wid.Primary, 0, th.DefaultPadding, th.DefaultMargin, 141 | wid.Label(th, "Primary", wid.Large(), wid.Role(wid.Primary))), 142 | wid.Container(th, wid.Secondary, 0, th.DefaultPadding, th.DefaultMargin, 143 | wid.Label(th, "Secondary", wid.Large(), wid.Role(wid.Secondary))), 144 | wid.Container(th, wid.Tertiary, 0, th.DefaultPadding, th.DefaultMargin, 145 | wid.Label(th, "Tertiary", wid.Large(), wid.Role(wid.Tertiary))), 146 | wid.Container(th, wid.Error, 0, th.DefaultPadding, th.DefaultMargin, 147 | wid.Label(th, "Error", wid.Large(), wid.Role(wid.Error))), 148 | wid.Container(th, wid.PrimaryContainer, 0, th.DefaultPadding, th.DefaultMargin, 149 | wid.Label(th, "PrimaryContainer", wid.Large(), wid.Role(wid.PrimaryContainer))), 150 | wid.Container(th, wid.SecondaryContainer, 0, th.DefaultPadding, th.DefaultMargin, 151 | wid.Label(th, "SecondaryContainer", wid.Large(), wid.Role(wid.SecondaryContainer))), 152 | wid.Container(th, wid.TertiaryContainer, 0, th.DefaultPadding, th.DefaultMargin, 153 | wid.Label(th, "TertiaryContainer", wid.Large(), wid.Role(wid.TertiaryContainer))), 154 | wid.Container(th, wid.ErrorContainer, 0, th.DefaultPadding, th.DefaultMargin, 155 | wid.Label(th, "ErrorContainer", wid.Large(), wid.Role(wid.ErrorContainer)))), 156 | wid.Col(wid.SpaceDistribute, 157 | wid.Container(th, wid.SurfaceContainerHighest, 0, th.DefaultPadding, th.DefaultMargin, 158 | wid.Label(th, "SurfaceContainerHighest", wid.Large(), wid.Role(wid.SurfaceContainerHighest))), 159 | wid.Container(th, wid.SurfaceContainerHigh, 0, th.DefaultPadding, th.DefaultMargin, 160 | wid.Label(th, "SurfaceContainerHigh", wid.Large(), wid.Role(wid.SurfaceContainerHigh))), 161 | wid.Container(th, wid.SurfaceContainer, 0, th.DefaultPadding, th.DefaultMargin, 162 | wid.Label(th, "SurfaceContainer", wid.Large(), wid.Role(wid.SurfaceContainer))), 163 | wid.Container(th, wid.SurfaceContainerLow, 0, th.DefaultPadding, th.DefaultMargin, 164 | wid.Label(th, "SurfaceContainerLow", wid.Large(), wid.Role(wid.SurfaceContainerLow))), 165 | wid.Container(th, wid.SurfaceContainerLowest, 0, th.DefaultPadding, th.DefaultMargin, 166 | wid.Label(th, "SurfaceContainerLowest", wid.Large(), wid.Role(wid.SurfaceContainerLowest))), 167 | wid.Container(th, wid.Canvas, 0, th.DefaultPadding, th.DefaultMargin, 168 | wid.Label(th, "Canvas", wid.Large(), wid.Role(wid.Canvas))), 169 | wid.Container(th, wid.Surface, 0, th.DefaultPadding, th.DefaultMargin, 170 | wid.Label(th, "Surface", wid.Large(), wid.Role(wid.Surface))), 171 | wid.Container(th, wid.SurfaceVariant, 0, th.DefaultPadding, th.DefaultMargin, 172 | wid.Label(th, "SurfaceVariant", wid.Large(), wid.Role(wid.SurfaceVariant)))), 173 | ), 174 | wid.Row(th, nil, wid.SpaceDistribute, 175 | wid.Button(th, "Set default", wid.Do(setDefault), wid.Hint("Set the default pallete on all widgets")), 176 | wid.Button(th, "Set palette 1", wid.Do(setPalett1), wid.Hint("Use a pallete 1")), 177 | wid.Button(th, "Set palette 2", wid.Do(setPalett2), wid.Hint("Select pallete nr 2")), 178 | wid.Button(th, "Set palette 3", wid.Do(setPalett3), wid.Hint("Select pallete nr. 3")), 179 | wid.Button(th, cr, wid.Do(setColorsRoles), wid.Hint("Change between showing color tones and role pallete")), 180 | wid.Button(th, ld, wid.Do(setDarkLight), wid.Hint("Select light or dark mode")), 181 | ), 182 | ) 183 | } 184 | 185 | // Demo setup. Called from Setup(), only once - at start of showing it. 186 | // Returns a widget - i.e. a function: func(gtx C) D 187 | func demo1(th *wid.Theme) layout.Widget { 188 | var ld string 189 | var cr string 190 | if theme.DarkMode { 191 | ld = "Set light" 192 | } else { 193 | ld = "Set dark" 194 | } 195 | if roles { 196 | cr = "Show Colors" 197 | } else { 198 | cr = "Show Roles" 199 | } 200 | return wid.Col([]float32{0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1}, 201 | wid.Label(th, "Show all tones for some palettes", wid.Middle(), wid.Heading(), wid.Bold()), 202 | wid.Label(th, "Also demonstrates a form that will fill the screen 100%", wid.Middle(), wid.Small()), 203 | wid.Row(th, nil, wid.SpaceDistribute, 204 | wid.Button(th, "Set default", wid.Do(setDefault), wid.Hint("Set the default pallete on all widgets")), 205 | wid.Button(th, "Set palette 1", wid.Do(setPalett1), wid.Hint("Use a pallete 1")), 206 | wid.Button(th, "Set palette 2", wid.Do(setPalett2), wid.Hint("Select pallete nr 2")), 207 | wid.Button(th, "Set palette 3", wid.Do(setPalett3), wid.Hint("Select pallete nr. 3")), 208 | wid.Button(th, cr, wid.Do(setColorsRoles), wid.Hint("Change between showing color tones and role pallete")), 209 | wid.Button(th, ld, wid.Do(setDarkLight), wid.Hint("Select light or dark mode")), 210 | ), 211 | wid.Separator(th, unit.Dp(1.0)), 212 | wid.Label(th, "Primary"), 213 | showTones(th, th.PrimaryColor), 214 | wid.Label(th, "Secondary"), 215 | showTones(th, th.SecondaryColor), 216 | wid.Label(th, "Tertiary"), 217 | showTones(th, th.TertiaryColor), 218 | wid.Label(th, "Error"), 219 | showTones(th, th.ErrorColor), 220 | wid.Label(th, "NeutralColor"), 221 | showTones(th, th.NeutralColor), 222 | wid.Label(th, "NeutralVariantColor"), 223 | showTones(th, th.NeutralVariantColor), 224 | ) 225 | } 226 | -------------------------------------------------------------------------------- /wid/button.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | // Package wid is an alternative implementation of gio material widgets 4 | package wid 5 | 6 | import ( 7 | "gioui.org/io/pointer" 8 | "gioui.org/io/semantic" 9 | "image" 10 | "image/color" 11 | "math" 12 | 13 | "gioui.org/unit" 14 | 15 | "gioui.org/layout" 16 | "gioui.org/op" 17 | "gioui.org/op/clip" 18 | "gioui.org/op/paint" 19 | "gioui.org/text" 20 | "gioui.org/widget" 21 | ) 22 | 23 | // ButtonStyle indicates a Contained, Text, Outline or round button 24 | type ButtonStyle int 25 | 26 | const ( 27 | // Contained is a solid, colored button 28 | Contained ButtonStyle = iota 29 | // Text is a button without outline or color. Just text 30 | Text 31 | // Outlined is a text button with outline 32 | Outlined 33 | // Round is a round button, usually with icon only 34 | Round 35 | // Header is used in tables to make them clickable 36 | Header 37 | ) 38 | 39 | type StrValue interface { 40 | string | *string 41 | } 42 | 43 | // ButtonDef is the struct for buttons 44 | type ButtonDef struct { 45 | Base 46 | TooltipDef 47 | Clickable 48 | Text *string 49 | Icon *Icon 50 | Style ButtonStyle 51 | } 52 | 53 | // BtnOption is the options for buttons only 54 | type BtnOption func(*ButtonDef) 55 | 56 | // RoundButton is a shortcut to a round button 57 | func RoundButton(th *Theme, d *Icon, options ...Option) layout.Widget { 58 | options = append([]Option{Role(Primary), BtnIcon(d), W(0), RR(99999)}, options...) 59 | return aButton(Round, th, "", options...).Layout 60 | } 61 | 62 | // TextButton is a shortcut to a text only button 63 | func TextButton(th *Theme, label string, options ...Option) layout.Widget { 64 | options = append([]Option{Role(Canvas)}, options...) 65 | return aButton(Text, th, label, options...).Layout 66 | } 67 | 68 | // OutlineButton is a shortcut to an outlined button 69 | func OutlineButton(th *Theme, label string, options ...Option) layout.Widget { 70 | options = append([]Option{Role(Canvas)}, options...) 71 | return aButton(Outlined, th, label, options...).Layout 72 | } 73 | 74 | // Button is the generic button selector. Defaults to primary 75 | func Button[V StrValue](th *Theme, label V, options ...Option) layout.Widget { 76 | return aButton(Contained, th, label, options...).Layout 77 | } 78 | 79 | // HeaderButton is a shortcut to a text only button with left justified text and a given size 80 | func HeaderButton(th *Theme, label string, options ...Option) layout.Widget { 81 | options = append([]Option{Role(Canvas)}, options...) 82 | b := aButton(Text, th, label, options...) 83 | b.cornerRadius = 0 84 | b.padding = th.DefaultPadding 85 | b.margin = layout.Inset{} 86 | b.Style = Header 87 | return b.Layout 88 | } 89 | 90 | func aButton[V StrValue](style ButtonStyle, th *Theme, label V, options ...Option) *ButtonDef { 91 | b := ButtonDef{} 92 | // Setup default values 93 | b.th = th 94 | b.role = Primary 95 | if x, ok := any(label).(string); ok { 96 | b.Text = &x 97 | } 98 | if x, ok := any(label).(*string); ok { 99 | b.Text = x 100 | } 101 | b.Font = &th.DefaultFont 102 | b.Style = style 103 | b.padding = th.ButtonPadding 104 | b.margin = th.ButtonMargin 105 | b.FontScale = 1.0 106 | b.cornerRadius = th.ButtonCornerRadius 107 | for _, option := range options { 108 | option.apply(&b) 109 | } 110 | b.TooltipDef = Tooltip(th) 111 | return &b 112 | } 113 | 114 | // Layout will draw a button defined in b. 115 | func (b *ButtonDef) Layout(gtx C) D { 116 | mt, mb, ml, mr := ScaleInset(gtx, b.margin) 117 | pt, pb, pl, pr := ScaleInset(gtx, b.padding) 118 | b.CheckDisabler(gtx) 119 | // Move the whole button down/right margin offset 120 | defer op.Offset(image.Pt(ml, mt)).Push(gtx.Ops).Pop() 121 | // Handle clickable pointer/keyboard inputs 122 | b.HandleEvents(gtx) 123 | for b.Clicked() { 124 | if b.onUserChange != nil { 125 | b.onUserChange() 126 | } 127 | } 128 | // Make macro with text color 129 | recorder := op.Record(gtx.Ops) 130 | paint.ColorOp{Color: b.Fg()}.Add(gtx.Ops) 131 | colorMacro := recorder.Stop() 132 | // Allow zero size for text. 133 | cgtx := gtx 134 | cgtx.Constraints.Min.X = 0 135 | cgtx.Constraints.Min.Y = 0 136 | cgtx.Constraints.Max.X = Max(0, cgtx.Constraints.Max.X-pr-pl-ml-mr) 137 | // Render text to find text width (and save drawing commands in macro) 138 | recorder = op.Record(gtx.Ops) 139 | textDim := widget.Label{Alignment: text.Start}.Layout(cgtx, b.th.Shaper, *b.Font, b.th.TextSize*unit.Sp(b.FontScale), *b.Text, colorMacro) 140 | textMacro := recorder.Stop() 141 | // Icon size is equal to label height 142 | iconSize := 0 143 | if b.Icon != nil { 144 | iconSize = textDim.Size.Y 145 | } 146 | iconPadding := iconSize / 8 147 | 148 | height := Min(textDim.Size.Y+pt+pb, gtx.Constraints.Max.Y) 149 | // Limit corner radius 150 | rr := Min(Px(gtx, b.cornerRadius), height/2) 151 | // Icon buttonDefault button width when width is not given has padding=0.5 times icon size 152 | contentWidth := textDim.Size.X + iconSize + iconPadding 153 | width := 0 154 | if b.Style == Header { 155 | width = gtx.Constraints.Max.X 156 | } else if b.Style == Round { 157 | width = height 158 | } else { 159 | // Width is maximum of user-specified width and actual content width 160 | width = Max(contentWidth+pl+pr+rr, Px(gtx, b.width)) 161 | // But limited by gtx max constraint 162 | width = Min(gtx.Constraints.Max.X, width) 163 | } 164 | 165 | if b.Alignment == text.End { 166 | ofs := image.Pt(gtx.Constraints.Max.X-width-mr, 0) 167 | defer op.Offset(ofs).Push(gtx.Ops).Pop() 168 | } else if b.Alignment == text.Middle { 169 | defer op.Offset(image.Pt((gtx.Constraints.Max.X-width)/2, 0)).Push(gtx.Ops).Pop() 170 | } 171 | 172 | outline := image.Rect(0, 0, width, height) 173 | 174 | // Draw shadow if pressed. Must be done before clipping 175 | // because the shadow is outside the button 176 | if gtx.Focused(&b.Clickable) { 177 | DrawShadow(gtx, outline, rr, 20) 178 | } 179 | cl := clip.UniformRRect(outline, rr).Push(gtx.Ops) 180 | 181 | // Catch input from the whole button (same area that is painted with background color) 182 | b.SetupEventHandlers(gtx, outline.Max) 183 | 184 | if b.Style == Outlined { 185 | w := float32(Px(gtx, b.th.BorderThickness)) 186 | paintBorder(gtx, outline, b.th.Fg[Outline], w, rr) 187 | } else if b.Style != Text && !gtx.Enabled() { 188 | paint.Fill(gtx.Ops, Disabled(b.Bg())) 189 | } else if b.Style != Text && b.Style != Header { 190 | paint.Fill(gtx.Ops, b.Bg()) 191 | } 192 | if gtx.Focused(&b.Clickable) && b.Clickable.Hovered() { 193 | paint.Fill(gtx.Ops, MulAlpha(b.Fg(), 30)) 194 | } else if gtx.Focused(&b.Clickable) { 195 | paint.Fill(gtx.Ops, MulAlpha(b.Fg(), 20)) 196 | } else if b.Clickable.Hovered() { 197 | paint.Fill(gtx.Ops, MulAlpha(b.Fg(), 15)) 198 | } 199 | 200 | semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops) 201 | 202 | // Icon context 203 | cgtx.Constraints.Min = image.Point{X: width, Y: height} 204 | for _, pressed := range b.Clickable.History() { 205 | drawInk(cgtx, pressed) 206 | } 207 | cgtx.Constraints.Min = image.Point{X: iconSize, Y: iconSize} 208 | 209 | // Calculate internal paddings and move 210 | dy := Max(pt, (height-textDim.Size.Y)/2) 211 | dx := Max(pl+rr/2, (width-contentWidth)/2) 212 | if b.Style == Header { 213 | dx = pl 214 | } 215 | 216 | if b.Icon != nil && *b.Text != "" { 217 | // Button with icon and text 218 | // First offset by dx 219 | o1 := op.Offset(image.Pt(dx, (height-iconSize)/2)).Push(gtx.Ops) 220 | _ = b.Icon.Layout(cgtx, b.Fg()) 221 | // Draw text at given offset with an added padding between icon and text 222 | o2 := op.Offset(image.Pt(iconPadding+iconSize, 0)).Push(gtx.Ops) 223 | paint.ColorOp{Color: b.Fg()}.Add(gtx.Ops) 224 | textMacro.Add(gtx.Ops) 225 | o2.Pop() 226 | o1.Pop() 227 | } else if b.Icon != nil { 228 | // Button with Icon only 229 | dx := (height - iconSize) / 2 230 | o := op.Offset(image.Pt(dx, dx)).Push(gtx.Ops) 231 | _ = b.Icon.Layout(cgtx, b.Fg()) 232 | o.Pop() 233 | } else { 234 | // Text only 235 | o := op.Offset(image.Pt(dx, dy)).Push(gtx.Ops) 236 | paint.ColorOp{Color: b.Fg()}.Add(gtx.Ops) 237 | textMacro.Add(gtx.Ops) 238 | o.Pop() 239 | } 240 | 241 | cl.Pop() 242 | outline.Max.X += ml + mr 243 | outline.Max.Y += mt + mb 244 | gtx.Constraints.Min = outline.Max 245 | gtx.Constraints.Max = outline.Max 246 | b.TooltipDef.Layout(gtx, b.hint, b.th) 247 | 248 | pointer.CursorPointer.Add(gtx.Ops) 249 | // Return size with margins 250 | return D{Size: outline.Max} 251 | } 252 | 253 | func (b BtnOption) apply(cfg interface{}) { 254 | b(cfg.(*ButtonDef)) 255 | } 256 | 257 | // BtnIcon sets button icon 258 | func BtnIcon(i *Icon) BtnOption { 259 | return func(b *ButtonDef) { 260 | b.Icon = i 261 | } 262 | } 263 | 264 | // RR is the corner radius 265 | func RR(rr unit.Dp) BtnOption { 266 | return func(b *ButtonDef) { 267 | b.cornerRadius = rr 268 | } 269 | } 270 | 271 | func drawInk(gtx C, c Press) { 272 | // duration is the number of seconds for the completed animation: 273 | // expand while fading in, then out. 274 | const ( 275 | expandDuration = float32(0.5) 276 | fadeDuration = float32(0.9) 277 | ) 278 | now := gtx.Now 279 | t := float32(now.Sub(c.Start).Seconds()) 280 | end := c.End 281 | if end.IsZero() { 282 | // If the press hasn't ended, don't fade-out. 283 | end = now 284 | } 285 | endt := float32(end.Sub(c.Start).Seconds()) 286 | // Compute the fade-in/out position in [0;1]. 287 | var alphat float32 288 | var haste float32 289 | if c.Cancelled { 290 | // If the press was cancelled before the inkwell 291 | // was fully faded in, fast-forward the animation 292 | // to match the fade-out. 293 | if h := 0.5 - endt/fadeDuration; h > 0 { 294 | haste = h 295 | } 296 | } 297 | // Fade in. 298 | half1 := t/fadeDuration + haste 299 | if half1 > 0.5 { 300 | half1 = 0.5 301 | } 302 | // Fade out. 303 | half2 := float32(now.Sub(end).Seconds()) 304 | half2 /= fadeDuration 305 | half2 += haste 306 | if half2 > 0.5 { 307 | // Too old. 308 | return 309 | } 310 | alphat = half1 + half2 311 | // Compute the expand position in [0;1]. 312 | sizet := t 313 | if c.Cancelled { 314 | // Freeze expansion of cancelled presses. 315 | sizet = endt 316 | } 317 | sizet /= expandDuration 318 | // Animate only ended presses, and presses that are fading in. 319 | if !c.End.IsZero() || sizet <= 1.0 { 320 | gtx.Execute(op.InvalidateCmd{}) 321 | } 322 | if sizet > 1.0 { 323 | sizet = 1.0 324 | } 325 | 326 | if alphat > .5 { 327 | // Start fadeout after half the animation. 328 | alphat = 1.0 - alphat 329 | } 330 | // Twice the speed to attain fully faded in at 0.5. 331 | t2 := alphat * 2 332 | // Beziér ease-in curve. 333 | alphaBezier := t2 * t2 * (3.0 - 2.0*t2) 334 | sizeBezier := sizet * sizet * (3.0 - 2.0*sizet) 335 | size := gtx.Constraints.Min.X 336 | if h := gtx.Constraints.Min.Y; h > size { 337 | size = h 338 | } 339 | size = 10 * int(float32(size)*2*float32(math.Sqrt(2))*sizeBezier) 340 | alpha := 0.7 * alphaBezier 341 | const col = 0.8 342 | ba, bc := byte(alpha*0xff), byte(col*0xff) 343 | rgba := MulAlpha(color.NRGBA{A: 0xff, R: bc, G: bc, B: bc}, ba) 344 | ink := paint.ColorOp{Color: rgba} 345 | ink.Add(gtx.Ops) 346 | rr := size / 2 347 | defer op.Offset(c.Position.Add(image.Point{X: -rr, Y: -rr})).Push(gtx.Ops).Pop() 348 | defer clip.UniformRRect(image.Rectangle{Max: image.Pt(size, size)}, rr).Push(gtx.Ops).Pop() 349 | paint.PaintOp{}.Add(gtx.Ops) 350 | } 351 | -------------------------------------------------------------------------------- /wid/theme.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package wid 4 | 5 | import ( 6 | "image/color" 7 | "time" 8 | 9 | "golang.org/x/exp/shiny/materialdesign/icons" 10 | 11 | "gioui.org/font" 12 | "gioui.org/layout" 13 | "gioui.org/text" 14 | "gioui.org/unit" 15 | ) 16 | 17 | // UIRole describes the type of UI element 18 | // There are two colors for each UIRole, one for text/icon and one for background 19 | // Typicaly you specify a UIRole for each user element (button, checkbox etc.). 20 | // Default and Zero value is Canvas which gives black text/borders on white background. 21 | type UIRole uint8 22 | 23 | const ( 24 | // Canvas is white/black. Used in edits, dropdowns etc. to standout 25 | Canvas UIRole = iota 26 | // Surface is the default surface for windows. 27 | Surface 28 | // SurfaceVariant is for variation 29 | SurfaceVariant 30 | // Primary is for prominent buttons, active states etc 31 | Primary 32 | // PrimaryContainer is a light background tinted with Primary color. 33 | PrimaryContainer 34 | // Secondary is for less prominent components 35 | Secondary 36 | // SecondaryContainer is a light background tinted with Secondary color. 37 | SecondaryContainer 38 | // Tertiary is for contrasting elements 39 | Tertiary 40 | // TertiaryContainer is a light background tinted with Tertiary color. 41 | TertiaryContainer 42 | // Error is usualy red 43 | Error 44 | // ErrorContainer is usualy light red 45 | ErrorContainer 46 | // Outline is used for frames and buttons 47 | Outline 48 | OutlineVariant 49 | // SurfaceContainerHighest is the grayest surface 50 | SurfaceContainerHighest 51 | SurfaceContainerHigh 52 | SurfaceContainer 53 | SurfaceContainerLow 54 | // SurfaceContainerLowest is almost white/black 55 | SurfaceContainerLowest 56 | TransparentSurface 57 | RoleCount 58 | ) 59 | 60 | // Tone is the Google material tone implementation 61 | func Tone(c color.NRGBA, tone int) color.NRGBA { 62 | h, s, _ := Rgb2hsl(c) 63 | return Hsl2rgb(h, s, float64(tone)/100.0) 64 | } 65 | 66 | // Theme contains color/layout settings for all widgets 67 | type Theme struct { 68 | PrimaryColor color.NRGBA 69 | SecondaryColor color.NRGBA 70 | TertiaryColor color.NRGBA 71 | ErrorColor color.NRGBA 72 | NeutralColor color.NRGBA 73 | NeutralVariantColor color.NRGBA 74 | Bg [RoleCount]color.NRGBA 75 | Fg [RoleCount]color.NRGBA 76 | DarkMode bool 77 | Shaper *text.Shaper 78 | TextSize unit.Sp 79 | DefaultFont font.Font 80 | CheckBoxChecked *Icon 81 | CheckBoxUnchecked *Icon 82 | RadioChecked *Icon 83 | RadioUnchecked *Icon 84 | FingerSize unit.Dp // FingerSize is the minimum touch target size. 85 | SelectionColor color.NRGBA 86 | BorderThickness unit.Dp 87 | BorderColor color.NRGBA 88 | BorderColorHovered color.NRGBA 89 | BorderColorActive color.NRGBA 90 | BorderCornerRadius unit.Dp 91 | TooltipInset layout.Inset 92 | TooltipCornerRadius unit.Dp 93 | TooltipWidth unit.Dp 94 | TooltipBackground color.NRGBA 95 | TooltipOnBackground color.NRGBA 96 | DefaultMargin layout.Inset 97 | DefaultPadding layout.Inset 98 | IconInset layout.Inset 99 | ListInset layout.Inset 100 | ButtonPadding layout.Inset 101 | ButtonMargin layout.Inset 102 | ButtonCornerRadius unit.Dp 103 | IconSize unit.Dp 104 | // Elevation is the shadow width 105 | Elevation unit.Dp 106 | // SashColor is the color of the movable divider 107 | SashColor color.NRGBA 108 | SashWidth unit.Dp 109 | TrackColor color.NRGBA 110 | DotColor color.NRGBA 111 | // Tooltip settings 112 | // HoverDelay is the delay between the cursor entering the tip area 113 | // and the tooltip appearing. 114 | HoverDelay time.Duration 115 | // LongPressDelay is the required duration of a press in the area for 116 | // it to count as a long press. 117 | LongPressDelay time.Duration 118 | // LongPressDuration is the amount of time the tooltip should be displayed 119 | // after being triggered by a long press. 120 | LongPressDuration time.Duration 121 | // FadeDuration is the amount of time it takes the tooltip to fade in 122 | // and out. 123 | FadeDuration time.Duration 124 | RowPadTop unit.Dp 125 | RowPadBtm unit.Dp 126 | // Scroll bar size 127 | ScrollMajorPadding unit.Dp 128 | ScrollMinorPadding unit.Dp 129 | ScrollMajorMinLen unit.Dp 130 | ScrollMinorWidth unit.Dp 131 | ScrollCornerRadius unit.Dp 132 | // Default split between edit label and edit field 133 | LabelSplit float32 134 | // Extra scaling of the Dp unit 135 | Scale float32 136 | DialogPadding layout.Inset 137 | DialogCorners unit.Dp 138 | DialogTextWidth unit.Sp 139 | } 140 | 141 | func mustIcon(ic *Icon, err error) *Icon { 142 | if err != nil { 143 | panic(err) 144 | } 145 | return ic 146 | } 147 | 148 | func uniformPadding(p float32) layout.Inset { 149 | pp := unit.Dp(p) 150 | return layout.Inset{Top: pp, Bottom: pp, Left: pp, Right: pp} 151 | } 152 | 153 | func (th *Theme) Dp(x unit.Dp) unit.Dp { 154 | return x 155 | } 156 | 157 | type GuiUnit interface{ unit.Dp | unit.Sp } 158 | 159 | // Px will convert a size given in either Dp or Sp to pixels 160 | // It applies the theme's scaling factor in addition to 161 | // the gtx metric's PixelPrSp and PixelPrDp 162 | func Px(gtx C, dp interface{}) int { 163 | if u, ok := dp.(unit.Dp); ok { 164 | return gtx.Dp(u) 165 | } 166 | if u, ok := dp.(unit.Sp); ok { 167 | return gtx.Sp(u) 168 | } 169 | panic("Px() called with illegal value") 170 | } 171 | 172 | // UpdateColors must be called after changing the pallete 173 | // See https://m3.material.io/styles/color/static/baseline 174 | func (th *Theme) UpdateColors() { 175 | if !th.DarkMode { 176 | 177 | // Light mode 178 | 179 | th.Fg[Canvas] = Tone(th.NeutralColor, 0) 180 | th.Bg[Canvas] = Tone(th.NeutralColor, 100) 181 | 182 | th.Fg[Primary] = Tone(th.PrimaryColor, 100) // #FFFFFF 183 | th.Bg[Primary] = Tone(th.PrimaryColor, 48) // #6750A4 184 | th.Fg[PrimaryContainer] = Tone(th.PrimaryColor, 10) // #21005D 185 | th.Bg[PrimaryContainer] = Tone(th.PrimaryColor, 90) // #EADDFF 186 | 187 | th.Fg[Secondary] = Tone(th.SecondaryColor, 100) 188 | th.Bg[Secondary] = Tone(th.SecondaryColor, 40) 189 | th.Fg[SecondaryContainer] = Tone(th.SecondaryColor, 10) // #1D192B 190 | th.Bg[SecondaryContainer] = Tone(th.SecondaryColor, 87) // #E8DEF8 191 | 192 | th.Fg[Tertiary] = Tone(th.TertiaryColor, 100) 193 | th.Bg[Tertiary] = Tone(th.TertiaryColor, 41) 194 | th.Fg[TertiaryContainer] = Tone(th.TertiaryColor, 10) // #1D192B 195 | th.Bg[TertiaryContainer] = Tone(th.TertiaryColor, 87) // #FFD8E4 196 | 197 | th.Fg[Error] = Tone(th.ErrorColor, 100) 198 | th.Bg[Error] = Tone(th.ErrorColor, 40) 199 | th.Fg[ErrorContainer] = Tone(th.ErrorColor, 30) // #410E0B 200 | th.Bg[ErrorContainer] = Tone(th.ErrorColor, 90) // #F9DEDC 201 | 202 | th.Fg[Outline] = Tone(th.NeutralVariantColor, 40) 203 | th.Bg[Outline] = Tone(th.NeutralVariantColor, 40) 204 | th.Fg[OutlineVariant] = Tone(th.NeutralVariantColor, 40) 205 | th.Bg[OutlineVariant] = Tone(th.NeutralVariantColor, 40) 206 | 207 | th.Fg[Surface] = Tone(th.NeutralColor, 10) // #1D1B20 208 | th.Bg[Surface] = Tone(th.NeutralColor, 98) // #FEF7FF 209 | th.Fg[SurfaceVariant] = Tone(th.NeutralVariantColor, 40) // #49454F 210 | th.Bg[SurfaceVariant] = Tone(th.NeutralVariantColor, 93) // #E7E0EC 211 | 212 | th.Fg[SurfaceContainerHighest] = Tone(th.NeutralColor, 10) // #1D1B20 213 | th.Bg[SurfaceContainerHighest] = Tone(th.NeutralColor, 90) // #E6E0E9 214 | th.Fg[SurfaceContainerHigh] = Tone(th.NeutralColor, 10) // #1D1B20 215 | th.Bg[SurfaceContainerHigh] = Tone(th.NeutralColor, 92) // #ECE6F0 216 | th.Fg[SurfaceContainer] = Tone(th.NeutralColor, 10) // #1D1B20 217 | th.Bg[SurfaceContainer] = Tone(th.NeutralColor, 94) // #F3EDF7 218 | th.Fg[SurfaceContainerLow] = Tone(th.NeutralColor, 10) // #1D1B20 219 | th.Bg[SurfaceContainerLow] = Tone(th.NeutralColor, 96) // #F7F2FA 220 | th.Fg[SurfaceContainerLowest] = Tone(th.NeutralColor, 10) // #1D1B20 221 | th.Bg[SurfaceContainerLowest] = Tone(th.NeutralColor, 100) // #FFFFFF 222 | th.Bg[TransparentSurface] = MulAlpha(th.Fg[SurfaceContainer], 199) 223 | th.Fg[TransparentSurface] = MulAlpha(th.Fg[SurfaceContainer], 100) 224 | 225 | } else { 226 | 227 | // Dark mode 228 | 229 | th.Fg[Canvas] = Tone(th.NeutralColor, 100) 230 | th.Bg[Canvas] = Tone(th.NeutralColor, 0) 231 | 232 | th.Fg[Primary] = Tone(th.PrimaryColor, 20) 233 | th.Bg[Primary] = Tone(th.PrimaryColor, 80) 234 | th.Fg[PrimaryContainer] = Tone(th.PrimaryColor, 90) 235 | th.Bg[PrimaryContainer] = Tone(th.PrimaryColor, 30) 236 | 237 | th.Fg[Secondary] = Tone(th.SecondaryColor, 20) 238 | th.Bg[Secondary] = Tone(th.SecondaryColor, 80) 239 | th.Fg[SecondaryContainer] = Tone(th.SecondaryColor, 90) 240 | th.Bg[SecondaryContainer] = Tone(th.SecondaryColor, 30) 241 | 242 | th.Fg[Tertiary] = Tone(th.TertiaryColor, 20) 243 | th.Bg[Tertiary] = Tone(th.TertiaryColor, 80) 244 | th.Fg[TertiaryContainer] = Tone(th.TertiaryColor, 90) 245 | th.Bg[TertiaryContainer] = Tone(th.TertiaryColor, 30) 246 | 247 | th.Fg[Error] = Tone(th.ErrorColor, 20) 248 | th.Bg[Error] = Tone(th.ErrorColor, 80) 249 | th.Fg[ErrorContainer] = Tone(th.ErrorColor, 90) 250 | th.Bg[ErrorContainer] = Tone(th.ErrorColor, 30) 251 | 252 | th.Fg[Outline] = Tone(th.NeutralVariantColor, 60) 253 | th.Bg[Outline] = Tone(th.NeutralVariantColor, 60) 254 | th.Fg[OutlineVariant] = Tone(th.NeutralVariantColor, 30) 255 | th.Bg[OutlineVariant] = Tone(th.NeutralVariantColor, 30) 256 | 257 | th.Fg[Surface] = Tone(th.NeutralColor, 90) 258 | th.Bg[Surface] = Tone(th.NeutralColor, 12) 259 | th.Fg[SurfaceVariant] = Tone(th.NeutralVariantColor, 90) 260 | th.Bg[SurfaceVariant] = Tone(th.NeutralVariantColor, 30) 261 | 262 | th.Fg[SurfaceContainerHighest] = Tone(th.NeutralColor, 90) 263 | th.Bg[SurfaceContainerHighest] = Tone(th.NeutralColor, 22) 264 | th.Fg[SurfaceContainerHigh] = Tone(th.NeutralColor, 90) 265 | th.Bg[SurfaceContainerHigh] = Tone(th.NeutralColor, 17) 266 | th.Fg[SurfaceContainer] = Tone(th.NeutralColor, 90) 267 | th.Bg[SurfaceContainer] = Tone(th.NeutralColor, 13) 268 | th.Fg[SurfaceContainerLow] = Tone(th.NeutralColor, 90) 269 | th.Bg[SurfaceContainerLow] = Tone(th.NeutralColor, 9) 270 | th.Fg[SurfaceContainerLowest] = Tone(th.NeutralColor, 90) 271 | th.Bg[SurfaceContainerLowest] = Tone(th.NeutralColor, 4) 272 | } 273 | // Borders around edit fields 274 | th.BorderColor = th.Fg[Outline] 275 | th.BorderColorHovered = th.Fg[Primary] 276 | th.BorderColorActive = th.Fg[Primary] 277 | th.SelectionColor = MulAlpha(th.Bg[Primary], 0x60) 278 | // Tooltip 279 | th.TooltipBackground = th.Bg[TertiaryContainer] 280 | th.TooltipOnBackground = th.Fg[TertiaryContainer] 281 | // Resizer 282 | th.SashColor = WithAlpha(th.Fg[Surface], 0x40) 283 | // Switch 284 | th.TrackColor = th.Bg[Surface] 285 | th.DotColor = th.Fg[Primary] 286 | } 287 | 288 | // NewTheme creates a new theme with given font size and pallete 289 | // The pallet can be left out, to use the defaults - or include as many colors you like. 290 | func NewTheme(fontCollection []text.FontFace, fontSize unit.Sp, colors ...color.NRGBA) *Theme { 291 | th := new(Theme) 292 | th.Scale = 1.0 293 | th.TextSize = fontSize 294 | // Set up the default pallete 295 | th.PrimaryColor = RGB(0x6750A4) 296 | th.SecondaryColor = RGB(0x625B71) 297 | th.TertiaryColor = RGB(0x567E3E) 298 | th.ErrorColor = RGB(0xCF1010) 299 | th.NeutralColor = RGB(0x79747E) 300 | th.NeutralVariantColor = RGB(0x79747E) 301 | // Then replace the optional colors in the argument list 302 | if len(colors) >= 1 { 303 | th.PrimaryColor = colors[0] 304 | } 305 | if len(colors) >= 2 { 306 | th.SecondaryColor = colors[1] 307 | } 308 | if len(colors) >= 3 { 309 | th.TertiaryColor = colors[2] 310 | } 311 | if len(colors) >= 4 { 312 | th.ErrorColor = colors[3] 313 | } 314 | // Setup icons 315 | th.CheckBoxChecked = mustIcon(NewIcon(icons.ToggleCheckBox)) 316 | th.CheckBoxUnchecked = mustIcon(NewIcon(icons.ToggleCheckBoxOutlineBlank)) 317 | th.RadioChecked = mustIcon(NewIcon(icons.ToggleRadioButtonChecked)) 318 | th.RadioUnchecked = mustIcon(NewIcon(icons.ToggleRadioButtonUnchecked)) 319 | // Setup font types 320 | th.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(fontCollection)) 321 | // Default to equal length for label and editor 322 | th.LabelSplit = 0.5 323 | th.FingerSize = unit.Dp(38) 324 | th.IconInset = layout.Inset{Top: 1, Right: 1, Bottom: 1, Left: 1} 325 | th.BorderThickness = 1.0 326 | th.BorderCornerRadius = 4.0 327 | // Shadow 328 | th.Elevation = 0.5 329 | // Text 330 | th.DefaultMargin = uniformPadding(6.0) 331 | th.DefaultPadding = layout.Inset{4, 4, 4, 2} 332 | th.ButtonPadding = uniformPadding(6.0) 333 | th.ButtonCornerRadius = th.BorderCornerRadius 334 | th.ButtonMargin = uniformPadding(4.0) 335 | th.IconSize = 20.0 336 | th.TooltipCornerRadius = th.BorderCornerRadius 337 | th.TooltipWidth = 250.0 338 | th.SashWidth = 8.0 339 | th.RowPadTop = 0.0 340 | th.RowPadBtm = 0.0 341 | th.ScrollMajorPadding = 2 342 | th.ScrollMinorPadding = 2 343 | th.ScrollMajorMinLen = 15.5 344 | th.ScrollMinorWidth = 10 345 | th.ScrollCornerRadius = 4.0 346 | th.TooltipInset = layout.UniformInset(1) 347 | th.DialogPadding = layout.Inset{33, 13, 33, 33} 348 | th.DialogCorners = 20 349 | th.DialogTextWidth = th.TextSize * 20 350 | // Update all colors from the pallete 351 | th.UpdateColors() 352 | return th 353 | } 354 | --------------------------------------------------------------------------------