commit 02418babc3d359494fc86a38cab382d8c238517d
parent de1bc0162742c5af2b7db1635a4c9ac0e9c3e9aa
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date: Thu, 11 Feb 2021 22:09:47 +0800
fix: layout using new model
Signed-off-by: Jack Mordaunt <jackmordaunt@gmail.com>
Diffstat:
6 files changed, 240 insertions(+), 196 deletions(-)
diff --git a/cmd/kanban/control/rail.go b/cmd/kanban/control/rail.go
@@ -45,7 +45,8 @@ func (r *Rail) next(key string) *widget.Clickable {
// Selected reports which rail child was selected, if any.
// Reports the first click encountered.
func (r *Rail) Selected() (string, bool) {
- for k, v := r.Map.Next(); r.Map.More(); k, v = r.Map.Next() {
+ for r.Map.More() {
+ k, v := r.Map.Next()
if (*widget.Clickable)(v).Clicked() {
return k, true
}
diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go
@@ -1,31 +1,26 @@
-// @Note data lifecycle idea: frame by frame sync
-// 1. load data at start of frame; pass it in to the ui context
-// 2. save data at end of frame, every frame
-// 3. mutate data with plain methods knowing that mutations will be saved at a known point
-//
-// let data be heirarchical eg projects -> stages -> tickets
-//
-// How are we accessing the data mostly?
-// Active Project gets loaded every frame.
-// All stages and tickets for the active project need to be read every frame.
-// Mutations occur async.
package main
import (
"fmt"
+ "image"
+ "image/color"
"log"
"os"
"path/filepath"
+ "unsafe"
"git.sr.ht/~jackmordaunt/kanban"
"github.com/asdine/storm/v3"
+ "github.com/spf13/pflag"
+ "gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.sr.ht/~jackmordaunt/kanban/cmd/kanban/control"
"git.sr.ht/~jackmordaunt/kanban/cmd/kanban/state"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
"git.sr.ht/~jackmordaunt/kanban/icons"
"gioui.org/app"
@@ -35,31 +30,44 @@ import (
"gioui.org/op"
)
+var (
+ MemStorage bool
+)
+
+func init() {
+ pflag.BoolVar(&MemStorage, "mem-storage", true, "store entities in memory")
+ pflag.Parse()
+}
+
func main() {
- db, err := func() (*storm.DB, error) {
- path := filepath.Join(os.TempDir(), "kanban.db")
- fmt.Printf("%s\n", path)
- db, err := storm.Open(path)
- if err != nil {
- return nil, fmt.Errorf("opening data file: %w", err)
- }
- if err := db.Init(&kanban.Ticket{}); err != nil {
- return nil, err
- }
- if err := db.Init(&kanban.Project{}); err != nil {
- return nil, err
+ storage := func() kanban.Storer {
+ if MemStorage {
+ return kanban.MapStorer{}.New()
+ } else {
+ db, err := func() (*storm.DB, error) {
+ path := filepath.Join(os.TempDir(), "kanban.db")
+ fmt.Printf("%s\n", path)
+ db, err := storm.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("opening data file: %w", err)
+ }
+ if err := db.Init(&kanban.ProjectSchema{}); err != nil {
+ return nil, err
+ }
+ return db, nil
+ }()
+ if err != nil {
+ log.Fatalf("error: initializing data: %v", err)
+ }
+ defer db.Close()
+ return &kanban.StormStorer{DB: db}
}
- return db, nil
}()
- if err != nil {
- log.Fatalf("error: initializing data: %v", err)
- }
- defer db.Close()
go func() {
ui := UI{
Window: app.NewWindow(app.Title("Kanban")),
Th: material.NewTheme(gofont.Collection()),
- Storage: &kanban.StormStorer{DB: db},
+ Storage: storage,
}
if err := ui.Loop(); err != nil {
log.Fatalf("error: %v", err)
@@ -83,8 +91,8 @@ type UI struct {
Th *material.Theme
Project *kanban.Project
- // @Todo panels shouldn't be stateful.
- Panels []control.Panel
+ Panels []*control.Panel
+
Rail control.Rail
TicketStates state.Map
Modal layout.Widget
@@ -140,6 +148,8 @@ func (ui *UI) Update(gtx C) {
ui.Clear()
case key.NameEnter, key.NameReturn:
// @Cleanup
+ // on "enter" we want to launch edit form for the focused ticket.
+ //
// var (
// t kanban.Ticket
// )
@@ -159,14 +169,54 @@ func (ui *UI) Update(gtx C) {
}
}
}
+ if ui.ProjectForm.Submit.Clicked() {
+ if err := ui.Storage.Create(&kanban.Project{
+ Name: ui.ProjectForm.Name.Text(),
+ Stages: []kanban.Stage{
+ {Name: "Todo"},
+ {Name: "In Progress"},
+ {Name: "Testing"},
+ {Name: "Done"},
+ },
+ }); err != nil {
+ log.Printf("creating new project: %v", err)
+ }
+ ui.Clear()
+ }
+ if p, ok := ui.Rail.Selected(); ok {
+ if ui.Project == nil || ui.Project.Name != p {
+ project, ok, err := ui.Storage.Load(p)
+ if err != nil {
+ log.Printf("loading project %q: %v", p, err)
+ }
+ if ok {
+ ui.Project = project
+ ui.Panels = func() (panels []*control.Panel) {
+ for _, s := range project.Stages {
+ panels = append(panels, &control.Panel{
+ Label: s.Name,
+ Color: color.NRGBA{R: 100, B: 100, G: 200, A: 255},
+ Thickness: unit.Dp(50),
+ })
+ }
+ return panels
+ }()
+ }
+
+ }
+ }
+ if ui.ProjectForm.Cancel.Clicked() {
+ ui.Clear()
+ }
for ii := range ui.Panels {
- panel := &ui.Panels[ii]
+ panel := ui.Panels[ii]
if panel.CreateTicket.Clicked() {
ui.AddTicket(panel.Label)
}
}
- for _, s := ui.TicketStates.Next(); ui.TicketStates.More(); _, s = ui.TicketStates.Next() {
- t := (*Ticket)(s)
+ for ui.TicketStates.More() {
+ _, v := ui.TicketStates.Next()
+ t := (*Ticket)(v)
if ui.Modal != nil {
continue
}
@@ -189,15 +239,7 @@ func (ui *UI) Update(gtx C) {
if ui.TicketForm.SubmitBtn.Clicked() {
// @todo handle create/update ambiguity.
_ = ui.TicketForm.Submit()
- // if err != nil {
- // fmt.Printf("error: %s\n", err)
- // } else {
- // if assign := ui.TicketForm.Stage != ""; assign {
- // ui.Project.AssignTicket(ui.TicketForm.Stage, ticket)
- // } else {
- // ui.Project.Update(ticket)
- // }
- // }
+ ui.Project.AssignTicket(ui.TicketForm.Stage, *ui.TicketForm.Ticket)
ui.Clear()
}
if ui.TicketForm.CancelBtn.Clicked() {
@@ -219,144 +261,117 @@ func (ui *UI) Update(gtx C) {
if ui.CreateProjectButton.Clicked() {
ui.CreateProject()
}
- if ui.ProjectForm.Cancel.Clicked() {
- ui.Clear()
- }
- if ui.ProjectForm.Submit.Clicked() {
- if err := ui.Storage.Create(&kanban.Project{
- Name: ui.ProjectForm.Name.Text(),
- }); err != nil {
- log.Printf("creating new project: %v", err)
- }
- ui.Clear()
- }
- if p, ok := ui.Rail.Selected(); ok {
- project, ok, err := ui.Storage.Load(p)
- if err != nil {
- log.Printf("loading project %q: %v", p, err)
- }
- if ok {
- ui.Project = project
- }
-
- }
}
func (ui *UI) Layout(gtx C) D {
key.InputOp{Tag: ui}.Add(gtx.Ops)
- return layout.Flex{Axis: layout.Horizontal}.Layout(
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
gtx.Constraints.Max.X = gtx.Px(unit.Dp(80))
gtx.Constraints.Min.X = 0
- var (
- rc []control.RailChild
- )
- // @cleanup
- // if err := ui.Project.AllByIndex("ID", &projects); err != nil {
- // log.Printf("error: loading projects: %v", err)
- // }
- // for _, p := range projects {
- // p := p
- // rc = append(rc, Destination(p.ID.String(), func(gtx C) D {
- // return layout.Stack{
- // Alignment: layout.Center,
- // }.Layout(
- // gtx,
- // layout.Stacked(func(gtx C) D {
- // return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
- // return material.Label(ui.Th, unit.Dp(16), p.Name).Layout(gtx)
- // })
- // }),
- // layout.Expanded(func(gtx C) D {
- // cs := gtx.Constraints
- // if p.ID == ui.ActiveProject {
- // return util.Rect{
- // Color: color.NRGBA{A: 100},
- // Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)),
- // }.Layout(gtx)
- // }
- // return D{Size: image.Point{X: cs.Max.X, Y: cs.Min.Y}}
- // }),
- // )
- // }))
- // }
- return ui.Rail.Layout(
- gtx,
- func(gtx C) D {
- return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
- btn := material.IconButton(ui.Th, &ui.CreateProjectButton, icons.ContentAdd)
- btn.Size = unit.Dp(20)
- btn.Inset = layout.UniformInset(unit.Dp(8))
- return btn.Layout(gtx)
- })
- },
- rc...,
- )
+ return ui.layoutRail(gtx)
}),
layout.Flexed(1, func(gtx C) D {
- return layout.Stack{}.Layout(
+ return ui.layoutContent(gtx)
+ }),
+ )
+}
+
+func (ui *UI) layoutRail(gtx C) D {
+ var (
+ rc []control.RailChild
+ )
+ projects, err := ui.Storage.List()
+ if err != nil {
+ log.Printf("error: loading projects: %v", err)
+ }
+ for _, p := range projects {
+ p := p
+ rc = append(rc, control.Destination(p.Name, func(gtx C) D {
+ return layout.Stack{
+ Alignment: layout.Center,
+ }.Layout(
gtx,
layout.Stacked(func(gtx C) D {
- if ui.Project == nil {
- return D{}
- }
- ui.TicketStates.Begin()
- var panels []layout.FlexChild
- // @cleanup
- // var (
- // project kanban.Project
- // stage kanban.Stage
- // ticket kanban.Ticket
- // t *Ticket
- // )
- // // @fixme show project creation hint when there are no projects.
- // if err := ui.Project.One("ID", ui.ActiveProject, &project); err != nil {
- // log.Printf("error: project %v", err)
- // }
- // for _, id := range project.Stages {
- // if err := ui.Project.One("ID", id, &stage); err != nil {
- // log.Printf("error: stage %v", err)
- // }
- // // render the stage panel.
- // for _, id := range stage.Tickets {
- // if err := ui.Project.One("ID", id, &ticket); err != nil {
- // log.Printf("error: ticket %v", err)
- // }
- // t = (*Ticket)(ui.TicketStates.New(strconv.Itoa(int(id)), unsafe.Pointer(&Ticket{})))
- // t.Ticket = ticket
- // t.Stage = stage.Name
- // panels = append(panels, layout.Flexed(1, func(gtx C) D {
- // if ui.FocusedTicket.ID == id {
- // return widget.Border{
- // Color: color.NRGBA{B: 200, A: 200},
- // Width: unit.Dp(2),
- // }.Layout(gtx, func(gtx C) D {
- // return t.Layout(gtx, ui.Th)
- // })
- // }
- // return t.Layout(gtx, ui.Th)
- // }))
- // }
- // }
- return layout.Flex{
- Axis: layout.Horizontal,
- Spacing: layout.SpaceEvenly,
- }.Layout(
- gtx,
- panels...,
- )
+ return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
+ return material.Label(ui.Th, unit.Dp(16), p.Name).Layout(gtx)
+ })
}),
layout.Expanded(func(gtx C) D {
- if ui.Modal == nil {
- return D{}
+ cs := gtx.Constraints
+ if ui.Project != nil && ui.Project.Name == p.Name {
+ return util.Rect{
+ Color: color.NRGBA{A: 100},
+ Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)),
+ }.Layout(gtx)
}
- return Modal(gtx, func(gtx C) D {
- return ui.Modal(gtx)
- })
+ return D{Size: image.Point{X: cs.Max.X, Y: cs.Min.Y}}
}),
)
+ }))
+ }
+ return ui.Rail.Layout(
+ gtx,
+ func(gtx C) D {
+ return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
+ btn := material.IconButton(ui.Th, &ui.CreateProjectButton, icons.ContentAdd)
+ btn.Size = unit.Dp(20)
+ btn.Inset = layout.UniformInset(unit.Dp(8))
+ return btn.Layout(gtx)
+ })
+ },
+ rc...,
+ )
+}
+
+func (ui *UI) layoutContent(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ if ui.Project == nil {
+ return D{}
+ }
+ ui.TicketStates.Begin()
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Spacing: layout.SpaceEvenly,
+ }.Layout(
+ gtx,
+ func() (panels []layout.FlexChild) {
+ // @decouple this iteration relies on the coincidence that panels are ordered the same.
+ for ii, stage := range ui.Project.Stages {
+ stage := stage
+ panel := ui.Panels[ii]
+ panels = append(panels, layout.Flexed(1, func(gtx C) D {
+ return panel.Layout(gtx, ui.Th, func() (tickets []layout.ListElement) {
+ for _, ticket := range stage.Tickets {
+ ticket := ticket
+ t := (*Ticket)(ui.TicketStates.New(ticket.Title, unsafe.Pointer(&Ticket{})))
+ t.Ticket = ticket
+ t.Stage = stage.Name
+ tickets = append(tickets, func(gtx C, index int) D {
+ return t.Layout(gtx, ui.Th, false)
+ })
+ }
+ return tickets
+ }()...)
+ }))
+ }
+ return panels
+ }()...,
+ )
+ }),
+ layout.Expanded(func(gtx C) D {
+ if ui.Modal == nil {
+ return D{}
+ }
+ return Modal(gtx, func(gtx C) D {
+ return ui.Modal(gtx)
+ })
}),
)
}
@@ -462,6 +477,7 @@ func (ui *UI) EditTicket(t *kanban.Ticket) {
// AddTicket opens the ticket form for creating ticket data.
func (ui *UI) AddTicket(stage string) {
+ ui.TicketForm.Set(&kanban.Ticket{})
ui.Modal = func(gtx C) D {
return control.Card{
Title: "Add Ticket",
diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go
@@ -38,7 +38,6 @@ func (f *TicketForm) Set(t *kanban.Ticket) {
}
// Submit validates inputs and writes to the ticket.
-// @Todo validation.
func (f TicketForm) Submit() error {
*f.Ticket = kanban.Ticket{
Title: f.Title.Text(),
@@ -211,13 +210,21 @@ type Ticket struct {
//
// To get around this I used a macro and manually stacked things sized exactly
// to the content, rather than the maximum Y.
-func (t *Ticket) Layout(gtx C, th *material.Theme) D {
+func (t *Ticket) Layout(gtx C, th *material.Theme, focused bool) D {
var (
barThickness = unit.Dp(25)
sideBarColor = color.NRGBA{R: 50, G: 50, B: 50, A: 255}
bottomBarColor = color.NRGBA{R: 220, G: 220, B: 220, A: 255}
minContentSize = gtx.Px(unit.Dp(150))
)
+ if focused {
+ return widget.Border{
+ Color: color.NRGBA{B: 200, A: 200},
+ Width: unit.Dp(2),
+ }.Layout(gtx, func(gtx C) D {
+ return t.Layout(gtx, th, false)
+ })
+ }
return widget.Border{
Width: unit.Dp(0.5),
Color: color.NRGBA{A: 200},
diff --git a/go.mod b/go.mod
@@ -6,6 +6,7 @@ require (
gioui.org v0.0.0-20210127212131-b698c8ed8229
gioui.org/x v0.0.0-20210120222453-b55819bc712b
github.com/asdine/storm/v3 v3.2.1
+ github.com/spf13/pflag v1.0.5
go.etcd.io/bbolt v1.3.5 // indirect
golang.org/x/exp v0.0.0-20210126221216-84987778548c
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect
diff --git a/go.sum b/go.sum
@@ -28,6 +28,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
diff --git a/kanban.go b/kanban.go
@@ -87,8 +87,9 @@ func (p *Project) AssignTicket(stage string, ticket Ticket) {
func (p *Project) ProgressTicket(ticket Ticket) {
for ii, s := range p.Stages {
if s.Contains(ticket) {
- // @todo bounds check
- p.Stages[ii+1].Assign(s.Take(ticket))
+ if ii < len(p.Stages)-1 {
+ p.Stages[ii+1].Assign(p.Stages[ii].Take(ticket))
+ }
break
}
}
@@ -98,8 +99,9 @@ func (p *Project) ProgressTicket(ticket Ticket) {
func (p *Project) RegressTicket(ticket Ticket) {
for ii, s := range p.Stages {
if s.Contains(ticket) {
- // @todo bounds check
- p.Stages[ii-1].Assign(s.Take(ticket))
+ if ii > 0 {
+ p.Stages[ii-1].Assign(p.Stages[ii].Take(ticket))
+ }
break
}
}
@@ -201,13 +203,8 @@ func (stages *Stages) Index(name string) (int, bool) {
// Take the specified ticket, if it exists.
// Removes it from the stage.
func (s *Stage) Take(ticket Ticket) Ticket {
- for ii, t := range s.Tickets {
- if t == ticket {
- s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
- return t
- }
- }
- return Ticket{}
+ s.UnAssign(ticket)
+ return ticket
}
// Contains returns true if the specified ticket exists in the stage.
@@ -264,12 +261,19 @@ func (dir Direction) Invert() Direction {
// MapStorer implements in-memory storage for Projects.
type MapStorer struct {
- Data map[string]Project
- Err error
+ Data map[string]Project
+ Order []string
+ Err error
}
var _ Storer = (*MapStorer)(nil)
+func (s MapStorer) New() *MapStorer {
+ return &MapStorer{
+ Data: make(map[string]Project),
+ }
+}
+
func (s *MapStorer) Create(p *Project) error {
if len(strings.TrimSpace(p.Name)) == 0 {
return fmt.Errorf("project name required")
@@ -278,6 +282,7 @@ func (s *MapStorer) Create(p *Project) error {
return fmt.Errorf("project %q exists", p.Name)
}
s.Data[p.Name] = *p
+ s.Order = append(s.Order, p.Name)
return nil
}
@@ -298,53 +303,65 @@ func (s *MapStorer) Load(name string) (*Project, bool, error) {
}
func (s *MapStorer) List() (list []*Project, err error) {
- for _, p := range s.Data {
- list = append(list, &p)
+ for _, name := range s.Order {
+ if p, ok := s.Data[name]; ok {
+ list = append(list, &p)
+ }
}
return list, nil
}
+// @todo move storer impl into package.
+
// StormStorer implements Project storage using storm db.
type StormStorer struct {
DB *storm.DB
}
+// ProjectSchema is a schema representation of a project.
+type ProjectSchema struct {
+ Name string `storm:"id"`
+ Project
+}
+
var _ Storer = (*StormStorer)(nil)
func (s *StormStorer) Create(p *Project) error {
if len(strings.TrimSpace(p.Name)) == 0 {
return fmt.Errorf("project name required")
}
- return s.DB.Save((*struct {
- Name string `storm:"id,unique,index"`
- Stages Stages
- Finalized []Ticket
- })(p))
+ fmt.Printf("creating project: %v\n ", p)
+ return s.DB.Save(&ProjectSchema{Name: p.Name, Project: *p})
}
func (s *StormStorer) Save(p *Project) error {
if len(strings.TrimSpace(p.Name)) == 0 {
return fmt.Errorf("project name required")
}
- return s.DB.Update((*struct {
- Name string `storm:"id,unique,index"`
- Stages Stages
- Finalized []Ticket
- })(p))
+ return s.DB.Update(&ProjectSchema{Name: p.Name, Project: *p})
}
func (s *StormStorer) Load(name string) (*Project, bool, error) {
- var p Project
+ var p ProjectSchema
if err := s.DB.Find("Name", name, &p); err != nil {
if errors.Is(err, storm.ErrNotFound) {
- return &p, false, nil
+ return &p.Project, false, nil
} else {
- return &p, false, err
+ return &p.Project, false, err
}
}
- return &p, true, nil
+ return &p.Project, true, nil
}
func (s *StormStorer) List() (list []*Project, err error) {
- return list, s.DB.All(&list)
+ var (
+ projects []*ProjectSchema
+ )
+ if err := s.DB.All(&projects); err != nil {
+ return nil, fmt.Errorf("loading projects: %v", err)
+ }
+ for _, p := range projects {
+ list = append(list, &p.Project)
+ }
+ return list, nil
}