kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

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:
Mcmd/kanban/control/rail.go | 3++-
Mcmd/kanban/main.go | 342+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcmd/kanban/widgets.go | 11+++++++++--
Mgo.mod | 1+
Mgo.sum | 2++
Mkanban.go | 77+++++++++++++++++++++++++++++++++++++++++++++++------------------------------
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 }