diff --git a/apps/gophers/main.go b/apps/gophers/main.go index 58c65052..5a697909 100644 --- a/apps/gophers/main.go +++ b/apps/gophers/main.go @@ -9,13 +9,10 @@ import ( "flag" "fmt" "image" - "image/color" "log" "net/http" "os" - "runtime" - "golang.org/x/image/draw" "golang.org/x/oauth2" _ "image/jpeg" @@ -25,84 +22,22 @@ import ( "gioui.org/ui" "gioui.org/ui/app" - gdraw "gioui.org/ui/draw" - "gioui.org/ui/f32" "gioui.org/ui/gesture" - "gioui.org/ui/input" "gioui.org/ui/key" "gioui.org/ui/layout" - "gioui.org/ui/measure" - "gioui.org/ui/pointer" - "gioui.org/ui/system" - "gioui.org/ui/text" - "gioui.org/ui/widget" - "golang.org/x/exp/shiny/iconvg" "github.com/google/go-github/v24/github" - "golang.org/x/image/font/gofont/gobold" - "golang.org/x/image/font/gofont/goitalic" - "golang.org/x/image/font/gofont/gomono" - "golang.org/x/image/font/gofont/goregular" - "golang.org/x/image/font/sfnt" - - "golang.org/x/exp/shiny/materialdesign/icons" ) type App struct { - w *app.Window - faces measure.Faces + w *app.Window - fab *ActionButton + ui *UI - usersList *layout.List - edit, edit2 *text.Editor - - users []*user - userClicks []gesture.Click - selectedUser *userPage - - updateUsers chan []*user - - ctx context.Context - ctxCancel context.CancelFunc - - // Profiling. - profiling bool - profile system.ProfileEvent - lastMallocs uint64 -} - -type userPage struct { - config ui.Config - faces measure.Faces - invalidate func() - user *user - commitsList *layout.List - commits []*github.Commit + updateUsers chan []*user commitsResult chan []*github.Commit -} - -type user struct { - name string - login string - company string - avatar image.Image -} - -type icon struct { - src []byte - size ui.Value - - // Cached values. - img image.Image - imgSize int -} - -type ActionButton struct { - face text.Face - Open bool - icons []*icon - sendIco *icon + ctx context.Context + ctxCancel context.CancelFunc } var ( @@ -111,20 +46,6 @@ var ( token = flag.String("token", "", "Github authentication token") ) -var fonts struct { - regular *sfnt.Font - bold *sfnt.Font - italic *sfnt.Font - mono *sfnt.Font -} - -var theme struct { - text ui.MacroOp - tertText ui.MacroOp - brand ui.MacroOp - white ui.MacroOp -} - func main() { flag.Parse() initProfiling() @@ -133,15 +54,6 @@ func main() { fmt.Println("See https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line.") } go func() { - fonts.regular = mustLoadFont(goregular.TTF) - fonts.bold = mustLoadFont(gobold.TTF) - fonts.italic = mustLoadFont(goitalic.TTF) - fonts.mono = mustLoadFont(gomono.TTF) - var ops ui.Ops - theme.text = colorMaterial(&ops, rgb(0x333333)) - theme.tertText = colorMaterial(&ops, rgb(0xbbbbbb)) - theme.brand = colorMaterial(&ops, rgb(0x62798c)) - theme.white = colorMaterial(&ops, rgb(0xffffff)) w := app.NewWindow(&app.WindowOptions{ Width: ui.Dp(400), Height: ui.Dp(800), @@ -163,23 +75,18 @@ func initProfiling() { }() } -func colorMaterial(ops *ui.Ops, color color.RGBA) ui.MacroOp { - var mat ui.MacroOp - mat.Record(ops) - gdraw.ColorOp{Color: color}.Add(ops) - mat.Stop() - return mat -} - func (a *App) run() error { - a.profiling = *stats + a.ui.profiling = *stats ops := new(ui.Ops) var cfg app.Config for { select { case users := <-a.updateUsers: - a.users = users - a.userClicks = make([]gesture.Click, len(users)) + a.ui.users = users + a.ui.userClicks = make([]gesture.Click, len(users)) + a.w.Invalidate() + case commits := <-a.commitsResult: + a.ui.selectedUser.commits = commits a.w.Invalidate() case e := <-a.w.Events(): switch e := e.(type) { @@ -189,7 +96,7 @@ func (a *App) run() error { os.Exit(0) case 'P': if e.Modifiers.Contain(key.ModCommand) { - a.profiling = !a.profiling + a.ui.profiling = !a.ui.profiling a.w.Invalidate() } } @@ -200,7 +107,7 @@ func (a *App) run() error { if a.ctxCancel == nil { a.ctx, a.ctxCancel = context.WithCancel(context.Background()) } - if a.users == nil { + if a.ui.users == nil { go a.fetchContributors() } } else { @@ -212,21 +119,17 @@ func (a *App) run() error { case *app.CommandEvent: switch e.Type { case app.CommandBack: - if a.selectedUser != nil { - a.selectedUser = nil + if a.ui.selectedUser != nil { + a.ui.selectedUser = nil e.Cancel = true a.w.Invalidate() } } case app.DrawEvent: ops.Reset() - a.faces.Reset(&cfg) cfg = e.Config cs := layout.RigidConstraints(e.Size) - a.Layout(&cfg, a.w.Queue(), ops, cs) - if a.profiling { - a.layoutTimings(&cfg, a.w.Queue(), ops, cs) - } + a.ui.Layout(&cfg, a.w.Queue(), ops, cs) a.w.Draw(ops) } } @@ -235,33 +138,14 @@ func (a *App) run() error { func newApp(w *app.Window) *App { a := &App{ - w: w, - updateUsers: make(chan []*user), + w: w, + updateUsers: make(chan []*user), + commitsResult: make(chan []*github.Commit, 1), } - a.usersList = &layout.List{ - Axis: layout.Vertical, + fetch := func(u string) { + a.fetchCommits(a.ctx, u) } - a.fab = &ActionButton{ - face: a.face(fonts.regular, 11), - sendIco: &icon{src: icons.ContentSend, size: ui.Dp(24)}, - icons: []*icon{}, - } - a.edit2 = &text.Editor{ - Face: a.face(fonts.italic, 14), - //Alignment: text.End, - SingleLine: true, - Hint: "Hint", - HintMaterial: theme.tertText, - Material: theme.text, - } - a.edit2.SetText("Single line editor. Edit me!") - a.edit = &text.Editor{ - Face: a.face(fonts.regular, 16), - Material: theme.text, - //Alignment: text.End, - //SingleLine: true, - } - a.edit.SetText(longTextSample) + a.ui = newUI(w.Invalidate, fetch) return a } @@ -342,122 +226,11 @@ func fetchImage(url string) (image.Image, error) { return img, nil } -func mustLoadFont(fontData []byte) *sfnt.Font { - fnt, err := sfnt.Parse(fontData) - if err != nil { - panic("failed to load font") - } - return fnt -} - -func rgb(c uint32) color.RGBA { - return argb((0xff << 24) | c) -} - -func argb(c uint32) color.RGBA { - return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} -} - -func (a *App) face(f *sfnt.Font, size float32) text.Face { - return a.faces.For(f, ui.Sp(size)) -} - -func (a *App) layoutTimings(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - for e, ok := q.Next(a); ok; e, ok = q.Next(a) { - if e, ok := e.(system.ProfileEvent); ok { - a.profile = e - } - } - system.ProfileOp{Key: a}.Add(ops) - var mstats runtime.MemStats - runtime.ReadMemStats(&mstats) - mallocs := mstats.Mallocs - a.lastMallocs - a.lastMallocs = mstats.Mallocs - al := layout.Align{Alignment: layout.NE} - cs = al.Begin(ops, cs) - in := layout.Inset{Top: ui.Dp(16)} - cs = in.Begin(c, ops, cs) - txt := fmt.Sprintf("m: %d %s", mallocs, a.profile.Timings) - dims := text.Label{Material: theme.text, Face: a.face(fonts.mono, 10), Text: txt}.Layout(ops, cs) - dims = in.End(dims) - return al.End(dims) -} - -func (a *App) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - for i := range a.userClicks { - click := &a.userClicks[i] - for _, e := range click.Events(q) { - if e.Type == gesture.TypeClick { - a.selectedUser = a.newUserPage(a.users[i]) - } - } - } - if a.selectedUser == nil { - return a.layoutUsers(c, q, ops, cs) - } else { - return a.selectedUser.Layout(c, q, ops, cs) - } -} - -func (a *App) newUserPage(user *user) *userPage { - up := &userPage{ - faces: a.faces, - invalidate: a.w.Invalidate, - user: user, - commitsList: &layout.List{Axis: layout.Vertical}, - commitsResult: make(chan []*github.Commit, 1), - } - up.fetchCommits(a.ctx) - return up -} - -func (up *userPage) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - l := up.commitsList - if l.Dragging() { - key.HideInputOp{}.Add(ops) - } - select { - case commits := <-up.commitsResult: - up.commits = commits - default: - } - for l.Init(c, q, ops, cs, len(up.commits)); l.More(); l.Next() { - l.Elem(up.commit(c, ops, l.Constraints(), l.Index())) - } - return l.Layout() -} - -func (up *userPage) commit(c ui.Config, ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens { - u := up.user - msg := up.commits[index].GetMessage() - label := text.Label{Material: theme.text, Face: up.faces.For(fonts.regular, ui.Sp(12)), Text: msg} - in := layout.Inset{Top: ui.Dp(16), Right: ui.Dp(8), Left: ui.Dp(8)} - cs = in.Begin(c, ops, cs) - f := (&layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start}).Init(ops, cs) - cs = f.Rigid() - sz := c.Px(ui.Dp(48)) - cc := clipCircle{} - cs = cc.Begin(ops, cs) - cs = layout.RigidConstraints(cs.Constrain(image.Point{X: sz, Y: sz})) - dims := widget.Image{Src: u.avatar, Rect: u.avatar.Bounds()}.Layout(c, ops, cs) - dims = cc.End(dims) - c1 := f.End(dims) - cs = f.Flexible(1) - cs.Width.Min = cs.Width.Max - in2 := layout.Inset{Left: ui.Dp(8)} - cs = in2.Begin(c, ops, cs) - dims = label.Layout(ops, cs) - dims = in2.End(dims) - c2 := f.End(dims) - dims = f.Layout(c1, c2) - return in.End(dims) -} - -func (up *userPage) fetchCommits(ctx context.Context) { +func (a *App) fetchCommits(ctx context.Context, user string) { go func() { gh := githubClient(ctx) repoCommits, _, err := gh.Repositories.ListCommits(ctx, "golang", "go", &github.CommitsListOptions{ - Author: up.user.login, + Author: user, }) if err != nil { log.Printf("failed to fetch commits: %v", err) @@ -469,321 +242,6 @@ func (up *userPage) fetchCommits(ctx context.Context) { commits = append(commits, c) } } - up.commitsResult <- commits - up.invalidate() + a.commitsResult <- commits }() } - -func (a *App) layoutUsers(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - st := (&layout.Stack{Alignment: layout.Start}).Init(ops, cs) - cs = st.Rigid() - al := layout.Align{Alignment: layout.SE} - in := layout.UniformInset(ui.Dp(16)) - cs = in.Begin(c, ops, al.Begin(ops, cs)) - dims := a.fab.Layout(c, q, ops, cs) - dims = al.End(in.End(dims)) - c2 := st.End(dims) - - cs = st.Expand() - { - f := (&layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}).Init(ops, cs) - - cs = f.Rigid() - { - cs.Width.Min = cs.Width.Max - in := layout.UniformInset(ui.Dp(16)) - sz := c.Px(ui.Dp(200)) - cs = layout.RigidConstraints(cs.Constrain(image.Point{X: sz, Y: sz})) - dims = a.edit.Layout(c, q, ops, in.Begin(c, ops, cs)) - dims = in.End(dims) - } - c1 := f.End(dims) - - cs = f.Rigid() - { - cs.Width.Min = cs.Width.Max - in := layout.Inset{Bottom: ui.Dp(16), Left: ui.Dp(16), Right: ui.Dp(16)} - dims = a.edit2.Layout(c, q, ops, in.Begin(c, ops, cs)) - dims = in.End(dims) - } - c2 := f.End(dims) - - cs = f.Rigid() - { - cs.Width.Min = cs.Width.Max - s := layout.Stack{Alignment: layout.Center} - s.Init(ops, cs) - cs = s.Rigid() - in := layout.Inset{Top: ui.Dp(16), Right: ui.Dp(8), Bottom: ui.Dp(8), Left: ui.Dp(8)} - grey := colorMaterial(ops, rgb(0x888888)) - lbl := text.Label{Material: grey, Face: a.face(fonts.regular, 11), Text: "GOPHERS"} - dims = in.End(lbl.Layout(ops, in.Begin(c, ops, cs))) - c2 := s.End(dims) - c1 := s.End(fill{colorMaterial(ops, rgb(0xf2f2f2))}.Layout(ops, s.Expand())) - dims = s.Layout(c1, c2) - } - c3 := f.End(dims) - - cs = f.Flexible(1) - cs.Width.Min = cs.Width.Max - dims = a.layoutContributors(c, q, ops, cs) - c4 := f.End(dims) - dims = f.Layout(c1, c2, c3, c4) - } - c1 := st.End(dims) - return st.Layout(c1, c2) -} - -func (a *ActionButton) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.End} - f.Init(ops, cs) - cs = f.Rigid() - in := layout.Inset{Top: ui.Dp(4)} - cs = in.Begin(c, ops, cs) - dims := fab(ops, cs, a.sendIco.image(c), theme.brand, c.Px(ui.Dp(56))) - pointer.EllipseAreaOp{Rect: image.Rectangle{Max: dims.Size}}.Add(ops) - dims = in.End(dims) - return f.Layout(f.End(dims)) -} - -func (a *App) layoutContributors(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { - l := a.usersList - if l.Dragging() { - key.HideInputOp{}.Add(ops) - } - for l.Init(c, q, ops, cs, len(a.users)); l.More(); l.Next() { - l.Elem(a.user(c, ops, l.Constraints(), l.Index())) - } - return l.Layout() -} - -func (a *App) user(c ui.Config, ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens { - u := a.users[index] - elem := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start} - elem.Init(ops, cs) - cs = elem.Rigid() - var dims layout.Dimens - { - in := layout.UniformInset(ui.Dp(8)) - cs = in.Begin(c, ops, cs) - f := centerRowOpts() - f.Init(ops, cs) - cs = f.Rigid() - { - in := layout.Inset{Right: ui.Dp(8)} - cc := clipCircle{} - cs = cc.Begin(ops, in.Begin(c, ops, cs)) - sz := image.Point{X: c.Px(ui.Dp(48)), Y: c.Px(ui.Dp(48))} - cs = layout.RigidConstraints(cs.Constrain(sz)) - dims = widget.Image{Src: u.avatar, Rect: u.avatar.Bounds()}.Layout(c, ops, cs) - dims = in.End(cc.End(dims)) - } - c1 := f.End(dims) - cs = f.Rigid() - { - f := column() - f.Init(ops, cs) - cs = f.Rigid() - { - f := baseline() - f.Init(ops, cs) - cs = f.Rigid() - dims = text.Label{Material: theme.text, Face: a.face(fonts.regular, 13), Text: u.name}.Layout(ops, cs) - c1 := f.End(dims) - cs = f.Flexible(1) - cs.Width.Min = cs.Width.Max - al := layout.Align{Alignment: layout.E} - in := layout.Inset{Left: ui.Dp(2)} - cs = in.Begin(c, ops, al.Begin(ops, cs)) - dims = text.Label{Material: theme.text, Face: a.face(fonts.regular, 10), Text: "3 hours ago"}.Layout(ops, cs) - dims = al.End(in.End(dims)) - c2 := f.End(dims) - dims = f.Layout(c1, c2) - } - c1 := f.End(dims) - cs = f.Rigid() - in := layout.Inset{Top: ui.Dp(4)} - cs = in.Begin(c, ops, cs) - dims = text.Label{Material: theme.tertText, Face: a.face(fonts.regular, 12), Text: u.company}.Layout(ops, cs) - dims = in.End(dims) - c2 := f.End(dims) - dims = f.Layout(c1, c2) - } - c2 := f.End(dims) - dims = f.Layout(c1, c2) - dims = in.End(dims) - pointer.RectAreaOp{Rect: image.Rectangle{Max: dims.Size}}.Add(ops) - click := &a.userClicks[index] - click.Add(ops) - } - c1 := elem.End(dims) - return elem.Layout(c1) -} - -type fill struct { - material ui.MacroOp -} - -func (f fill) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens { - d := image.Point{X: cs.Width.Max, Y: cs.Height.Max} - dr := f32.Rectangle{ - Max: f32.Point{X: float32(d.X), Y: float32(d.Y)}, - } - f.material.Add(ops) - gdraw.DrawOp{Rect: dr}.Add(ops) - return layout.Dimens{Size: d, Baseline: d.Y} -} - -func column() layout.Flex { - return layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start} -} - -func centerRowOpts() layout.Flex { - return layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Center} -} - -func baseline() layout.Flex { - return layout.Flex{Axis: layout.Horizontal, CrossAxisAlignment: layout.Baseline} -} - -type clipCircle struct { - m ui.MacroOp - ops *ui.Ops -} - -func (c *clipCircle) Begin(ops *ui.Ops, cs layout.Constraints) layout.Constraints { - c.ops = ops - c.m.Record(ops) - return cs -} - -func (c *clipCircle) End(dims layout.Dimens) layout.Dimens { - c.m.Stop() - ops := c.ops - max := dims.Size.X - if dy := dims.Size.Y; dy > max { - max = dy - } - szf := float32(max) - rr := szf * .5 - var stack ui.StackOp - stack.Push(ops) - rrect(ops, szf, szf, rr, rr, rr, rr) - c.m.Add(ops) - stack.Pop() - return dims -} - -func fab(ops *ui.Ops, cs layout.Constraints, ico image.Image, mat ui.MacroOp, size int) layout.Dimens { - dp := image.Point{X: (size - ico.Bounds().Dx()) / 2, Y: (size - ico.Bounds().Dy()) / 2} - dims := image.Point{X: size, Y: size} - rr := float32(size) * .5 - rrect(ops, float32(size), float32(size), rr, rr, rr, rr) - mat.Add(ops) - gdraw.DrawOp{Rect: f32.Rectangle{Max: f32.Point{X: float32(size), Y: float32(size)}}}.Add(ops) - gdraw.ImageOp{Src: ico, Rect: ico.Bounds()}.Add(ops) - gdraw.DrawOp{ - Rect: toRectF(ico.Bounds().Add(dp)), - }.Add(ops) - return layout.Dimens{Size: dims} -} - -func toRectF(r image.Rectangle) f32.Rectangle { - return f32.Rectangle{ - Min: f32.Point{X: float32(r.Min.X), Y: float32(r.Min.Y)}, - Max: f32.Point{X: float32(r.Max.X), Y: float32(r.Max.Y)}, - } -} - -func (ic *icon) image(cfg ui.Config) image.Image { - sz := cfg.Px(ic.size) - if sz == ic.imgSize { - return ic.img - } - m, _ := iconvg.DecodeMetadata(ic.src) - dx, dy := m.ViewBox.AspectRatio() - img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}}) - var ico iconvg.Rasterizer - ico.SetDstImage(img, img.Bounds(), draw.Src) - // Use white for icons. - m.Palette[0] = color.RGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} - iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{ - Palette: &m.Palette, - }) - ic.img = img - ic.imgSize = sz - return img -} - -// https://pomax.github.io/bezierinfo/#circles_cubic. -func rrect(ops *ui.Ops, width, height, se, sw, nw, ne float32) { - w, h := float32(width), float32(height) - const c = 0.55228475 // 4*(sqrt(2)-1)/3 - var b gdraw.PathBuilder - b.Init(ops) - b.Move(f32.Point{X: w, Y: h - se}) - b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE - b.Line(f32.Point{X: sw - w + se, Y: 0}) - b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW - b.Line(f32.Point{X: 0, Y: nw - h + sw}) - b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW - b.Line(f32.Point{X: w - ne - nw, Y: 0}) - b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE - b.End() -} - -const longTextSample = `1. I learned from my grandfather, Verus, to use good manners, and to -put restraint on anger. 2. In the famous memory of my father I had a -pattern of modesty and manliness. 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. 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. - -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. - -6. Of Diognetus I had the lesson not to busy myself about vain things; -not to credit the great professions of such as pretend to work -wonders, or of sorcerers about their charms, and their expelling of -Demons and the like; not to keep quails (for fighting or divination), -nor to run after such things; to suffer freedom of speech in others, -and to apply myself heartily to philosophy. Him also I must thank for -my hearing first Bacchius, then Tandasis and Marcianus; that I wrote -dialogues in my youth, and took a liking to the philosopher's pallet -and skins, and to the other things which, by the Grecian discipline, -belong to that profession. - -7. To Rusticus I owe my first apprehensions that my nature needed -reform and cure; and that I did not fall into the ambition of the -common Sophists, either by composing speculative writings or by -declaiming harangues of exhortation in public; further, that I never -strove to be admired by ostentation of great patience in an ascetic -life, or by display of activity and application; that I gave over the -study of rhetoric, poetry, and the graces of language; and that I did -not pace my house in my senatorial robes, or practise any similar -affectation. I observed also the simplicity of style in his letters, -particularly in that which he wrote to my mother from Sinuessa. I -learned from him to be easily appeased, and to be readily reconciled -with those who had displeased me or given cause of offence, so soon as -they inclined to make their peace; to read with care; not to rest -satisfied with a slight and superficial knowledge; nor quickly to -assent to great talkers. I have him to thank that I met with the -discourses of Epictetus, which he furnished me from his own library. - -8. From Apollonius I learned true liberty, and tenacity of purpose; to -regard nothing else, even in the smallest degree, but reason always; -and always to remain unaltered in the agonies of pain, in the losses -of children, or in long diseases. He afforded me a living example of -how the same man can, upon occasion, be most yielding and most -inflexible. He was patient in exposition; and, as might well be seen, -esteemed his fine skill and ability in teaching others the principles -of philosophy as the least of his endowments. It was from him that I -learned how to receive from friends what are thought favours without -seeming humbled by the giver or insensible to the gift.` diff --git a/apps/gophers/main_test.go b/apps/gophers/main_test.go new file mode 100644 index 00000000..2b189ced --- /dev/null +++ b/apps/gophers/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "image" + "testing" + "time" + + "gioui.org/ui" + "gioui.org/ui/input" + "gioui.org/ui/layout" +) + +type queue struct{} + +type config struct{} + +func BenchmarkUI(b *testing.B) { + invalidate := func() {} + fetch := func(_ string) {} + u := newUI(invalidate, fetch) + ops := new(ui.Ops) + q := new(queue) + c := new(config) + cs := layout.RigidConstraints(image.Point{800, 600}) + for i := 0; i < b.N; i++ { + ops.Reset() + u.Layout(c, q, ops, cs) + } +} + +func (queue) Next(k input.Key) (input.Event, bool) { + return nil, false +} + +func (config) Now() time.Time { + return time.Now() +} + +func (config) Px(v ui.Value) int { + return int(v.V + .5) +} diff --git a/apps/gophers/ui.go b/apps/gophers/ui.go new file mode 100644 index 00000000..d2384c5a --- /dev/null +++ b/apps/gophers/ui.go @@ -0,0 +1,583 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package main + +// A Gio program that displays Go contributors from GitHub. See https://gioui.org for more information. + +import ( + "fmt" + "image" + "image/color" + "runtime" + + "golang.org/x/image/draw" + + _ "image/jpeg" + _ "image/png" + + _ "net/http/pprof" + + "gioui.org/ui" + gdraw "gioui.org/ui/draw" + "gioui.org/ui/f32" + "gioui.org/ui/gesture" + "gioui.org/ui/input" + "gioui.org/ui/key" + "gioui.org/ui/layout" + "gioui.org/ui/measure" + "gioui.org/ui/pointer" + "gioui.org/ui/system" + "gioui.org/ui/text" + "gioui.org/ui/widget" + "golang.org/x/exp/shiny/iconvg" + + "github.com/google/go-github/v24/github" + "golang.org/x/image/font/gofont/gobold" + "golang.org/x/image/font/gofont/goitalic" + "golang.org/x/image/font/gofont/gomono" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/sfnt" + + "golang.org/x/exp/shiny/materialdesign/icons" +) + +type UI struct { + faces measure.Faces + fab *ActionButton + usersList *layout.List + users []*user + userClicks []gesture.Click + selectedUser *userPage + edit, edit2 *text.Editor + invalidate func() + fetchCommits func(u string) + + // Profiling. + profiling bool + profile system.ProfileEvent + lastMallocs uint64 +} + +type userPage struct { + config ui.Config + faces measure.Faces + invalidate func() + user *user + commitsList *layout.List + commits []*github.Commit +} + +type user struct { + name string + login string + company string + avatar image.Image +} + +type icon struct { + src []byte + size ui.Value + + // Cached values. + img image.Image + imgSize int +} + +type ActionButton struct { + face text.Face + Open bool + icons []*icon + sendIco *icon +} + +var fonts struct { + regular *sfnt.Font + bold *sfnt.Font + italic *sfnt.Font + mono *sfnt.Font +} + +var theme struct { + text ui.MacroOp + tertText ui.MacroOp + brand ui.MacroOp + white ui.MacroOp +} + +func colorMaterial(ops *ui.Ops, color color.RGBA) ui.MacroOp { + var mat ui.MacroOp + mat.Record(ops) + gdraw.ColorOp{Color: color}.Add(ops) + mat.Stop() + return mat +} + +func init() { + fonts.regular = mustLoadFont(goregular.TTF) + fonts.bold = mustLoadFont(gobold.TTF) + fonts.italic = mustLoadFont(goitalic.TTF) + fonts.mono = mustLoadFont(gomono.TTF) + var ops ui.Ops + theme.text = colorMaterial(&ops, rgb(0x333333)) + theme.tertText = colorMaterial(&ops, rgb(0xbbbbbb)) + theme.brand = colorMaterial(&ops, rgb(0x62798c)) + theme.white = colorMaterial(&ops, rgb(0xffffff)) +} + +func newUI(invalidate func(), fetchCommits func(string)) *UI { + u := &UI{ + invalidate: invalidate, + fetchCommits: fetchCommits, + } + u.usersList = &layout.List{ + Axis: layout.Vertical, + } + u.fab = &ActionButton{ + face: u.face(fonts.regular, 11), + sendIco: &icon{src: icons.ContentSend, size: ui.Dp(24)}, + icons: []*icon{}, + } + u.edit2 = &text.Editor{ + Face: u.face(fonts.italic, 14), + //Alignment: text.End, + SingleLine: true, + Hint: "Hint", + HintMaterial: theme.tertText, + Material: theme.text, + } + u.edit2.SetText("Single line editor. Edit me!") + u.edit = &text.Editor{ + Face: u.face(fonts.regular, 16), + Material: theme.text, + //Alignment: text.End, + //SingleLine: true, + } + u.edit.SetText(longTextSample) + return u +} + +func mustLoadFont(fontData []byte) *sfnt.Font { + fnt, err := sfnt.Parse(fontData) + if err != nil { + panic("failed to load font") + } + return fnt +} + +func rgb(c uint32) color.RGBA { + return argb((0xff << 24) | c) +} + +func argb(c uint32) color.RGBA { + return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} +} + +func (u *UI) face(f *sfnt.Font, size float32) text.Face { + return u.faces.For(f, ui.Sp(size)) +} + +func (u *UI) layoutTimings(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + if !u.profiling { + return layout.Dimens{} + } + for e, ok := q.Next(u); ok; e, ok = q.Next(u) { + if e, ok := e.(system.ProfileEvent); ok { + u.profile = e + } + } + system.ProfileOp{Key: u}.Add(ops) + var mstats runtime.MemStats + runtime.ReadMemStats(&mstats) + mallocs := mstats.Mallocs - u.lastMallocs + u.lastMallocs = mstats.Mallocs + al := layout.Align{Alignment: layout.NE} + cs = al.Begin(ops, cs) + in := layout.Inset{Top: ui.Dp(16)} + cs = in.Begin(c, ops, cs) + txt := fmt.Sprintf("m: %d %s", mallocs, u.profile.Timings) + dims := text.Label{Material: theme.text, Face: u.face(fonts.mono, 10), Text: txt}.Layout(ops, cs) + dims = in.End(dims) + return al.End(dims) +} + +func (u *UI) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + u.faces.Reset(c) + for i := range u.userClicks { + click := &u.userClicks[i] + for _, e := range click.Events(q) { + if e.Type == gesture.TypeClick { + u.selectedUser = u.newUserPage(u.users[i]) + } + } + } + var dims layout.Dimens + if u.selectedUser == nil { + dims = u.layoutUsers(c, q, ops, cs) + } else { + dims = u.selectedUser.Layout(c, q, ops, cs) + } + u.layoutTimings(c, q, ops, cs) + return dims +} + +func (u *UI) newUserPage(user *user) *userPage { + up := &userPage{ + faces: u.faces, + invalidate: u.invalidate, + user: user, + commitsList: &layout.List{Axis: layout.Vertical}, + } + u.fetchCommits(user.login) + return up +} + +func (up *userPage) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + l := up.commitsList + if l.Dragging() { + key.HideInputOp{}.Add(ops) + } + for l.Init(c, q, ops, cs, len(up.commits)); l.More(); l.Next() { + l.Elem(up.commit(c, ops, l.Constraints(), l.Index())) + } + return l.Layout() +} + +func (up *userPage) commit(c ui.Config, ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens { + u := up.user + msg := up.commits[index].GetMessage() + label := text.Label{Material: theme.text, Face: up.faces.For(fonts.regular, ui.Sp(12)), Text: msg} + in := layout.Inset{Top: ui.Dp(16), Right: ui.Dp(8), Left: ui.Dp(8)} + cs = in.Begin(c, ops, cs) + f := (&layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start}).Init(ops, cs) + cs = f.Rigid() + sz := c.Px(ui.Dp(48)) + cc := clipCircle{} + cs = cc.Begin(ops, cs) + cs = layout.RigidConstraints(cs.Constrain(image.Point{X: sz, Y: sz})) + dims := widget.Image{Src: u.avatar, Rect: u.avatar.Bounds()}.Layout(c, ops, cs) + dims = cc.End(dims) + c1 := f.End(dims) + cs = f.Flexible(1) + cs.Width.Min = cs.Width.Max + in2 := layout.Inset{Left: ui.Dp(8)} + cs = in2.Begin(c, ops, cs) + dims = label.Layout(ops, cs) + dims = in2.End(dims) + c2 := f.End(dims) + dims = f.Layout(c1, c2) + return in.End(dims) +} + +func (u *UI) layoutUsers(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + st := (&layout.Stack{Alignment: layout.Start}).Init(ops, cs) + cs = st.Rigid() + al := layout.Align{Alignment: layout.SE} + in := layout.UniformInset(ui.Dp(16)) + cs = in.Begin(c, ops, al.Begin(ops, cs)) + dims := u.fab.Layout(c, q, ops, cs) + dims = al.End(in.End(dims)) + c2 := st.End(dims) + + cs = st.Expand() + { + f := (&layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}).Init(ops, cs) + + cs = f.Rigid() + { + cs.Width.Min = cs.Width.Max + in := layout.UniformInset(ui.Dp(16)) + sz := c.Px(ui.Dp(200)) + cs = layout.RigidConstraints(cs.Constrain(image.Point{X: sz, Y: sz})) + dims = u.edit.Layout(c, q, ops, in.Begin(c, ops, cs)) + dims = in.End(dims) + } + c1 := f.End(dims) + + cs = f.Rigid() + { + cs.Width.Min = cs.Width.Max + in := layout.Inset{Bottom: ui.Dp(16), Left: ui.Dp(16), Right: ui.Dp(16)} + dims = u.edit2.Layout(c, q, ops, in.Begin(c, ops, cs)) + dims = in.End(dims) + } + c2 := f.End(dims) + + cs = f.Rigid() + { + cs.Width.Min = cs.Width.Max + s := layout.Stack{Alignment: layout.Center} + s.Init(ops, cs) + cs = s.Rigid() + in := layout.Inset{Top: ui.Dp(16), Right: ui.Dp(8), Bottom: ui.Dp(8), Left: ui.Dp(8)} + grey := colorMaterial(ops, rgb(0x888888)) + lbl := text.Label{Material: grey, Face: u.face(fonts.regular, 11), Text: "GOPHERS"} + dims = in.End(lbl.Layout(ops, in.Begin(c, ops, cs))) + c2 := s.End(dims) + c1 := s.End(fill{colorMaterial(ops, rgb(0xf2f2f2))}.Layout(ops, s.Expand())) + dims = s.Layout(c1, c2) + } + c3 := f.End(dims) + + cs = f.Flexible(1) + cs.Width.Min = cs.Width.Max + dims = u.layoutContributors(c, q, ops, cs) + c4 := f.End(dims) + dims = f.Layout(c1, c2, c3, c4) + } + c1 := st.End(dims) + return st.Layout(c1, c2) +} + +func (a *ActionButton) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.End} + f.Init(ops, cs) + cs = f.Rigid() + in := layout.Inset{Top: ui.Dp(4)} + cs = in.Begin(c, ops, cs) + dims := fab(ops, cs, a.sendIco.image(c), theme.brand, c.Px(ui.Dp(56))) + pointer.EllipseAreaOp{Rect: image.Rectangle{Max: dims.Size}}.Add(ops) + dims = in.End(dims) + return f.Layout(f.End(dims)) +} + +func (u *UI) layoutContributors(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimens { + l := u.usersList + if l.Dragging() { + key.HideInputOp{}.Add(ops) + } + for l.Init(c, q, ops, cs, len(u.users)); l.More(); l.Next() { + l.Elem(u.user(c, ops, l.Constraints(), l.Index())) + } + return l.Layout() +} + +func (u *UI) user(c ui.Config, ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens { + user := u.users[index] + elem := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start} + elem.Init(ops, cs) + cs = elem.Rigid() + var dims layout.Dimens + { + in := layout.UniformInset(ui.Dp(8)) + cs = in.Begin(c, ops, cs) + f := centerRowOpts() + f.Init(ops, cs) + cs = f.Rigid() + { + in := layout.Inset{Right: ui.Dp(8)} + cc := clipCircle{} + cs = cc.Begin(ops, in.Begin(c, ops, cs)) + sz := image.Point{X: c.Px(ui.Dp(48)), Y: c.Px(ui.Dp(48))} + cs = layout.RigidConstraints(cs.Constrain(sz)) + dims = widget.Image{Src: user.avatar, Rect: user.avatar.Bounds()}.Layout(c, ops, cs) + dims = in.End(cc.End(dims)) + } + c1 := f.End(dims) + cs = f.Rigid() + { + f := column() + f.Init(ops, cs) + cs = f.Rigid() + { + f := baseline() + f.Init(ops, cs) + cs = f.Rigid() + dims = text.Label{Material: theme.text, Face: u.face(fonts.regular, 13), Text: user.name}.Layout(ops, cs) + c1 := f.End(dims) + cs = f.Flexible(1) + cs.Width.Min = cs.Width.Max + al := layout.Align{Alignment: layout.E} + in := layout.Inset{Left: ui.Dp(2)} + cs = in.Begin(c, ops, al.Begin(ops, cs)) + dims = text.Label{Material: theme.text, Face: u.face(fonts.regular, 10), Text: "3 hours ago"}.Layout(ops, cs) + dims = al.End(in.End(dims)) + c2 := f.End(dims) + dims = f.Layout(c1, c2) + } + c1 := f.End(dims) + cs = f.Rigid() + in := layout.Inset{Top: ui.Dp(4)} + cs = in.Begin(c, ops, cs) + dims = text.Label{Material: theme.tertText, Face: u.face(fonts.regular, 12), Text: user.company}.Layout(ops, cs) + dims = in.End(dims) + c2 := f.End(dims) + dims = f.Layout(c1, c2) + } + c2 := f.End(dims) + dims = f.Layout(c1, c2) + dims = in.End(dims) + pointer.RectAreaOp{Rect: image.Rectangle{Max: dims.Size}}.Add(ops) + click := &u.userClicks[index] + click.Add(ops) + } + c1 := elem.End(dims) + return elem.Layout(c1) +} + +type fill struct { + material ui.MacroOp +} + +func (f fill) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens { + d := image.Point{X: cs.Width.Max, Y: cs.Height.Max} + dr := f32.Rectangle{ + Max: f32.Point{X: float32(d.X), Y: float32(d.Y)}, + } + f.material.Add(ops) + gdraw.DrawOp{Rect: dr}.Add(ops) + return layout.Dimens{Size: d, Baseline: d.Y} +} + +func column() layout.Flex { + return layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start} +} + +func centerRowOpts() layout.Flex { + return layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Center} +} + +func baseline() layout.Flex { + return layout.Flex{Axis: layout.Horizontal, CrossAxisAlignment: layout.Baseline} +} + +type clipCircle struct { + m ui.MacroOp + ops *ui.Ops +} + +func (c *clipCircle) Begin(ops *ui.Ops, cs layout.Constraints) layout.Constraints { + c.ops = ops + c.m.Record(ops) + return cs +} + +func (c *clipCircle) End(dims layout.Dimens) layout.Dimens { + c.m.Stop() + ops := c.ops + max := dims.Size.X + if dy := dims.Size.Y; dy > max { + max = dy + } + szf := float32(max) + rr := szf * .5 + var stack ui.StackOp + stack.Push(ops) + rrect(ops, szf, szf, rr, rr, rr, rr) + c.m.Add(ops) + stack.Pop() + return dims +} + +func fab(ops *ui.Ops, cs layout.Constraints, ico image.Image, mat ui.MacroOp, size int) layout.Dimens { + dp := image.Point{X: (size - ico.Bounds().Dx()) / 2, Y: (size - ico.Bounds().Dy()) / 2} + dims := image.Point{X: size, Y: size} + rr := float32(size) * .5 + rrect(ops, float32(size), float32(size), rr, rr, rr, rr) + mat.Add(ops) + gdraw.DrawOp{Rect: f32.Rectangle{Max: f32.Point{X: float32(size), Y: float32(size)}}}.Add(ops) + gdraw.ImageOp{Src: ico, Rect: ico.Bounds()}.Add(ops) + gdraw.DrawOp{ + Rect: toRectF(ico.Bounds().Add(dp)), + }.Add(ops) + return layout.Dimens{Size: dims} +} + +func toRectF(r image.Rectangle) f32.Rectangle { + return f32.Rectangle{ + Min: f32.Point{X: float32(r.Min.X), Y: float32(r.Min.Y)}, + Max: f32.Point{X: float32(r.Max.X), Y: float32(r.Max.Y)}, + } +} + +func (ic *icon) image(cfg ui.Config) image.Image { + sz := cfg.Px(ic.size) + if sz == ic.imgSize { + return ic.img + } + m, _ := iconvg.DecodeMetadata(ic.src) + dx, dy := m.ViewBox.AspectRatio() + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}}) + var ico iconvg.Rasterizer + ico.SetDstImage(img, img.Bounds(), draw.Src) + // Use white for icons. + m.Palette[0] = color.RGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} + iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{ + Palette: &m.Palette, + }) + ic.img = img + ic.imgSize = sz + return img +} + +// https://pomax.github.io/bezierinfo/#circles_cubic. +func rrect(ops *ui.Ops, width, height, se, sw, nw, ne float32) { + w, h := float32(width), float32(height) + const c = 0.55228475 // 4*(sqrt(2)-1)/3 + var b gdraw.PathBuilder + b.Init(ops) + b.Move(f32.Point{X: w, Y: h - se}) + b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE + b.Line(f32.Point{X: sw - w + se, Y: 0}) + b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW + b.Line(f32.Point{X: 0, Y: nw - h + sw}) + b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW + b.Line(f32.Point{X: w - ne - nw, Y: 0}) + b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE + b.End() +} + +const longTextSample = `1. I learned from my grandfather, Verus, to use good manners, and to +put restraint on anger. 2. In the famous memory of my father I had a +pattern of modesty and manliness. 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. 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. + +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. + +6. Of Diognetus I had the lesson not to busy myself about vain things; +not to credit the great professions of such as pretend to work +wonders, or of sorcerers about their charms, and their expelling of +Demons and the like; not to keep quails (for fighting or divination), +nor to run after such things; to suffer freedom of speech in others, +and to apply myself heartily to philosophy. Him also I must thank for +my hearing first Bacchius, then Tandasis and Marcianus; that I wrote +dialogues in my youth, and took a liking to the philosopher's pallet +and skins, and to the other things which, by the Grecian discipline, +belong to that profession. + +7. To Rusticus I owe my first apprehensions that my nature needed +reform and cure; and that I did not fall into the ambition of the +common Sophists, either by composing speculative writings or by +declaiming harangues of exhortation in public; further, that I never +strove to be admired by ostentation of great patience in an ascetic +life, or by display of activity and application; that I gave over the +study of rhetoric, poetry, and the graces of language; and that I did +not pace my house in my senatorial robes, or practise any similar +affectation. I observed also the simplicity of style in his letters, +particularly in that which he wrote to my mother from Sinuessa. I +learned from him to be easily appeased, and to be readily reconciled +with those who had displeased me or given cause of offence, so soon as +they inclined to make their peace; to read with care; not to rest +satisfied with a slight and superficial knowledge; nor quickly to +assent to great talkers. I have him to thank that I met with the +discourses of Epictetus, which he furnished me from his own library. + +8. From Apollonius I learned true liberty, and tenacity of purpose; to +regard nothing else, even in the smallest degree, but reason always; +and always to remain unaltered in the agonies of pain, in the losses +of children, or in long diseases. He afforded me a living example of +how the same man can, upon occasion, be most yielding and most +inflexible. He was patient in exposition; and, as might well be seen, +esteemed his fine skill and ability in teaching others the principles +of philosophy as the least of his endowments. It was from him that I +learned how to receive from friends what are thought favours without +seeming humbled by the giver or insensible to the gift.`