kanban

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

commit 1d25464d524cadb3ee85a218d0c4a2e1612e15cd
parent 63011112056d8928b350e48f43f0e2ff27c33d7b
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Sat, 13 Feb 2021 17:29:40 +0800

ref: pull out storage impls into packages

Diffstat:
Mcmd/kanban/main.go | 510++-----------------------------------------------------------------------------
Acmd/kanban/ui.go | 490+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgo.sum | 3+++
Astorage/mem/mem.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astorage/storage.go | 15+++++++++++++++
Astorage/storm/storm.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 657 insertions(+), 500 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -1,33 +1,20 @@ package main import ( - "fmt" - "image" - "image/color" "log" "os" "path/filepath" - "unsafe" - "git.sr.ht/~jackmordaunt/kanban" - "github.com/asdine/storm/v3" + "git.sr.ht/~jackmordaunt/kanban/storage/storm" + "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" + "git.sr.ht/~jackmordaunt/kanban/storage" + "git.sr.ht/~jackmordaunt/kanban/storage/mem" "gioui.org/app" - "gioui.org/io/key" - "gioui.org/io/system" - "gioui.org/layout" - "gioui.org/op" ) var ( @@ -40,28 +27,16 @@ func init() { } func main() { - storage := func() kanban.Storer { + storage, err := func() (storage.Storer, error) { if MemStorage { - return kanban.MapStorer{}.New() + return mem.New(), nil } 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) - } - return &kanban.StormStorer{DB: db} + return storm.Open(filepath.Join(os.TempDir(), "kanban.db")) } }() + if err != nil { + log.Fatalf("storage driver: %v\n", err) + } if closer, ok := storage.(interface{ Close() error }); ok { defer closer.Close() } @@ -78,468 +53,3 @@ func main() { }() app.Main() } - -type ( - C = layout.Context - D = layout.Dimensions -) - -// UI is the high level object that contains UI-global state. -// -// Anything that needs to integrate with the external system is allocated on -// this object. -// -// UI has three primary methods "Loop", "Update" and "Layout". -// Loop starts the event loop and runs until the program terminates. -// Update changes state based on events. -// Layout takes the UI state and renders using Gio primitives. -type UI struct { - // Window is a reference to the window handle. - *app.Window - - // Th contains theme data application wide. - Th *material.Theme - - // Storage driver responsible for allocating Project objects. - Storage kanban.Storer - - // Project is the currently active kanban Project. - // Contains the state and methods for kanban operations. - // Points to memory allocated by the storage implementation. - // nil value implies no active project. - Project *kanban.Project - - // Panels render the active Project stages. - // Shares the same lifetime as the active project. - Panels []*control.Panel - - // Rail allows intra-project navigation as a side bar. - // When a Project item is clicked, that Project is loaded from storage and - // becomes the active Project. - Rail control.Rail - - // TicketStates allocates memory for the Project's tickets and assocated - // UI state. - TicketStates state.Map - - // Modal is rendered atop the main content when not nil. - Modal layout.Widget - - // Form state. - TicketForm TicketForm - TicketDetails TicketDetails - DeleteDialog DeleteDialog - ProjectForm ProjectForm - - // FocusedTicket struct { - // ID kanban.ID - // Index int - // Stage kanban.ID - // } - - CreateProjectButton widget.Clickable -} - -// Loop runs the event loop until terminated. -func (ui *UI) Loop() error { - var ( - ops op.Ops - events = ui.Window.Events() - ) - for event := range events { - switch event := (event).(type) { - case system.DestroyEvent: - return event.Err - case system.FrameEvent: - gtx := layout.NewContext(&ops, event) - ui.Update(gtx) - ui.Layout(gtx) - event.Frame(gtx.Ops) - } - } - return ui.Shutdown() -} - -// Shutdown does cleanup. -func (ui UI) Shutdown() error { - if err := ui.Storage.Save(ui.Project); err != nil { - return fmt.Errorf("saving project: %v", err) - } - return nil -} - -// Update state based on events. -// -// TODO: investigate best way to handle errors. Some errors are for the user -// and others are for the devs. -// Currently errors are just printed; not great for windowed applications. -func (ui *UI) Update(gtx C) { - for _, event := range gtx.Events(ui) { - if k, ok := event.(key.Event); ok { - switch k.Name { - case key.NameEscape: - ui.Clear() - case key.NameEnter, key.NameReturn: - // @Cleanup - // on "enter" we want to launch edit form for the focused ticket. - // - // var ( - // t kanban.Ticket - // ) - // if err := ui.Project.Find("ID", ui.FocusedTicket, &t); err != nil { - // fmt.Printf("error: %v\n", err) - // } else { - // ui.InspectTicket(t) - // } - case key.NameDownArrow: - ui.Refocus(NextTicket) - case key.NameUpArrow: - ui.Refocus(PreviousTicket) - case key.NameRightArrow: - ui.Refocus(NextStage) - case key.NameLeftArrow: - ui.Refocus(PreviousStage) - } - } - } - 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] - if panel.CreateTicket.Clicked() { - ui.AddTicket(panel.Label) - } - } - for ui.TicketStates.More() { - _, v := ui.TicketStates.Next() - t := (*Ticket)(v) - if ui.Modal != nil { - continue - } - if t.NextButton.Clicked() { - ui.Project.ProgressTicket(t.Ticket) - } - if t.PrevButton.Clicked() { - ui.Project.RegressTicket(t.Ticket) - } - if t.EditButton.Clicked() { - ui.EditTicket(&t.Ticket) - } - if t.DeleteButton.Clicked() { - ui.DeleteTicket(t.Ticket) - } - if t.Content.Clicked() { - ui.InspectTicket(t.Ticket) - } - } - if ui.TicketForm.SubmitBtn.Clicked() { - // @todo handle create/update ambiguity. - _ = ui.TicketForm.Submit() - ui.Project.AssignTicket(ui.TicketForm.Stage, *ui.TicketForm.Ticket) - ui.Clear() - } - if ui.TicketForm.CancelBtn.Clicked() { - ui.Clear() - } - if ui.DeleteDialog.Ok.Clicked() { - ui.Project.FinalizeTicket(ui.DeleteDialog.Ticket) - ui.Clear() - } - if ui.DeleteDialog.Cancel.Clicked() { - ui.Clear() - } - if ui.TicketDetails.Edit.Clicked() { - ui.EditTicket(&ui.TicketDetails.Ticket) - } - if ui.TicketDetails.Cancel.Clicked() { - ui.Clear() - } - if ui.CreateProjectButton.Clicked() { - ui.CreateProject() - } -} - -// Layout UI. -func (ui *UI) Layout(gtx C) D { - key.InputOp{Tag: ui}.Add(gtx.Ops) - 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 - return ui.layoutRail(gtx) - }), - layout.Flexed(1, func(gtx C) D { - 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 { - 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 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 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) - }) - }), - ) -} - -type Direction uint8 - -const ( - NextTicket Direction = iota - PreviousTicket - NextStage - PreviousStage -) - -// Refocus to the ticket in the given direction. -// Allows movement between tickets and stages in sequential order. -func (ui *UI) Refocus(d Direction) { - // var ( - // project kanban.Project - // stage kanban.Stage - // ) - // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil { - // log.Printf("error: %v", err) - // return - // } - // if err := ui.Project.Find("ID", projet.St) - - // for { - // switch d { - // case NextTicket: - // ui.FocusedTicket.Index++ - // if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) { - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage++ - // if ui.FocusedTicket.Stage > len(stages)-1 { - // ui.FocusedTicket.Stage = 0 - // } - // } - // case PreviousTicket: - // ui.FocusedTicket.Index-- - // if ui.FocusedTicket.Index < 1 { - // ui.FocusedTicket.Stage-- - // if ui.FocusedTicket.Stage < 0 { - // ui.FocusedTicket.Stage = len(stages) - 1 - // } - // ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets) - // } - // case NextStage: - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage++ - // if ui.FocusedTicket.Stage > len(stages)-1 { - // ui.FocusedTicket.Stage = 0 - // } - // case PreviousStage: - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage-- - // if ui.FocusedTicket.Stage < 0 { - // ui.FocusedTicket.Stage = len(stages) - 1 - // } - // } - // if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() { - // break - // } - // } - // ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID -} - -// Clear resets navigational state. -func (ui *UI) Clear() { - ui.Modal = nil - ui.TicketForm = TicketForm{} - ui.ProjectForm = ProjectForm{} - // @cleanup - // ui.FocusedTicket = struct { - // ID kanban.ID - // Index int - // Stage kanban.ID - // }{} -} - -// InspectTicket opens the ticket details card for the given ticket. -func (ui *UI) InspectTicket(t kanban.Ticket) { - ui.TicketDetails.Ticket = t - ui.Modal = func(gtx C) D { - return control.Card{ - Title: fmt.Sprintf("%q", t.Title), - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.TicketDetails.Layout(gtx, ui.Th) - }) - } -} - -// EditTicket opens the ticket form for editing ticket data. -func (ui *UI) EditTicket(t *kanban.Ticket) { - ui.TicketForm.Set(t) - ui.Modal = func(gtx C) D { - return control.Card{ - Title: "Edit Ticket", - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.TicketForm.Layout(gtx, ui.Th, "") - }) - } -} - -// 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", - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.TicketForm.Layout(gtx, ui.Th, stage) - }) - } -} - -// CreatTicket opens the project creation dialog. -func (ui *UI) CreateProject() { - ui.Modal = func(gtx C) D { - return control.Card{ - Title: "Create a new Project", - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.ProjectForm.Layout(gtx, ui.Th) - }) - } -} - -// DeleteTickets opens the confirmation dialog for deleting a ticket. -func (ui *UI) DeleteTicket(t kanban.Ticket) { - ui.DeleteDialog.Ticket = t - ui.Modal = func(gtx C) D { - return control.Card{ - Title: "Delete Ticket", - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.DeleteDialog.Layout(gtx, ui.Th) - }) - } -} diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go @@ -0,0 +1,490 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "log" + "unsafe" + + "gioui.org/app" + "gioui.org/f32" + "gioui.org/io/key" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban" + "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" + "git.sr.ht/~jackmordaunt/kanban/storage" +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +// UI is the high level object that contains UI-global state. +// +// Anything that needs to integrate with the external system is allocated on +// this object. +// +// UI has three primary methods "Loop", "Update" and "Layout". +// Loop starts the event loop and runs until the program terminates. +// Update changes state based on events. +// Layout takes the UI state and renders using Gio primitives. +type UI struct { + // Window is a reference to the window handle. + *app.Window + + // Th contains theme data application wide. + Th *material.Theme + + // Storage driver responsible for allocating Project objects. + Storage storage.Storer + + // Project is the currently active kanban Project. + // Contains the state and methods for kanban operations. + // Points to memory allocated by the storage implementation. + // nil value implies no active project. + Project *kanban.Project + + // Panels render the active Project stages. + // Shares the same lifetime as the active project. + Panels []*control.Panel + + // Rail allows intra-project navigation as a side bar. + // When a Project item is clicked, that Project is loaded from storage and + // becomes the active Project. + Rail control.Rail + + // TicketStates allocates memory for the Project's tickets and assocated + // UI state. + TicketStates state.Map + + // Modal is rendered atop the main content when not nil. + Modal layout.Widget + + // Form state. + TicketForm TicketForm + TicketDetails TicketDetails + DeleteDialog DeleteDialog + ProjectForm ProjectForm + + // FocusedTicket struct { + // ID kanban.ID + // Index int + // Stage kanban.ID + // } + + CreateProjectButton widget.Clickable +} + +// Loop runs the event loop until terminated. +func (ui *UI) Loop() error { + var ( + ops op.Ops + events = ui.Window.Events() + ) + for event := range events { + switch event := (event).(type) { + case system.DestroyEvent: + return event.Err + case system.FrameEvent: + gtx := layout.NewContext(&ops, event) + ui.Update(gtx) + ui.Layout(gtx) + event.Frame(gtx.Ops) + } + } + return ui.Shutdown() +} + +// Shutdown does cleanup. +func (ui UI) Shutdown() error { + if err := ui.Storage.Save(ui.Project); err != nil { + return fmt.Errorf("saving project: %v", err) + } + return nil +} + +// Update state based on events. +// +// TODO: investigate best way to handle errors. Some errors are for the user +// and others are for the devs. +// Currently errors are just printed; not great for windowed applications. +func (ui *UI) Update(gtx C) { + for _, event := range gtx.Events(ui) { + if k, ok := event.(key.Event); ok { + switch k.Name { + case key.NameEscape: + ui.Clear() + case key.NameEnter, key.NameReturn: + // @Cleanup + // on "enter" we want to launch edit form for the focused ticket. + // + // var ( + // t kanban.Ticket + // ) + // if err := ui.Project.Find("ID", ui.FocusedTicket, &t); err != nil { + // fmt.Printf("error: %v\n", err) + // } else { + // ui.InspectTicket(t) + // } + case key.NameDownArrow: + ui.Refocus(NextTicket) + case key.NameUpArrow: + ui.Refocus(PreviousTicket) + case key.NameRightArrow: + ui.Refocus(NextStage) + case key.NameLeftArrow: + ui.Refocus(PreviousStage) + } + } + } + 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] + if panel.CreateTicket.Clicked() { + ui.AddTicket(panel.Label) + } + } + for ui.TicketStates.More() { + _, v := ui.TicketStates.Next() + t := (*Ticket)(v) + if ui.Modal != nil { + continue + } + if t.NextButton.Clicked() { + ui.Project.ProgressTicket(t.Ticket) + } + if t.PrevButton.Clicked() { + ui.Project.RegressTicket(t.Ticket) + } + if t.EditButton.Clicked() { + ui.EditTicket(&t.Ticket) + } + if t.DeleteButton.Clicked() { + ui.DeleteTicket(t.Ticket) + } + if t.Content.Clicked() { + ui.InspectTicket(t.Ticket) + } + } + if ui.TicketForm.SubmitBtn.Clicked() { + // @todo handle create/update ambiguity. + _ = ui.TicketForm.Submit() + ui.Project.AssignTicket(ui.TicketForm.Stage, *ui.TicketForm.Ticket) + ui.Clear() + } + if ui.TicketForm.CancelBtn.Clicked() { + ui.Clear() + } + if ui.DeleteDialog.Ok.Clicked() { + ui.Project.FinalizeTicket(ui.DeleteDialog.Ticket) + ui.Clear() + } + if ui.DeleteDialog.Cancel.Clicked() { + ui.Clear() + } + if ui.TicketDetails.Edit.Clicked() { + ui.EditTicket(&ui.TicketDetails.Ticket) + } + if ui.TicketDetails.Cancel.Clicked() { + ui.Clear() + } + if ui.CreateProjectButton.Clicked() { + ui.CreateProject() + } +} + +// Layout UI. +func (ui *UI) Layout(gtx C) D { + key.InputOp{Tag: ui}.Add(gtx.Ops) + 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 + return ui.layoutRail(gtx) + }), + layout.Flexed(1, func(gtx C) D { + 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 { + 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 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 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) + }) + }), + ) +} + +type Direction uint8 + +const ( + NextTicket Direction = iota + PreviousTicket + NextStage + PreviousStage +) + +// Refocus to the ticket in the given direction. +// Allows movement between tickets and stages in sequential order. +func (ui *UI) Refocus(d Direction) { + // var ( + // project kanban.Project + // stage kanban.Stage + // ) + // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil { + // log.Printf("error: %v", err) + // return + // } + // if err := ui.Project.Find("ID", projet.St) + + // for { + // switch d { + // case NextTicket: + // ui.FocusedTicket.Index++ + // if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) { + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage++ + // if ui.FocusedTicket.Stage > len(stages)-1 { + // ui.FocusedTicket.Stage = 0 + // } + // } + // case PreviousTicket: + // ui.FocusedTicket.Index-- + // if ui.FocusedTicket.Index < 1 { + // ui.FocusedTicket.Stage-- + // if ui.FocusedTicket.Stage < 0 { + // ui.FocusedTicket.Stage = len(stages) - 1 + // } + // ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets) + // } + // case NextStage: + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage++ + // if ui.FocusedTicket.Stage > len(stages)-1 { + // ui.FocusedTicket.Stage = 0 + // } + // case PreviousStage: + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage-- + // if ui.FocusedTicket.Stage < 0 { + // ui.FocusedTicket.Stage = len(stages) - 1 + // } + // } + // if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() { + // break + // } + // } + // ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID +} + +// Clear resets navigational state. +func (ui *UI) Clear() { + ui.Modal = nil + ui.TicketForm = TicketForm{} + ui.ProjectForm = ProjectForm{} + // @cleanup + // ui.FocusedTicket = struct { + // ID kanban.ID + // Index int + // Stage kanban.ID + // }{} +} + +// InspectTicket opens the ticket details card for the given ticket. +func (ui *UI) InspectTicket(t kanban.Ticket) { + ui.TicketDetails.Ticket = t + ui.Modal = func(gtx C) D { + return control.Card{ + Title: fmt.Sprintf("%q", t.Title), + }.Layout(gtx, ui.Th, func(gtx C) D { + return ui.TicketDetails.Layout(gtx, ui.Th) + }) + } +} + +// EditTicket opens the ticket form for editing ticket data. +func (ui *UI) EditTicket(t *kanban.Ticket) { + ui.TicketForm.Set(t) + ui.Modal = func(gtx C) D { + return control.Card{ + Title: "Edit Ticket", + }.Layout(gtx, ui.Th, func(gtx C) D { + return ui.TicketForm.Layout(gtx, ui.Th, "") + }) + } +} + +// 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", + }.Layout(gtx, ui.Th, func(gtx C) D { + return ui.TicketForm.Layout(gtx, ui.Th, stage) + }) + } +} + +// CreatTicket opens the project creation dialog. +func (ui *UI) CreateProject() { + ui.Modal = func(gtx C) D { + return control.Card{ + Title: "Create a new Project", + }.Layout(gtx, ui.Th, func(gtx C) D { + return ui.ProjectForm.Layout(gtx, ui.Th) + }) + } +} + +// DeleteTickets opens the confirmation dialog for deleting a ticket. +func (ui *UI) DeleteTicket(t kanban.Ticket) { + ui.DeleteDialog.Ticket = t + ui.Modal = func(gtx C) D { + return control.Card{ + Title: "Delete Ticket", + }.Layout(gtx, ui.Th, func(gtx C) D { + return ui.DeleteDialog.Layout(gtx, ui.Th) + }) + } +} diff --git a/go.sum b/go.sum @@ -12,6 +12,9 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM= github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= +github.com/asdine/storm v1.1.0 h1:lwDLqMMPhokfYk8EuU1RRHTi54T68EI+QnCqK5t4TCM= +github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q= +github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ= github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac= github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/storage/mem/mem.go b/storage/mem/mem.go @@ -0,0 +1,58 @@ +package mem + +import ( + "fmt" + + "git.sr.ht/~jackmordaunt/kanban/storage" + + "git.sr.ht/~jackmordaunt/kanban" +) + +var _ storage.Storer = (*Storer)(nil) + +// Storer implements in-memory storage for Projects. +type Storer struct { + Data map[string]kanban.Project + Order []string + Err error +} + +func New() *Storer { + return &Storer{ + Data: make(map[string]kanban.Project), + } +} + +func (s *Storer) Create(p kanban.Project) error { + if _, ok := s.Data[p.Name]; ok { + return fmt.Errorf("project %q exists", p.Name) + } + s.Data[p.Name] = p + s.Order = append(s.Order, p.Name) + return nil +} + +func (s *Storer) Save(p *kanban.Project) error { + if _, ok := s.Data[p.Name]; ok { + s.Data[p.Name] = *p + } else { + return fmt.Errorf("project %q does not exist", p.Name) + } + return nil +} + +func (s *Storer) Load(name string) (*kanban.Project, bool, error) { + if p, ok := s.Data[name]; ok { + return &p, ok, nil + } + return nil, false, nil +} + +func (s *Storer) List() (list []*kanban.Project, err error) { + for _, name := range s.Order { + if p, ok := s.Data[name]; ok { + list = append(list, &p) + } + } + return list, nil +} diff --git a/storage/storage.go b/storage/storage.go @@ -0,0 +1,15 @@ +// Package storage specifies a storage interface for Kanban Projects. +// Sub packages implement the interface providing different storage strategies. +package storage + +import ( + "git.sr.ht/~jackmordaunt/kanban" +) + +// Storer persists Project entities. +type Storer interface { + Create(p kanban.Project) error + Save(p *kanban.Project) error + Load(name string) (*kanban.Project, bool, error) + List() ([]*kanban.Project, error) +} diff --git a/storage/storm/storm.go b/storage/storm/storm.go @@ -0,0 +1,81 @@ +package storm + +import ( + "errors" + "fmt" + "log" + "strings" + + "git.sr.ht/~jackmordaunt/kanban" + "git.sr.ht/~jackmordaunt/kanban/storage" + + "github.com/asdine/storm/v3" +) + +var _ storage.Storer = (*Storer)(nil) + +// Storer implements Project storage using storm db. +type Storer struct { + DB *storm.DB +} + +// Schema is a database representation of a project. +type Schema struct { + ID string `storm:"id"` + Project kanban.Project +} + +// Open a database handle using the file specified by path. +func Open(path string) (*Storer, error) { + db, err := storm.Open(path) + if err != nil { + return nil, fmt.Errorf("opening data file: %w", err) + } + if err := db.Init(&Schema{}); err != nil { + return nil, fmt.Errorf("initialising schema: %v", err) + } + if err != nil { + log.Fatalf("error: initializing data: %v", err) + } + return &Storer{DB: db}, nil + +} + +func (s *Storer) Create(p kanban.Project) error { + return s.DB.Save(&Schema{ + ID: p.Name, + Project: p, + }) +} + +func (s *Storer) Save(p *kanban.Project) error { + if len(strings.TrimSpace(p.Name)) == 0 { + return fmt.Errorf("project name required") + } + return s.DB.Update(&Schema{ID: p.Name, Project: *p}) +} + +func (s *Storer) Load(name string) (*kanban.Project, bool, error) { + var schema Schema + if err := s.DB.One("ID", name, &schema); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return &schema.Project, false, nil + } else { + return &schema.Project, false, err + } + } + return &schema.Project, true, nil +} + +func (s *Storer) List() (list []*kanban.Project, err error) { + var ( + projects []Schema + ) + 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 +}