commit 6c05603f522ff84f681f92b7c6b0b4c724542f2d
parent 4a8516305247741be83fc202b449fe54b68d543c
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date: Tue, 3 Nov 2020 13:19:16 +0800
feat: gui structure
Diffstat:
| A | .gitignore | | | 2 | ++ |
| A | cmd/kanban/main.go | | | 506 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | go.sum | | | 43 | +++++++++++++++++++++++++++++++++++++++++++ |
| A | icons/icons.go | | | 76 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | kanban.go | | | 97 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
5 files changed, 724 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+.vscode
+\ No newline at end of file
diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go
@@ -0,0 +1,506 @@
+package main
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "log"
+ "os"
+
+ "git.sr.ht/~jackmordaunt/kanban"
+
+ "gioui.org/f32"
+ "gioui.org/font/gofont"
+ "gioui.org/op/clip"
+ "gioui.org/op/paint"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/icons"
+ "git.sr.ht/~whereswaldon/materials"
+
+ "gioui.org/app"
+ "gioui.org/io/system"
+ "gioui.org/layout"
+ "gioui.org/op"
+)
+
+func main() {
+ w := app.NewWindow(app.Title("Kanban"))
+ th := material.NewTheme(gofont.Collection())
+ ui := &UI{
+ Window: w,
+ Th: th,
+ Engine: &kanban.Engine{},
+ // TODO: render dynamically from storage.
+ Panels: []Panel{
+ {
+ Label: "Todo",
+ Color: color.RGBA{R: 200, G: 200, B: 200, A: 255},
+ Thickness: unit.Dp(50),
+ },
+ {
+ Label: "In Progress",
+ Color: color.RGBA{G: 100, B: 200, A: 255},
+ Thickness: unit.Dp(50),
+ },
+ {
+ Label: "Testing",
+ Color: color.RGBA{R: 200, G: 100, A: 255},
+ Thickness: unit.Dp(50),
+ },
+ {
+ Label: "Done",
+ Color: color.RGBA{R: 50, G: 200, B: 100, A: 255},
+ Thickness: unit.Dp(50),
+ },
+ },
+ }
+ go func() {
+ if err := ui.Loop(); err != nil {
+ log.Fatalf("error: %v", err)
+ }
+ os.Exit(0)
+ }()
+ app.Main()
+}
+
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
+// UI is the high level object that contains all global state.
+// Anything that needs to integrate with the external system is allocated on
+// this object.
+type UI struct {
+ *app.Window
+ Engine *kanban.Engine
+ Th *material.Theme
+ Panels []Panel
+ Tickets []Ticket
+ Modal layout.Widget
+ TicketForm TicketForm
+}
+
+func (ui *UI) Loop() error {
+ var ops op.Ops
+ for {
+ switch event := (<-ui.Events()).(type) {
+ case system.DestroyEvent:
+ return event.Err
+ case system.ClipboardEvent:
+ fmt.Printf("clipboard: %v\n", event.Text)
+ case *system.CommandEvent:
+ // TODO: integrate with command events.
+ case system.FrameEvent:
+ gtx := layout.NewContext(&ops, event)
+ ui.Update(gtx)
+ ui.Layout(gtx)
+ event.Frame(gtx.Ops)
+ }
+ }
+}
+
+func (ui *UI) Update(gtx C) {
+ for ii := range ui.Panels {
+ panel := &ui.Panels[ii]
+ if panel.CreateTicket.Clicked() {
+ ui.Modal = func(gtx C) D {
+ return ui.TicketForm.Layout(gtx, ui.Th, panel.Label)
+ }
+ }
+ }
+ if ui.TicketForm.Submit.Clicked() {
+ ticket, err := ui.TicketForm.Validate()
+ if err != nil {
+ fmt.Printf("error: %s", err)
+ return
+ }
+ if err := ui.Engine.Assign(ui.TicketForm.Stage, ticket); err != nil {
+ fmt.Printf("error: %s", err)
+ return
+ }
+ ui.TicketForm = TicketForm{}
+ ui.Modal = nil
+ }
+ if ui.TicketForm.Cancel.Clicked() {
+ ui.TicketForm = TicketForm{}
+ ui.Modal = nil
+ }
+}
+
+func (ui *UI) Layout(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ var panels = make([]layout.FlexChild, len(ui.Panels))
+ for ii := range ui.Panels {
+ panel := &ui.Panels[ii]
+ panels[ii] = layout.Flexed(1, func(gtx C) D {
+ panel := panel
+ stage, _ := ui.Engine.Stage(panel.Label)
+ var cards = make([]layout.Widget, len(stage.Tickets))
+ for ii, ticket := range stage.Tickets {
+ ticket := ticket
+ cards[ii] = func(gtx C) D {
+ // TODO: ticket state needs to live somewhere.
+ return (&TicketStyle{Ticket: ticket}).Layout(gtx, ui.Th)
+ }
+ }
+ return panel.Layout(gtx, ui.Th, cards...)
+ })
+ }
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Spacing: layout.SpaceEvenly,
+ }.Layout(
+ gtx,
+ panels...,
+ )
+ }),
+ layout.Expanded(func(gtx C) D {
+ if ui.Modal == nil {
+ return D{}
+ }
+ return Modal(gtx, ui.Th, "Add Ticket", func(gtx C) D {
+ return ui.Modal(gtx)
+ })
+ }),
+ )
+}
+
+// Modal renders content centered with decorations.
+func Modal(gtx C, th *material.Theme, title string, content layout.Widget) D {
+ return Centered(gtx, func(gtx C) D {
+ gtx.Constraints.Max.X = int(float32(gtx.Constraints.Max.X) * 0.8)
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return Rect{
+ Color: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ Size: layout.FPt(gtx.Constraints.Min),
+ Radii: 4,
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ inset := layout.UniformInset(unit.Dp(10))
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return Rect{
+ Color: color.RGBA{A: 100},
+ Size: f32.Point{
+ X: float32(gtx.Constraints.Max.X),
+ Y: float32(gtx.Constraints.Min.Y),
+ },
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ return material.H6(th, title).Layout(gtx)
+ })
+ }),
+ )
+ }),
+ layout.Rigid(func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ return content(gtx)
+ })
+ }),
+ )
+ }),
+ )
+ })
+}
+
+func Centered(gtx C, content layout.Widget) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ return Rect{
+ Size: layout.FPt(gtx.Constraints.Max),
+ Color: color.RGBA{A: 200},
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return content(gtx)
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ )
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ )
+ }),
+ )
+}
+
+// TicketForm renders the form for ticket information.
+type TicketForm struct {
+ Stage string
+ Title materials.TextField
+ Category materials.TextField
+ Summary materials.TextField
+ Details materials.TextField
+ Submit widget.Clickable
+ Cancel widget.Clickable
+}
+
+func (form TicketForm) Validate() (kanban.Ticket, error) {
+ ticket := kanban.Ticket{
+ Title: form.Title.Text(),
+ Details: form.Details.Text(),
+ Summary: form.Summary.Text(),
+ Category: form.Category.Text(),
+ }
+ return ticket, nil
+}
+
+func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D {
+ form.Stage = stage
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return form.Title.Layout(gtx, th, "Title")
+ }),
+ layout.Rigid(func(gtx C) D {
+ return form.Category.Layout(gtx, th, "Category")
+ }),
+ layout.Rigid(func(gtx C) D {
+ return form.Summary.Layout(gtx, th, "Summary")
+ }),
+ layout.Rigid(func(gtx C) D {
+ return form.Details.Layout(gtx, th, "Details")
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Inset{
+ Top: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, &form.Cancel, "Cancel").Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, &form.Submit, "Submit").Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+}
+
+// Rect creates a rectangle of the provided background color with
+// Dimensions specified by size and a corner radius (on all corners)
+// specified by radii.
+type Rect struct {
+ Color color.RGBA
+ Size f32.Point
+ Radii float32
+}
+
+// Layout renders the Rect into the provided context
+func (r Rect) Layout(gtx C) D {
+ paint.FillShape(gtx.Ops, clip.UniformRRect(f32.Rectangle{Max: r.Size}, r.Radii).Op(gtx.Ops), r.Color)
+ return layout.Dimensions{Size: image.Pt(int(r.Size.X), int(r.Size.Y))}
+}
+
+// Panel can hold cards.
+// Has a title and action bar.
+type Panel struct {
+ Label string
+ Color color.RGBA
+ Thickness unit.Value
+ CreateTicket widget.Clickable
+ layout.List
+}
+
+func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D {
+ return widget.Border{
+ Color: color.RGBA{A: 200},
+ Width: unit.Dp(0.5),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return Rect{
+ Size: f32.Point{
+ X: layout.FPt(gtx.Constraints.Max).X,
+ Y: float32(gtx.Px(p.Thickness)),
+ },
+ Color: p.Color,
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.Inset{
+ Left: unit.Dp(10),
+ Right: unit.Dp(15),
+ Top: unit.Dp(12),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.H6(th, p.Label).Layout(gtx)
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Max = image.Point{
+ X: gtx.Px(unit.Dp(10)),
+ Y: gtx.Px(unit.Dp(10)),
+ }
+ return material.IconButton(th, &p.CreateTicket, icons.ContentAdd).Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return Rect{
+ Color: color.RGBA{R: 240, G: 240, B: 240, A: 255},
+ Size: layout.FPt(gtx.Constraints.Max),
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ p.List.Axis = layout.Vertical
+ return p.List.Layout(gtx, len(tickets), func(gtx C, ii int) D {
+ return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
+ gtx.Constraints.Max.Y = gtx.Px(unit.Dp(100))
+ return widget.Border{
+ Width: unit.Dp(0.5),
+ Color: color.RGBA{A: 200},
+ }.Layout(gtx, func(gtx C) D {
+ return tickets[ii](gtx)
+ })
+ })
+ })
+ }),
+ )
+ }),
+ )
+ })
+}
+
+// TicketStyle renders a ticket control.
+type TicketStyle struct {
+ kanban.Ticket
+}
+
+type Ticket struct {
+ MoveButton widget.Clickable
+}
+
+func (t *TicketStyle) Layout(gtx C, th *material.Theme) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ // Side bar with controls
+ return Rect{
+ Color: color.RGBA{G: 100, B: 200, A: 255},
+ Size: f32.Point{
+ X: float32(gtx.Px(unit.Dp(15))),
+ Y: float32(gtx.Constraints.Max.Y),
+ },
+ }.Layout(gtx)
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ // content
+ return layout.Inset{
+ Top: unit.Dp(5),
+ Left: unit.Dp(10),
+ Right: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.Label(th, unit.Dp(20), t.Title).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Inset{
+ Top: unit.Dp(2),
+ }.Layout(gtx, func(gtx C) D {
+ th := *th
+ th.Color.Text = materials.AlphaMultiply(th.Color.Text, 200)
+ return material.Label(&th, unit.Dp(14), t.Category).Layout(gtx)
+ })
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D {
+ return material.Body1(th, t.Summary).Layout(gtx)
+ })
+ }),
+ )
+ })
+ }),
+ layout.Rigid(func(gtx C) D {
+ // bottom controls
+ return Rect{
+ Color: color.RGBA{A: 100},
+ Size: f32.Point{
+ X: float32(gtx.Constraints.Max.X),
+ Y: float32(gtx.Px(unit.Dp(15))),
+ },
+ }.Layout(gtx)
+ }),
+ )
+ }),
+ )
+}
diff --git a/go.sum b/go.sum
@@ -0,0 +1,43 @@
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+gioui.org v0.0.0-20201022165755-672555d3d076/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw=
+gioui.org v0.0.0-20201101161300-a55302a49867 h1:Tq3Z4wJ1QLJ552s9VXPk339cSvF9iNZYDJ6Fv4IyGHA=
+gioui.org v0.0.0-20201101161300-a55302a49867/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw=
+git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730 h1:pAOILGMbZpo3BzS0NXX41bCd4yhIzlG00fkXE1ItN/E=
+git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730/go.mod h1:kLyZfWzOWp15bbVIiZXdNHBg+lluQq66KnB67d70O0U=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs=
+golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
+golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 h1:5XVKs2rlCg8EFyRcvO8/XFwYxh1oKJO1Q3X5vttIf9c=
+golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
+golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20200801110659-972c09e46d76 h1:U7GPaoQyQmX+CBRWXKrvRzWTbd+slqeSh8uARsIyhAw=
+golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/icons/icons.go b/icons/icons.go
@@ -0,0 +1,76 @@
+package icons
+
+import (
+ "gioui.org/widget"
+ "golang.org/x/exp/shiny/materialdesign/icons"
+)
+
+var BackIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.NavigationArrowBack)
+ return icon
+}()
+
+var ClearIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentClear)
+ return icon
+}()
+
+var ReplyIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentReply)
+ return icon
+}()
+
+var CancelReplyIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.NavigationCancel)
+ return icon
+}()
+
+var SendReplyIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentSend)
+ return icon
+}()
+
+var ContentAdd *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentAdd)
+ return icon
+}()
+
+var CopyIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentContentCopy)
+ return icon
+}()
+
+var PasteIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentContentPaste)
+ return icon
+}()
+
+var FilterIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ContentFilterList)
+ return icon
+}()
+
+var MenuIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.NavigationMenu)
+ return icon
+}()
+
+var ServerIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ActionDNS)
+ return icon
+}()
+
+var SettingsIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ActionSettings)
+ return icon
+}()
+
+var ChatIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.CommunicationChat)
+ return icon
+}()
+
+var IdentityIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.ActionPermIdentity)
+ return icon
+}()
diff --git a/kanban.go b/kanban.go
@@ -0,0 +1,97 @@
+package kanban
+
+// Kanban engine that drives the model.
+type Engine struct {
+ stages []Stage
+}
+
+// Stages returns a list of stages.
+func (eng Engine) Stages() ([]Stage, error) {
+ return eng.stages, nil
+}
+
+// Stage returns a stage by the given name.
+// Creates an empty stage if it doesn't exist.
+func (eng *Engine) Stage(name string) (Stage, error) {
+ for _, stage := range eng.stages {
+ if stage.Name == name {
+ return stage, nil
+ }
+ }
+ eng.stages = append(eng.stages, Stage{Name: name})
+ return eng.stages[len(eng.stages)-1], nil
+}
+
+func (eng *Engine) Move(stage string, ticket ID) error {
+ return nil
+}
+
+func (eng *Engine) Assign(stage string, ticket Ticket) error {
+ ticket.ID = eng.nextID()
+ for ii := range eng.stages {
+ s := &eng.stages[ii]
+ if s.Name == stage {
+ s.Tickets = append(s.Tickets, ticket)
+ return nil
+ }
+ }
+ eng.stages = append(eng.stages, Stage{
+ Name: stage,
+ Tickets: []Ticket{ticket},
+ })
+ return nil
+}
+
+func (eng *Engine) Delete(ticket ID) error {
+ for _, s := range eng.stages {
+ for ii := range s.Tickets {
+ if s.Tickets[ii].ID == ticket {
+ s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
+ return nil
+ }
+ }
+ }
+ return nil
+}
+
+func (eng *Engine) Update(ticket Ticket) error {
+ for _, s := range eng.stages {
+ for ii := range s.Tickets {
+ if s.Tickets[ii].ID == ticket.ID {
+ s.Tickets[ii] = ticket
+ return nil
+ }
+ }
+ }
+ return nil
+}
+
+func (eng *Engine) nextID() ID {
+ var max int
+ for _, stage := range eng.stages {
+ for _, t := range stage.Tickets {
+ if int(t.ID) > max {
+ max = int(t.ID)
+ }
+ }
+ }
+ return ID(max + 1)
+}
+
+type ID int
+
+// Stage in the kanban pipeline, can hold a number of tickets.
+type Stage struct {
+ Name string
+ Tickets []Ticket
+}
+
+// Ticket in a stage.
+type Ticket struct {
+ ID ID
+ Title string
+ Category string
+ Summary string
+ Details string
+ References []ID
+}