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:
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
+}