kanban

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

commit de1bc0162742c5af2b7db1635a4c9ac0e9c3e9aa
parent a0e097c56ab4948663919d0d1994cd45c9888c95
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Wed, 10 Feb 2021 18:12:12 +0800

ref: restructure; pull code into packages

Diffstat:
Dcmd/bundle/main.go | 213-------------------------------------------------------------------------------
Acmd/kanban/control/card.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/kanban/control/panel.go | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/kanban/control/rail.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/kanban/layout.go | 55++-----------------------------------------------------
Mcmd/kanban/main.go | 859+++++++++++++++----------------------------------------------------------------
Dcmd/kanban/map.go | 76----------------------------------------------------------------------------
Acmd/kanban/state/map.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/kanban/util/util.go | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcmd/kanban/widget.go | 212-------------------------------------------------------------------------------
Acmd/kanban/widgets.go | 451+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgo.mod | 3---
Mgo.sum | 6------
Mkanban.go | 466++++++++++++++++++++++++++++++++++++++++++-------------------------------------
14 files changed, 1334 insertions(+), 1480 deletions(-)

diff --git a/cmd/bundle/main.go b/cmd/bundle/main.go @@ -1,213 +0,0 @@ -// bundle is a tool for creating OS specific installable packages for Windows, -// macOS and Linux. -package main - -import ( - "fmt" - "image/png" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - - "github.com/jackmordaunt/icns" -) - -func main() { - if err := func() error { - if len(os.Args) < 2 { - return fmt.Errorf("specify a platform {windows,macos,linux}") - } - binary, err := build("cmd/kanban") - if err != nil { - return fmt.Errorf("building: %w", err) - } - platform := os.Args[1] - switch platform { - case "macos": - if err := bundleMacOS("dist/Kanban.app", binary, "res/icon.png", "res/darwin/Info.plist"); err != nil { - return fmt.Errorf("bundling macos: %w", err) - } - case "windows": - if err := bundleWindows("dist/Kanban.exe", binary, "res/icon.png", ""); err != nil { - return fmt.Errorf("bundling windows: %w", err) - } - case "linux": - if err := bundleLinux("dist/Kanban", binary, "res/icon.png"); err != nil { - return fmt.Errorf("bundling linux: %w", err) - } - default: - return fmt.Errorf("platform %q not supported", platform) - } - return nil - }(); err != nil { - fmt.Printf("error: %v", err) - } -} - -// bundleMacOS creates a macOS .app bundleMacOS on disk rooted at dest. -// All paramaters are filepaths. -// NB: Will clobber destination if it is a directory, or error if it is a file. -func bundleMacOS(dest, binary, icon, plist string) error { - var ( - contents = filepath.Join(dest, "Contents") - macos = filepath.Join(contents, "MacOS") - resources = filepath.Join(contents, "Resources") - ) - m, err := os.Stat(dest) - if os.IsNotExist(err) || m.IsDir() { - os.RemoveAll(dest) - if err := os.MkdirAll(dest, 0777); err != nil { - return fmt.Errorf("preparing destination: %w", err) - } - } else if !m.IsDir() { - return fmt.Errorf("destination %q: not a directory", dest) - } - if err := os.MkdirAll(macos, 0777); err != nil { - return fmt.Errorf("preparing directory: %w", err) - } - if err := os.MkdirAll(resources, 0777); err != nil { - return fmt.Errorf("preparing directory: %w", err) - } - if err := cp(binary, filepath.Join(macos, "kanban")); err != nil { - return fmt.Errorf("copying binary: %w", err) - } - if err := cp(plist, filepath.Join(contents, "Info.plist")); err != nil { - return fmt.Errorf("copying plist: %w", err) - } - if err := convertIcon(icon, filepath.Join(resources, "kanban.icns")); err != nil { - return fmt.Errorf("converting icon to icns: %w", err) - } - switch runtime.GOOS { - case "linux": - if err := run( - "genisoimage", - "-V", "Kanban", - "-D", - "-R", - "-apple", - "-no-pad", - "-o", "Kanban.dmg", - filepath.Dir(dest), - ); err != nil { - return fmt.Errorf("genisoimage: %w", err) - } - case "darwin": - // dmg: | $(DMG_NAME) - // $(DMG_NAME): $(APP_NAME) - // @echo "Packing disk image..." - // @ln -sf /Applications $(DMG_DIR)/Applications - // @hdiutil create $(DMG_DIR)/$(DMG_NAME) \ - // -volname "Kanban" \ - // -fs HFS+ \ - // -srcfolder $(APP_DIR) \ - // -ov -format UDZO - // @echo "Packed '$@' in '$(APP_DIR)'" - if err := run( - "hdiutil", - "create", - filepath.Join(filepath.Dir(dest), "Kanban.dmg"), - "-volname", "Kanban", - "-fs", "HFS+", - "-srcfolder", dest, - "-ov", "-format", "UDZO", - ); err != nil { - return fmt.Errorf("hdiutil: %w", err) - } - case "windows": - return fmt.Errorf("cannot create dmg on windows yet") - default: - return fmt.Errorf("cannot create dmg on %q", runtime.GOOS) - } - return nil -} - -func bundleWindows(dest, binary, icon, manifest string) error { - return fmt.Errorf("unimplemented") -} - -func bundleLinux(dest, binary, icon string) error { - return fmt.Errorf("unimplemented") -} - -// convertIcon converts the source png to icon and returns a path to it. -func convertIcon(src, dst string) error { - srcf, err := os.Open(src) - if err != nil { - return fmt.Errorf("opening source file: %w", err) - } - defer srcf.Close() - img, err := png.Decode(srcf) - if err != nil { - return fmt.Errorf("decoding source png: %w", err) - } - dstf, err := os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("opening destination file: %w", err) - } - defer dstf.Close() - if err := icns.Encode(dstf, img); err != nil { - return fmt.Errorf("encoding icns: %w", err) - } - return nil -} - -// build the Go program rooted at path and returns a path to it. -func build(path string) (string, error) { - path, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("resolving absolute path: %w", err) - } - if err := run("go", "build", "-o", "dist/kanban", path); err != nil { - return "", err - } - return "dist/kanban", nil -} - -// run the specified command and return any error. -func run(cmd string, args ...string) error { - if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil { - return fmt.Errorf("running command %q: %v: %w", cmd, string(out), err) - } - return nil -} - -// cp copies src file to destination. -// If destination is a directory, the file will be copied into it. -// If destination doesn't exist it will be created as a file. -// If destination is a file an error will be returned. -func cp(src, dst string) error { - if src == "" || dst == "" { - return nil - } - var err error - src, err = filepath.Abs(src) - if err != nil { - return fmt.Errorf("resolving path: %w", err) - } - dst, err = filepath.Abs(dst) - if err != nil { - return fmt.Errorf("resolving path: %w", err) - } - srcf, err := os.Open(src) - if err != nil { - return fmt.Errorf("opening %q: %w", src, err) - } - defer srcf.Close() - _, err = os.Stat(filepath.Dir(dst)) - if os.IsNotExist(err) { - if err := os.MkdirAll(filepath.Dir(dst), 0777); err != nil { - return fmt.Errorf("preparing %q: %w", filepath.Dir(dst), err) - } - } - dstf, err := os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0777) - if err != nil { - return fmt.Errorf("creating %q: %w", dst, err) - } - defer dstf.Close() - if _, err := io.Copy(dstf, srcf); err != nil { - return fmt.Errorf("copying data: %w", err) - } - return nil -} diff --git a/cmd/kanban/control/card.go b/cmd/kanban/control/card.go @@ -0,0 +1,61 @@ +package control + +import ( + "image/color" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" +) + +// Card lays the content out with a title for context. +type Card struct { + Title string +} + +func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return util.Rect{ + Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + Size: layout.FPt(gtx.Constraints.Min), + Radii: 4, + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + inset := layout.UniformInset(unit.Dp(10)) + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return util.Rect{ + Color: color.NRGBA{A: 100}, + Size: f32.Point{ + X: float32(gtx.Constraints.Max.X), + Y: float32(gtx.Constraints.Min.Y), + }, + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + return inset.Layout(gtx, func(gtx C) D { + return material.H6(th, c.Title).Layout(gtx) + }) + }), + ) + }), + layout.Rigid(func(gtx C) D { + return inset.Layout(gtx, func(gtx C) D { + return w(gtx) + }) + }), + ) + }), + ) +} diff --git a/cmd/kanban/control/panel.go b/cmd/kanban/control/panel.go @@ -0,0 +1,107 @@ +package control + +import ( + "image/color" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" + "git.sr.ht/~jackmordaunt/kanban/icons" +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +// Panel can hold cards. +// One panel per stage in the kanban pipeline. +// Has a title and action bar. +type Panel struct { + Label string + Color color.NRGBA + Thickness unit.Value + CreateTicket widget.Clickable + + layout.List +} + +func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D { + return widget.Border{ + Color: color.NRGBA{A: 200}, + Width: unit.Dp(0.5), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return util.Rect{ + Size: f32.Point{ + X: layout.FPt(gtx.Constraints.Max).X, + Y: float32(gtx.Px(p.Thickness)), + }, + Color: p.Color, + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{ + Left: unit.Dp(10), + Right: unit.Dp(15), + Top: unit.Dp(12), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return material.H6(th, p.Label).Layout(gtx) + }), + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + return util.Button( + &p.CreateTicket, + util.WithIcon(icons.ContentAdd), + util.WithSize(unit.Dp(15)), + util.WithInset(layout.UniformInset(unit.Dp(6))), + util.WithBgColor(color.NRGBA{}), + util.WithIconColor(th.Fg), + ).Layout(gtx) + }), + ) + }) + }), + ) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return util.Rect{ + Color: color.NRGBA{R: 240, G: 240, B: 240, A: 255}, + Size: layout.FPt(gtx.Constraints.Max), + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + p.List.Axis = layout.Vertical + return p.List.Layout(gtx, len(tickets), func(gtx C, ii int) D { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D { + return tickets[ii](gtx, ii) + }) + + }) + }), + ) + }), + ) + }) +} diff --git a/cmd/kanban/control/rail.go b/cmd/kanban/control/rail.go @@ -0,0 +1,88 @@ +package control + +import ( + "image/color" + "unsafe" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/state" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" +) + +// Rail is an interactive side rail with a list of widget items that can be +// selected. +// +// Typically used as navigation or contextual actions. +// +// Rail is stateful. +type Rail struct { + layout.List + Map state.Map +} + +// RailChild is an item that renders in a rail. +type RailChild struct { + Name string + W layout.Widget +} + +// Destination is a rail item that represents a navigatable object. +// Destinations are pab+dded by default. +func Destination(name string, w layout.Widget) RailChild { + return RailChild{ + Name: name, + W: w, + } +} + +func (r *Rail) next(key string) *widget.Clickable { + return (*widget.Clickable)(r.Map.New(key, unsafe.Pointer(&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() { + if (*widget.Clickable)(v).Clicked() { + return k, true + } + } + return "", false +} + +// Layout the rail with the given items. +func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D { + r.List.Axis = layout.Vertical + r.List.Alignment = layout.Middle + r.Map.Begin() + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return action(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D { + return util.Div{ + Color: color.NRGBA{R: 220, B: 220, G: 220, A: 255}, + Length: unit.Px(float32(gtx.Constraints.Max.X)), + Thickness: unit.Dp(1), + Axis: layout.Horizontal, + }.Layout(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + return r.List.Layout(gtx, len(items), func(gtx C, ii int) D { + rc := items[ii] + return material.Clickable(gtx, r.next(rc.Name), func(gtx C) D { + return rc.W(gtx) + }) + }) + }), + ) +} diff --git a/cmd/kanban/layout.go b/cmd/kanban/layout.go @@ -3,68 +3,17 @@ package main import ( "image/color" - "gioui.org/f32" "gioui.org/layout" "gioui.org/unit" - "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" ) -// Card lays the content out with a title for context. -type Card struct { - Title string -} - -func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D { - return layout.Stack{}.Layout( - gtx, - layout.Expanded(func(gtx C) D { - return Rect{ - Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, - Size: layout.FPt(gtx.Constraints.Min), - Radii: 4, - }.Layout(gtx) - }), - layout.Stacked(func(gtx C) D { - inset := layout.UniformInset(unit.Dp(10)) - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return layout.Stack{}.Layout( - gtx, - layout.Expanded(func(gtx C) D { - return Rect{ - Color: color.NRGBA{A: 100}, - Size: f32.Point{ - X: float32(gtx.Constraints.Max.X), - Y: float32(gtx.Constraints.Min.Y), - }, - }.Layout(gtx) - }), - layout.Stacked(func(gtx C) D { - return inset.Layout(gtx, func(gtx C) D { - return material.H6(th, c.Title).Layout(gtx) - }) - }), - ) - }), - layout.Rigid(func(gtx C) D { - return inset.Layout(gtx, func(gtx C) D { - return w(gtx) - }) - }), - ) - }), - ) -} - // Modal renders content centered on a translucent scrim. func Modal(gtx C, w layout.Widget) D { return layout.Stack{}.Layout( gtx, layout.Stacked(func(gtx C) D { - return Rect{ + return util.Rect{ Size: layout.FPt(gtx.Constraints.Max), Color: color.NRGBA{A: 200}, }.Layout(gtx) diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -1,24 +1,31 @@ +// @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" - "strconv" - "time" - "unsafe" "git.sr.ht/~jackmordaunt/kanban" "github.com/asdine/storm/v3" - "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/icons" "gioui.org/app" @@ -26,7 +33,6 @@ import ( "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" - "gioui.org/x/component" ) func main() { @@ -37,9 +43,6 @@ func main() { if err != nil { return nil, fmt.Errorf("opening data file: %w", err) } - if err := db.Init(&kanban.Stage{}); err != nil { - return nil, err - } if err := db.Init(&kanban.Ticket{}); err != nil { return nil, err } @@ -54,34 +57,9 @@ func main() { defer db.Close() go func() { ui := UI{ - Window: app.NewWindow(app.Title("Kanban")), - Th: material.NewTheme(gofont.Collection()), - Kanban: &kanban.Kanban{ - Store: db, - }, - // // TODO: render dynamically from storage. - // Panels: []Panel{ - // { - // Label: "Todo", - // Color: color.NRGBA{R: 0x91, G: 0x81, B: 0x8a, A: 220}, - // Thickness: unit.Dp(50), - // }, - // { - // Label: "In Progress", - // Color: color.NRGBA{R: 0, G: 100, B: 200, A: 220}, - // Thickness: unit.Dp(50), - // }, - // { - // Label: "Testing", - // Color: color.NRGBA{R: 200, G: 100, B: 0, A: 220}, - // Thickness: unit.Dp(50), - // }, - // { - // Label: "Done", - // Color: color.NRGBA{R: 50, G: 200, B: 100, A: 220}, - // Thickness: unit.Dp(50), - // }, - // }, + Window: app.NewWindow(app.Title("Kanban")), + Th: material.NewTheme(gofont.Collection()), + Storage: &kanban.StormStorer{DB: db}, } if err := ui.Loop(); err != nil { log.Fatalf("error: %v", err) @@ -101,24 +79,25 @@ type ( // this object. type UI struct { *app.Window - Kanban *kanban.Kanban - Th *material.Theme - - // ActiveProject is the project being operated on. - ActiveProject kanban.ID - - Panels []Panel - Rail Rail - TicketStates Map + Storage kanban.Storer + Th *material.Theme + Project *kanban.Project + + // @Todo panels shouldn't be stateful. + Panels []control.Panel + Rail control.Rail + TicketStates state.Map Modal layout.Widget TicketForm TicketForm TicketDetails TicketDetails DeleteDialog DeleteDialog - FocusedTicket struct { - ID kanban.ID - Index int - Stage kanban.ID - } + + // FocusedTicket struct { + // ID kanban.ID + // Index int + // Stage kanban.ID + // } + CreateProjectButton widget.Clickable ProjectForm ProjectForm } @@ -139,6 +118,14 @@ func (ui *UI) Loop() error { 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 } @@ -152,14 +139,15 @@ func (ui *UI) Update(gtx C) { case key.NameEscape: ui.Clear() case key.NameEnter, key.NameReturn: - var ( - t kanban.Ticket - ) - if err := ui.Kanban.Store.Find("ID", ui.FocusedTicket, &t); err != nil { - fmt.Printf("error: %v\n", err) - } else { - ui.InspectTicket(t) - } + // @Cleanup + // 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: @@ -183,17 +171,13 @@ func (ui *UI) Update(gtx C) { continue } if t.NextButton.Clicked() { - if err := ui.Kanban.Progress(t.ID); err != nil { - fmt.Printf("error: %s\n", err) - } + ui.Project.ProgressTicket(t.Ticket) } if t.PrevButton.Clicked() { - if err := ui.Kanban.Regress(t.ID); err != nil { - fmt.Printf("error: %s\n", err) - } + ui.Project.RegressTicket(t.Ticket) } if t.EditButton.Clicked() { - ui.EditTicket(t.Ticket) + ui.EditTicket(&t.Ticket) } if t.DeleteButton.Clicked() { ui.DeleteTicket(t.Ticket) @@ -202,39 +186,32 @@ func (ui *UI) Update(gtx C) { ui.InspectTicket(t.Ticket) } } - if ui.TicketForm.Submit.Clicked() { - ticket, err := ui.TicketForm.Validate() - if err != nil { - fmt.Printf("error: %s\n", err) - return - } - if assign := ui.TicketForm.Stage != ""; assign { - if err := ui.Kanban.Assign(ui.TicketForm.Stage, ticket); err != nil { - fmt.Printf("error: assigning ticket: %s\n", err) - return - } - } else { - if err := ui.Kanban.Update(ticket); err != nil { - fmt.Printf("error: updating ticket: %s\n", err) - return - } - } + 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.Clear() } - if ui.TicketForm.Cancel.Clicked() { + if ui.TicketForm.CancelBtn.Clicked() { ui.Clear() } if ui.DeleteDialog.Ok.Clicked() { - if err := ui.Kanban.Finalize(ui.DeleteDialog.ID); err != nil { - fmt.Printf("error: %s\n", err) - } + 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) + ui.EditTicket(&ui.TicketDetails.Ticket) } if ui.TicketDetails.Cancel.Clicked() { ui.Clear() @@ -246,16 +223,22 @@ func (ui *UI) Update(gtx C) { ui.Clear() } if ui.ProjectForm.Submit.Clicked() { - if err := ui.Kanban.Store.Save(&kanban.Project{ + if err := ui.Storage.Create(&kanban.Project{ Name: ui.ProjectForm.Name.Text(), }); err != nil { - log.Printf("saving new project: %v", err) + log.Printf("creating new project: %v", err) } + ui.Clear() } if p, ok := ui.Rail.Selected(); ok { - if projectID, err := strconv.Atoi(p); err == nil { - ui.ActiveProject = kanban.ID(projectID) + project, ok, err := ui.Storage.Load(p) + if err != nil { + log.Printf("loading project %q: %v", p, err) } + if ok { + ui.Project = project + } + } } @@ -264,42 +247,41 @@ func (ui *UI) Layout(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout( gtx, layout.Rigid(func(gtx C) D { - // @Todo: render "active" destination in rail. gtx.Constraints.Min.Y = gtx.Constraints.Max.Y gtx.Constraints.Max.X = gtx.Px(unit.Dp(80)) gtx.Constraints.Min.X = 0 var ( - projects []kanban.Project - rc []RailChild + rc []control.RailChild ) - if err := ui.Kanban.Store.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 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}} - }), - ) - })) - } + // @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 { @@ -317,46 +299,47 @@ func (ui *UI) Layout(gtx C) D { return layout.Stack{}.Layout( gtx, layout.Stacked(func(gtx C) D { - if ui.ActiveProject.None() { + if ui.Project == nil { return D{} } ui.TicketStates.Begin() - var ( - project kanban.Project - stage kanban.Stage - ticket kanban.Ticket - t *Ticket - panels []layout.FlexChild - ) - // @fixme show project creation hint when there are no projects. - if err := ui.Kanban.Store.One("ID", ui.ActiveProject, &project); err != nil { - log.Printf("error: project %v", err) - } - for _, id := range project.Stages { - if err := ui.Kanban.Store.One("ID", id, &stage); err != nil { - log.Printf("error: stage %v", err) - } - // render the stage panel. - for _, id := range stage.Tickets { - if err := ui.Kanban.Store.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) - })) - } - } + 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, @@ -394,11 +377,11 @@ func (ui *UI) Refocus(d Direction) { // project kanban.Project // stage kanban.Stage // ) - // if err := ui.Kanban.Store.Find("ID", ui.ActiveProject, &project); err != nil { + // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil { // log.Printf("error: %v", err) // return // } - // if err := ui.Kanban.Store.Find("ID", projet.St) + // if err := ui.Project.Find("ID", projet.St) // for { // switch d { @@ -445,18 +428,19 @@ func (ui *UI) Clear() { ui.Modal = nil ui.TicketForm = TicketForm{} ui.ProjectForm = ProjectForm{} - ui.FocusedTicket = struct { - ID kanban.ID - Index int - Stage kanban.ID - }{} + // @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 Card{ + return control.Card{ Title: fmt.Sprintf("%q", t.Title), }.Layout(gtx, ui.Th, func(gtx C) D { return ui.TicketDetails.Layout(gtx, ui.Th) @@ -465,10 +449,10 @@ func (ui *UI) InspectTicket(t kanban.Ticket) { } // EditTicket opens the ticket form for editing ticket data. -func (ui *UI) EditTicket(t kanban.Ticket) { +func (ui *UI) EditTicket(t *kanban.Ticket) { ui.TicketForm.Set(t) ui.Modal = func(gtx C) D { - return Card{ + return control.Card{ Title: "Edit Ticket", }.Layout(gtx, ui.Th, func(gtx C) D { return ui.TicketForm.Layout(gtx, ui.Th, "") @@ -479,7 +463,7 @@ func (ui *UI) EditTicket(t kanban.Ticket) { // AddTicket opens the ticket form for creating ticket data. func (ui *UI) AddTicket(stage string) { ui.Modal = func(gtx C) D { - return Card{ + return control.Card{ Title: "Add Ticket", }.Layout(gtx, ui.Th, func(gtx C) D { return ui.TicketForm.Layout(gtx, ui.Th, stage) @@ -490,7 +474,7 @@ func (ui *UI) AddTicket(stage string) { // CreatTicket opens the project creation dialog. func (ui *UI) CreateProject() { ui.Modal = func(gtx C) D { - return Card{ + return control.Card{ Title: "Create a new Project", }.Layout(gtx, ui.Th, func(gtx C) D { return ui.ProjectForm.Layout(gtx, ui.Th) @@ -502,537 +486,10 @@ func (ui *UI) CreateProject() { func (ui *UI) DeleteTicket(t kanban.Ticket) { ui.DeleteDialog.Ticket = t ui.Modal = func(gtx C) D { - return Card{ + return control.Card{ Title: "Delete Ticket", }.Layout(gtx, ui.Th, func(gtx C) D { return ui.DeleteDialog.Layout(gtx, ui.Th) }) } } - -// TicketForm renders the form for ticket information. -// -// TODO: tab navigation through form fields. -type TicketForm struct { - Stage string - Data kanban.Ticket - Title component.TextField - Summary component.TextField - Details component.TextField - Submit widget.Clickable - Cancel widget.Clickable -} - -func (form *TicketForm) Set(t kanban.Ticket) { - form.Data = t - form.Title.SetText(t.Title) - form.Summary.SetText(t.Summary) - form.Details.SetText(t.Details) - // form.References.SetText(t.References) -} - -// Validate the inputs. -// Note: No actual validation is done yet. -func (form TicketForm) Validate() (kanban.Ticket, error) { - ticket := kanban.Ticket{ - Entity: kanban.Entity{ - ID: form.Data.ID, - Created: form.Data.Created, - }, - Title: form.Title.Text(), - Details: form.Details.Text(), - Summary: form.Summary.Text(), - } - return ticket, nil -} - -func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D { - form.Stage = stage - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return form.Title.Layout(gtx, th, "Title") - }), - layout.Rigid(func(gtx C) D { - return form.Summary.Layout(gtx, th, "Summary") - }), - layout.Rigid(func(gtx C) D { - return form.Details.Layout(gtx, th, "Details") - }), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{ - Top: unit.Dp(10), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - }.Layout( - gtx, - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - btn := material.Button(th, &form.Cancel, "Cancel") - btn.Color = th.Bg - btn.Background = color.NRGBA{} - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} - }), - layout.Rigid(func(gtx C) D { - return material.Button(th, &form.Submit, "Submit").Layout(gtx) - }), - ) - }) - }), - ) -} - -// DeleteDialog prompts the user with an option to delete a ticket. -type DeleteDialog struct { - kanban.Ticket - Ok widget.Clickable - Cancel widget.Clickable -} - -func (d *DeleteDialog) Layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return layout.Center.Layout(gtx, func(gtx C) D { - return material.Body1( - th, - fmt.Sprintf("Are you sure you want to delete ticket %q?", d.Title), - ).Layout(gtx) - }) - }), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{ - Top: unit.Dp(10), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - }.Layout( - gtx, - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - btn := material.Button(th, &d.Cancel, "Cancel") - btn.Color = th.Bg - btn.Background = color.NRGBA{} - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} - }), - layout.Rigid(func(gtx C) D { - btn := material.Button(th, &d.Ok, "Delete") - btn.Background = color.NRGBA{R: 200, A: 255} - return btn.Layout(gtx) - }), - ) - }) - }), - ) -} - -// Panel can hold cards. -// One panel per stage in the kanban pipeline. -// Has a title and action bar. -type Panel struct { - Label string - Color color.NRGBA - Thickness unit.Value - CreateTicket widget.Clickable - - layout.List -} - -func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D { - return widget.Border{ - Color: color.NRGBA{A: 200}, - Width: unit.Dp(0.5), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return layout.Stack{}.Layout( - gtx, - layout.Expanded(func(gtx C) D { - return Rect{ - Size: f32.Point{ - X: layout.FPt(gtx.Constraints.Max).X, - Y: float32(gtx.Px(p.Thickness)), - }, - Color: p.Color, - }.Layout(gtx) - }), - layout.Stacked(func(gtx C) D { - return layout.Inset{ - Left: unit.Dp(10), - Right: unit.Dp(15), - Top: unit.Dp(12), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - Alignment: layout.Middle, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return material.H6(th, p.Label).Layout(gtx) - }), - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - return Button( - &p.CreateTicket, - WithIcon(icons.ContentAdd), - WithSize(unit.Dp(15)), - WithInset(layout.UniformInset(unit.Dp(6))), - WithBgColor(color.NRGBA{}), - WithIconColor(th.Fg), - ).Layout(gtx) - }), - ) - }) - }), - ) - }), - layout.Flexed(1, func(gtx C) D { - return layout.Stack{}.Layout( - gtx, - layout.Expanded(func(gtx C) D { - return Rect{ - Color: color.NRGBA{R: 240, G: 240, B: 240, A: 255}, - Size: layout.FPt(gtx.Constraints.Max), - }.Layout(gtx) - }), - layout.Stacked(func(gtx C) D { - p.List.Axis = layout.Vertical - return p.List.Layout(gtx, len(tickets), func(gtx C, ii int) D { - return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D { - return tickets[ii](gtx, ii) - }) - - }) - }), - ) - }), - ) - }) -} - -// Ticket renders a ticket control. -type Ticket struct { - kanban.Ticket - Stage string - NextButton widget.Clickable - PrevButton widget.Clickable - EditButton widget.Clickable - DeleteButton widget.Clickable - Content widget.Clickable -} - -// Layout the ticket card. -// -// The layouting here was actually quite tricky because `layout.List` simulates -// an infinite Y axis. That means you can just specify a max Y constraint. This -// makes expanding stacked content vertically impossible with a naive use of -// `layout.Stack`. -// -// 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 { - 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)) - ) - return widget.Border{ - Width: unit.Dp(0.5), - Color: color.NRGBA{A: 200}, - }.Layout(gtx, func(gtx C) D { - dims := layout.Inset{ - Left: unit.Dp(25), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.Y = minContentSize - return t.content(gtx, th) - }), - layout.Rigid(func(gtx C) D { - return t.bottomBar( - gtx, - th, - image.Point{ - X: gtx.Constraints.Max.X, - Y: gtx.Px(barThickness), - }, - bottomBarColor, - ) - }), - ) - }) - t.sideBar( - gtx, - image.Point{ - X: gtx.Px(barThickness), - Y: dims.Size.Y, - }, - sideBarColor, - ) - return dims - }) -} - -func (t *Ticket) content(gtx C, th *material.Theme) D { - macro := op.Record(gtx.Ops) - dims := layout.Inset{ - Top: unit.Dp(5), - Bottom: unit.Dp(5), - Left: unit.Dp(10), - Right: unit.Dp(10), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return material.Label(th, unit.Dp(20), t.Title).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D { - return material.Body1(th, t.Summary).Layout(gtx) - }) - }), - ) - }) - call := macro.Stop() - layout.Stack{}.Layout( - gtx, - layout.Stacked(func(gtx C) D { - return Rect{ - Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, - Size: layout.FPt(image.Point{ - X: gtx.Constraints.Max.X, - Y: dims.Size.Y, - }), - }.Layout(gtx) - - }), - layout.Expanded(func(gtx C) D { - return t.Content.Layout(gtx) - }), - ) - call.Add(gtx.Ops) - return dims -} - -func (t *Ticket) bottomBar(gtx C, th *material.Theme, sz image.Point, c color.NRGBA) D { - return layout.Stack{}.Layout( - gtx, - layout.Expanded(func(gtx C) D { - return Rect{ - Color: c, - Size: layout.FPt(sz), - }.Layout(gtx) - }), - layout.Stacked(func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - Alignment: layout.Middle, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return layout.Inset{ - Left: unit.Px(10), - }.Layout(gtx, func(gtx C) D { - return material.Label(th, unit.Dp(10), func() string { - d := time.Since(t.Created) - d = d.Round(time.Minute) - h := d / time.Hour - d -= h * time.Hour - m := d / time.Minute - return fmt.Sprintf("%02d:%02d", h, m) - }()).Layout(gtx) - }) - }), - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - return Button( - &t.PrevButton, - WithIcon(icons.BackIcon), - WithSize(unit.Dp(12)), - WithInset(layout.UniformInset(unit.Dp(6))), - WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}), - WithBgColor(c), - ).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return Button( - &t.NextButton, - WithIcon(icons.ForwardIcon), - WithSize(unit.Dp(12)), - WithInset(layout.UniformInset(unit.Dp(6))), - WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}), - WithBgColor(c), - ).Layout(gtx) - }), - ) - }), - ) -} - -func (t *Ticket) sideBar(gtx C, sz image.Point, c color.NRGBA) D { - return layout.Stack{}.Layout( - gtx, - layout.Stacked(func(gtx C) D { - Rect{ - Color: c, - Size: layout.FPt(sz), - }.Layout(gtx) - return D{} - }), - layout.Stacked(func(gtx C) D { - return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return Button( - &t.EditButton, - WithIcon(icons.ContentEdit), - WithSize(unit.Dp(16)), - WithInset(layout.UniformInset(unit.Dp(2))), - WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), - WithBgColor(c), - ).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx C) D { - return Button( - &t.DeleteButton, - WithIcon(icons.ContentDelete), - WithSize(unit.Dp(16)), - WithInset(layout.UniformInset(unit.Dp(2))), - WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), - WithBgColor(c), - ).Layout(gtx) - }) - }), - ) - }) - }), - ) -} - -// TicketDetails renders the read-only long form details of a ticket. -type TicketDetails struct { - kanban.Ticket - Edit widget.Clickable - Cancel widget.Clickable -} - -func (t *TicketDetails) Layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return material.Body1(th, t.Summary).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return material.Body1(th, t.Details).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{ - Top: unit.Dp(10), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - }.Layout( - gtx, - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - btn := material.Button(th, &t.Cancel, "Cancel") - btn.Color = th.Fg - btn.Background = color.NRGBA{} - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} - }), - layout.Rigid(func(gtx C) D { - return material.Button(th, &t.Edit, "Edit").Layout(gtx) - }), - ) - }) - }), - ) -} - -// ProjectForm renders a form for manipulating projects. -type ProjectForm struct { - Name component.TextField - Submit widget.Clickable - Cancel widget.Clickable -} - -func (form *ProjectForm) Layout(gtx C, th *material.Theme) D { - return layout.Flex{ - Axis: layout.Vertical, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return form.Name.Layout(gtx, th, "Project Name") - }), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{ - Top: unit.Dp(10), - }.Layout(gtx, func(gtx C) D { - return layout.Flex{ - Axis: layout.Horizontal, - }.Layout( - gtx, - layout.Flexed(1, func(gtx C) D { - return D{Size: gtx.Constraints.Min} - }), - layout.Rigid(func(gtx C) D { - btn := material.Button(th, &form.Cancel, "Cancel") - btn.Color = th.Fg - btn.Background = color.NRGBA{} - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} - }), - layout.Rigid(func(gtx C) D { - return material.Button(th, &form.Submit, "Submit").Layout(gtx) - }), - ) - }) - }), - ) -} diff --git a/cmd/kanban/map.go b/cmd/kanban/map.go @@ -1,76 +0,0 @@ -package main - -import "unsafe" - -// Map of arbitrary data to hold unordered state for `layout.List` items. -// This allows Gio programs to re-use a buffer of states for lists items in -// between frames. It is a grow-only buffer that expects entries to stabilise. -// -// This is designed along 2 constraints: -// 1. Performance -// 2. Type ambiguity -// -// Since Go doesn't have generics, I decided to give the caller type control -// by using `unsafe.Pointer`. -// -// The caller only has to ensure that the type they initialise it with is the -// type they attempt to cast out of it. -// Since the scope of use is small, this invariant is straightforward to uphold. -// -// Nonetheless, this style of API is primarily motivated by re-use concerns when -// using common patterns in Gio (specifically `layout.List` state management). -// The static approach would be to copy-paste the same structures with different -// types every time you have list state to manage. -// -// In light of Go generics incoming, this may become a moot issue. In the meantime -// this remains an experimental API that functions as expected. -type Map struct { - data map[string]unsafe.Pointer - index []string - current int -} - -// Begin prepares the map to be accessed. -// Require to reset iteration state each frame. -func (m *Map) Begin() { - m.current = 0 - if m.data == nil { - m.data = make(map[string]unsafe.Pointer) - } -} - -// New returns a value for the provided key. -// In the case no value exists, the initializer is used as the default value. -// The initializer is the value that will be returned from the map. -// Take care when casting it. -// -// v := (*T)(m.New("foo", &T{})) -// -func (m *Map) New(k string, init unsafe.Pointer) unsafe.Pointer { - if _, ok := m.data[k]; !ok { - m.data[k] = init - m.index = append(m.index, k) - } - return m.data[k] -} - -// Next iterates over the collection, returning the key-value pair. -// -// for key, value := m.Next(); m.More(); key, value = m.Next() { -// t := (*T)(v) -// } -// -func (m *Map) Next() (key string, value unsafe.Pointer) { - if m.current >= len(m.index) { - return key, value - } - defer func() { m.current++ }() - key = m.index[m.current] - value = m.data[key] - return key, value -} - -// More reports whether there is more data to iterate. -func (m *Map) More() bool { - return m.current <= len(m.index)-1 -} diff --git a/cmd/kanban/state/map.go b/cmd/kanban/state/map.go @@ -0,0 +1,76 @@ +package state + +import "unsafe" + +// Map of arbitrary data to hold unordered state for `layout.List` items. +// This allows Gio programs to re-use a buffer of states for lists items in +// between frames. It is a grow-only buffer that expects entries to stabilise. +// +// This is designed along 2 constraints: +// 1. Performance +// 2. Type ambiguity +// +// Since Go doesn't have generics, I decided to give the caller type control +// by using `unsafe.Pointer`. +// +// The caller only has to ensure that the type they initialise it with is the +// type they attempt to cast out of it. +// Since the scope of use is small, this invariant is straightforward to uphold. +// +// Nonetheless, this style of API is primarily motivated by re-use concerns when +// using common patterns in Gio (specifically `layout.List` state management). +// The static approach would be to copy-paste the same structures with different +// types every time you have list state to manage. +// +// In light of Go generics incoming, this may become a moot issue. In the meantime +// this remains an experimental API that functions as expected. +type Map struct { + data map[string]unsafe.Pointer + index []string + current int +} + +// Begin prepares the map to be accessed. +// Require to reset iteration state each frame. +func (m *Map) Begin() { + m.current = 0 + if m.data == nil { + m.data = make(map[string]unsafe.Pointer) + } +} + +// New returns a value for the provided key. +// In the case no value exists, the initializer is used as the default value. +// The initializer is the value that will be returned from the map. +// Take care when casting it. +// +// v := (*T)(m.New("foo", &T{})) +// +func (m *Map) New(k string, init unsafe.Pointer) unsafe.Pointer { + if _, ok := m.data[k]; !ok { + m.data[k] = init + m.index = append(m.index, k) + } + return m.data[k] +} + +// Next iterates over the collection, returning the key-value pair. +// +// for key, value := m.Next(); m.More(); key, value = m.Next() { +// t := (*T)(v) +// } +// +func (m *Map) Next() (key string, value unsafe.Pointer) { + if m.current >= len(m.index) { + return key, value + } + defer func() { m.current++ }() + key = m.index[m.current] + value = m.data[key] + return key, value +} + +// More reports whether there is more data to iterate. +func (m *Map) More() bool { + return m.current <= len(m.index)-1 +} diff --git a/cmd/kanban/util/util.go b/cmd/kanban/util/util.go @@ -0,0 +1,141 @@ +package util + +import ( + "image" + "image/color" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +// Rect creates a rectangle of the provided background color with +// Dimensions specified by size and a corner radius (on all corners) +// specified by radii. +type Rect struct { + Color color.NRGBA + Size f32.Point + Radii float32 +} + +// Layout renders the Rect into the provided context +func (r Rect) Layout(gtx C) D { + paint.FillShape( + gtx.Ops, + r.Color, + clip.UniformRRect( + f32.Rectangle{Max: r.Size}, + r.Radii, + ).Op(gtx.Ops), + ) + return layout.Dimensions{ + Size: image.Pt(int(r.Size.X), int(r.Size.Y)), + } +} + +// Button renders a clickable button. +func Button( + state *widget.Clickable, + opt ...ButtonOption, +) ButtonStyle { + btn := ButtonStyle{} + btn.IconButtonStyle.Button = state + btn.ButtonStyle.Button = state + for _, opt := range opt { + opt(&btn) + } + return btn +} + +// ButtonStyle provides a unified api for both icon buttons and text buttons. +type ButtonStyle struct { + material.IconButtonStyle + material.ButtonStyle +} + +func (btn ButtonStyle) Layout(gtx C) D { + if btn.Icon != nil { + return btn.IconButtonStyle.Layout(gtx) + } + return btn.ButtonStyle.Layout(gtx) +} + +type ButtonOption func(*ButtonStyle) + +func WithSize(sz unit.Value) ButtonOption { + return func(btn *ButtonStyle) { + btn.Size = sz + } +} + +func WithIconColor(c color.NRGBA) ButtonOption { + return func(btn *ButtonStyle) { + btn.IconButtonStyle.Color = c + btn.ButtonStyle.Color = c + } +} + +func WithBgColor(c color.NRGBA) ButtonOption { + return func(btn *ButtonStyle) { + btn.IconButtonStyle.Background = c + btn.ButtonStyle.Background = c + } +} + +func WithIcon(icon *widget.Icon) ButtonOption { + return func(btn *ButtonStyle) { + btn.Icon = icon + } +} + +// func WithText(txt string) ButtonOption { +// return func(btn *ButtonStyle) { +// btn.Text = txt +// } +// } + +func WithInset(inset layout.Inset) ButtonOption { + return func(btn *ButtonStyle) { + btn.IconButtonStyle.Inset = inset + btn.ButtonStyle.Inset = inset + } +} + +// Div is a visual divider: a colored line with a thickness. +type Div struct { + Thickness unit.Value + Length unit.Value + Axis layout.Axis + Color color.NRGBA +} + +func (d Div) Layout(gtx C) D { + // Draw a line as a very thin rectangle. + var sz image.Point + switch d.Axis { + case layout.Horizontal: + sz = image.Point{ + X: gtx.Px(d.Length), + Y: gtx.Px(d.Thickness), + } + case layout.Vertical: + sz = image.Point{ + X: gtx.Px(d.Thickness), + Y: gtx.Px(d.Length), + } + } + return Rect{ + Color: d.Color, + Size: layout.FPt(sz), + Radii: 0, + }.Layout(gtx) +} diff --git a/cmd/kanban/widget.go b/cmd/kanban/widget.go @@ -1,212 +0,0 @@ -package main - -import ( - "image" - "image/color" - "unsafe" - - "gioui.org/f32" - "gioui.org/layout" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" -) - -// Rect creates a rectangle of the provided background color with -// Dimensions specified by size and a corner radius (on all corners) -// specified by radii. -type Rect struct { - Color color.NRGBA - Size f32.Point - Radii float32 -} - -// Layout renders the Rect into the provided context -func (r Rect) Layout(gtx C) D { - paint.FillShape( - gtx.Ops, - r.Color, - clip.UniformRRect( - f32.Rectangle{Max: r.Size}, - r.Radii, - ).Op(gtx.Ops), - ) - return layout.Dimensions{ - Size: image.Pt(int(r.Size.X), int(r.Size.Y)), - } -} - -// Button renders a clickable button. -func Button( - state *widget.Clickable, - opt ...ButtonOption, -) ButtonStyle { - btn := ButtonStyle{} - btn.IconButtonStyle.Button = state - btn.ButtonStyle.Button = state - for _, opt := range opt { - opt(&btn) - } - return btn -} - -// ButtonStyle provides a unified api for both icon buttons and text buttons. -type ButtonStyle struct { - material.IconButtonStyle - material.ButtonStyle -} - -func (btn ButtonStyle) Layout(gtx C) D { - if btn.Icon != nil { - return btn.IconButtonStyle.Layout(gtx) - } - return btn.ButtonStyle.Layout(gtx) -} - -type ButtonOption func(*ButtonStyle) - -func WithSize(sz unit.Value) ButtonOption { - return func(btn *ButtonStyle) { - btn.Size = sz - } -} - -func WithIconColor(c color.NRGBA) ButtonOption { - return func(btn *ButtonStyle) { - btn.IconButtonStyle.Color = c - btn.ButtonStyle.Color = c - } -} - -func WithBgColor(c color.NRGBA) ButtonOption { - return func(btn *ButtonStyle) { - btn.IconButtonStyle.Background = c - btn.ButtonStyle.Background = c - } -} - -func WithIcon(icon *widget.Icon) ButtonOption { - return func(btn *ButtonStyle) { - btn.Icon = icon - } -} - -// func WithText(txt string) ButtonOption { -// return func(btn *ButtonStyle) { -// btn.Text = txt -// } -// } - -func WithInset(inset layout.Inset) ButtonOption { - return func(btn *ButtonStyle) { - btn.IconButtonStyle.Inset = inset - btn.ButtonStyle.Inset = inset - } -} - -// Rail is an interactive side rail with a list of widget items that can be -// selected. -// -// Typically used as navigation or contextual actions. -// -// Rail is stateful. -type Rail struct { - layout.List - Map Map -} - -// RailChild is an item that renders in a rail. -type RailChild struct { - Name string - W layout.Widget -} - -// Destination is a rail item that represents a navigatable object. -// Destinations are pab+dded by default. -func Destination(name string, w layout.Widget) RailChild { - return RailChild{ - Name: name, - W: w, - } -} - -func (r *Rail) next(key string) *widget.Clickable { - return (*widget.Clickable)(r.Map.New(key, unsafe.Pointer(&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() { - if (*widget.Clickable)(v).Clicked() { - return k, true - } - } - return "", false -} - -// Layout the rail with the given items. -func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D { - r.List.Axis = layout.Vertical - r.List.Alignment = layout.Middle - r.Map.Begin() - return layout.Flex{ - Axis: layout.Vertical, - Alignment: layout.Middle, - }.Layout( - gtx, - layout.Rigid(func(gtx C) D { - return action(gtx) - }), - layout.Rigid(func(gtx C) D { - return layout.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D { - return Div{ - Color: color.NRGBA{R: 220, B: 220, G: 220, A: 255}, - Length: unit.Px(float32(gtx.Constraints.Max.X)), - Thickness: unit.Dp(1), - Axis: layout.Horizontal, - }.Layout(gtx) - }) - }), - layout.Rigid(func(gtx C) D { - return r.List.Layout(gtx, len(items), func(gtx C, ii int) D { - rc := items[ii] - return material.Clickable(gtx, r.next(rc.Name), func(gtx C) D { - return rc.W(gtx) - }) - }) - }), - ) -} - -// Div is a visual divider: a colored line with a thickness. -type Div struct { - Thickness unit.Value - Length unit.Value - Axis layout.Axis - Color color.NRGBA -} - -func (d Div) Layout(gtx C) D { - // Draw a line as a very thin rectangle. - var sz image.Point - switch d.Axis { - case layout.Horizontal: - sz = image.Point{ - X: gtx.Px(d.Length), - Y: gtx.Px(d.Thickness), - } - case layout.Vertical: - sz = image.Point{ - X: gtx.Px(d.Thickness), - Y: gtx.Px(d.Length), - } - } - return Rect{ - Color: d.Color, - Size: layout.FPt(sz), - Radii: 0, - }.Layout(gtx) -} diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go @@ -0,0 +1,451 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "time" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "git.sr.ht/~jackmordaunt/kanban" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" + "git.sr.ht/~jackmordaunt/kanban/icons" +) + +// TicketForm renders the form for ticket information. +// +// @Todo use form pattern from avisha. +type TicketForm struct { + *kanban.Ticket + Stage string + Title component.TextField + Summary component.TextField + Details component.TextField + SubmitBtn widget.Clickable + CancelBtn widget.Clickable +} + +func (f *TicketForm) Set(t *kanban.Ticket) { + f.Ticket = t + f.Title.SetText(t.Title) + f.Summary.SetText(t.Summary) + f.Details.SetText(t.Details) +} + +// Submit validates inputs and writes to the ticket. +// @Todo validation. +func (f TicketForm) Submit() error { + *f.Ticket = kanban.Ticket{ + Title: f.Title.Text(), + Summary: f.Summary.Text(), + Details: f.Details.Text(), + } + return nil +} + +func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D { + form.Stage = stage + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return form.Title.Layout(gtx, th, "Title") + }), + layout.Rigid(func(gtx C) D { + return form.Summary.Layout(gtx, th, "Summary") + }), + layout.Rigid(func(gtx C) D { + return form.Details.Layout(gtx, th, "Details") + }), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: unit.Dp(10), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th, &form.CancelBtn, "Cancel") + btn.Color = th.Bg + btn.Background = color.NRGBA{} + return btn.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} + }), + layout.Rigid(func(gtx C) D { + return material.Button(th, &form.SubmitBtn, "Submit").Layout(gtx) + }), + ) + }) + }), + ) +} + +// ProjectForm renders a form for manipulating projects. +type ProjectForm struct { + Name component.TextField + Submit widget.Clickable + Cancel widget.Clickable +} + +func (form *ProjectForm) Layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return form.Name.Layout(gtx, th, "Project Name") + }), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: unit.Dp(10), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th, &form.Cancel, "Cancel") + btn.Color = th.Fg + btn.Background = color.NRGBA{} + return btn.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} + }), + layout.Rigid(func(gtx C) D { + return material.Button(th, &form.Submit, "Submit").Layout(gtx) + }), + ) + }) + }), + ) +} + +// DeleteDialog prompts the user with an option to delete a ticket. +type DeleteDialog struct { + kanban.Ticket + Ok widget.Clickable + Cancel widget.Clickable +} + +func (d *DeleteDialog) Layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return material.Body1( + th, + fmt.Sprintf("Are you sure you want to delete ticket %q?", d.Title), + ).Layout(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: unit.Dp(10), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th, &d.Cancel, "Cancel") + btn.Color = th.Bg + btn.Background = color.NRGBA{} + return btn.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th, &d.Ok, "Delete") + btn.Background = color.NRGBA{R: 200, A: 255} + return btn.Layout(gtx) + }), + ) + }) + }), + ) +} + +// Ticket renders a ticket control. +type Ticket struct { + kanban.Ticket + Stage string + NextButton widget.Clickable + PrevButton widget.Clickable + EditButton widget.Clickable + DeleteButton widget.Clickable + Content widget.Clickable +} + +// Layout the ticket card. +// +// The layouting here was actually quite tricky because `layout.List` simulates +// an infinite Y axis. That means you can just specify a max Y constraint. This +// makes expanding stacked content vertically impossible with a naive use of +// `layout.Stack`. +// +// 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 { + 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)) + ) + return widget.Border{ + Width: unit.Dp(0.5), + Color: color.NRGBA{A: 200}, + }.Layout(gtx, func(gtx C) D { + dims := layout.Inset{ + Left: unit.Dp(25), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.Y = minContentSize + return t.content(gtx, th) + }), + layout.Rigid(func(gtx C) D { + return t.bottomBar( + gtx, + th, + image.Point{ + X: gtx.Constraints.Max.X, + Y: gtx.Px(barThickness), + }, + bottomBarColor, + ) + }), + ) + }) + t.sideBar( + gtx, + image.Point{ + X: gtx.Px(barThickness), + Y: dims.Size.Y, + }, + sideBarColor, + ) + return dims + }) +} + +func (t *Ticket) content(gtx C, th *material.Theme) D { + macro := op.Record(gtx.Ops) + dims := layout.Inset{ + Top: unit.Dp(5), + Bottom: unit.Dp(5), + Left: unit.Dp(10), + Right: unit.Dp(10), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return material.Label(th, unit.Dp(20), t.Title).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D { + return material.Body1(th, t.Summary).Layout(gtx) + }) + }), + ) + }) + call := macro.Stop() + layout.Stack{}.Layout( + gtx, + layout.Stacked(func(gtx C) D { + return util.Rect{ + Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + Size: layout.FPt(image.Point{ + X: gtx.Constraints.Max.X, + Y: dims.Size.Y, + }), + }.Layout(gtx) + + }), + layout.Expanded(func(gtx C) D { + return t.Content.Layout(gtx) + }), + ) + call.Add(gtx.Ops) + return dims +} + +func (t *Ticket) bottomBar(gtx C, th *material.Theme, sz image.Point, c color.NRGBA) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return util.Rect{ + Color: c, + Size: layout.FPt(sz), + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Left: unit.Px(10), + }.Layout(gtx, func(gtx C) D { + return material.Label(th, unit.Dp(10), func() string { + d := time.Since(t.Created) + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + return fmt.Sprintf("%02d:%02d", h, m) + }()).Layout(gtx) + }) + }), + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + return util.Button( + &t.PrevButton, + util.WithIcon(icons.BackIcon), + util.WithSize(unit.Dp(12)), + util.WithInset(layout.UniformInset(unit.Dp(6))), + util.WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}), + util.WithBgColor(c), + ).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return util.Button( + &t.NextButton, + util.WithIcon(icons.ForwardIcon), + util.WithSize(unit.Dp(12)), + util.WithInset(layout.UniformInset(unit.Dp(6))), + util.WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}), + util.WithBgColor(c), + ).Layout(gtx) + }), + ) + }), + ) +} + +func (t *Ticket) sideBar(gtx C, sz image.Point, c color.NRGBA) D { + return layout.Stack{}.Layout( + gtx, + layout.Stacked(func(gtx C) D { + util.Rect{ + Color: c, + Size: layout.FPt(sz), + }.Layout(gtx) + return D{} + }), + layout.Stacked(func(gtx C) D { + return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return util.Button( + &t.EditButton, + util.WithIcon(icons.ContentEdit), + util.WithSize(unit.Dp(16)), + util.WithInset(layout.UniformInset(unit.Dp(2))), + util.WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), + util.WithBgColor(c), + ).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx C) D { + return util.Button( + &t.DeleteButton, + util.WithIcon(icons.ContentDelete), + util.WithSize(unit.Dp(16)), + util.WithInset(layout.UniformInset(unit.Dp(2))), + util.WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), + util.WithBgColor(c), + ).Layout(gtx) + }) + }), + ) + }) + }), + ) +} + +// TicketDetails renders the read-only long form details of a ticket. +type TicketDetails struct { + kanban.Ticket + Edit widget.Clickable + Cancel widget.Clickable +} + +func (t *TicketDetails) Layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return material.Body1(th, t.Summary).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return material.Body1(th, t.Details).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: unit.Dp(10), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th, &t.Cancel, "Cancel") + btn.Color = th.Fg + btn.Background = color.NRGBA{} + return btn.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}} + }), + layout.Rigid(func(gtx C) D { + return material.Button(th, &t.Edit, "Edit").Layout(gtx) + }), + ) + }) + }), + ) +} diff --git a/go.mod b/go.mod @@ -6,9 +6,6 @@ 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/jackmordaunt/icns v1.0.0 - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/pkg/errors v0.9.1 // indirect 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 @@ -21,17 +21,11 @@ github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ= -github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= diff --git a/kanban.go b/kanban.go @@ -3,277 +3,233 @@ package kanban import ( "errors" "fmt" - "strconv" + "strings" "time" "github.com/asdine/storm/v3" ) -// Kanban manipulates the model. -type Kanban struct { - // Data access layer for querying and mutating data. - Store *storm.DB +// Project +// - represents some project that can be broken down into to discrete tasks, described by a name +// - each project has it's own arbitrary pipeline of stages with which tickets move through left-to-right +// - contains an ordered list of stages +// - stages are re-orderable +// - can be renamed +// - can be deleted +// +// Stage +// - represents an important part in the lifecycle of a task, described by a name +// - contains an ordered list of tickets +// - tickets are re-orderable +// - tickets can advance back and forth between stages, typically linearly +// - can be renamed +// - can be deleted +// +// Ticket +// - contains information about a task for a project +// - is unique to a Project and sits within one of it's stages +// - cannot occupy more than one stage +// - can be edited +// - can be deleted + +type IProject interface { + MakeStage(stage string) + ListStages() []Stage + MoveStage(stage string, dir Direction) bool + AssignTicket(stage string, t Ticket) + ProgressTicket(t Ticket) + RegressTicket(t Ticket) + ListTickets(stage string) []Ticket + MoveTicket(t Ticket, dir Direction) bool + FinalizeTicket(t Ticket) } -// ID is a unique identifier encoded as an integer. -type ID int - -// Entity is unique schema object that changes over time. -type Entity struct { - ID ID `storm:"id,index,increment"` - Created time.Time +// Storage handles serialization of Project entities. +type Storer interface { + Create(p *Project) error + Save(p *Project) error + Load(name string) (*Project, bool, error) + List() ([]*Project, error) } // Project is a context for a given set of tickets. type Project struct { - Entity `storm:"inline"` - Name string `storm:"unique"` - // Stages lists stage IDs in order. - Stages Stages + // Name of project, must be unique. + Name string + // Stages owned by this project. + Stages Stages + Finalized []Ticket } -type Stages []ID +var _ IProject = (*Project)(nil) -func (stages *Stages) Swap(id ID, dir Direction) { - for ii := range *stages { - if (*stages)[ii] == id { - if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 { - return - } - (*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii] - } - } +// MakeStage assigns a ticket to the given stage. +func (p *Project) MakeStage(name string) { + p.Stages = append(p.Stages, Stage{ + Name: name, + }) } -// Stage in the kanban pipeline, can hold a number of tickets. -type Stage struct { - Entity `storm:"inline"` - Name string - // Tickest lists ticket IDs in order. - Tickets []ID // @Todo abstract into "reorderable list", to use with project stage list as well. +func (p *Project) ListStages() []Stage { + return p.Stages } -func (s *Stage) Assign(ticket ID) { - for _, t := range s.Tickets { - if t == ticket { - return +func (p *Project) MoveStage(name string, dir Direction) bool { + return p.Stages.Swap(name, dir) +} + +// AssignTicket assigns a ticket to the given stage. +func (p *Project) AssignTicket(stage string, ticket Ticket) { + p.Stages.Find(stage).Assign(ticket) +} + +// ProgressTicket moves a ticket to the "next" stage. +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)) + break } } - s.Tickets = append(s.Tickets, ticket) } -func (s *Stage) UnAssign(ticket ID) { - for ii, t := range s.Tickets { - if t == ticket { - s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) +// RegressTicket moves a ticket to the "previous" stage. +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)) + break } } } -// FinalisedTicket is an inactive ticket kept for analytic purposes. -type FinalisedTicket = Ticket - -// Ticket in a stage. -type Ticket struct { - Entity `storm:"inline"` - Project ID - Stage ID +// MoveTicket within a stage. +func (p *Project) MoveTicket(ticket Ticket, dir Direction) bool { + // @implement + return false +} - // Title of the ticket. - Title string - // Summary contains short and concise overview of the ticket. - Summary string - // Details contains the full details of the ticket. - Details string +func (p *Project) ListTickets(stage string) []Ticket { + return p.Stages.Find(stage).Tickets } -// ListStages returns a list of stages. -func (k Kanban) ListStages(projectID ID) (stages []Stage, err error) { - var ( - project Project - ) - if err := k.Store.Find("ID", projectID, &project); err != nil { - return nil, fmt.Errorf("loading project: %v", err) - } - for _, stageID := range project.Stages { - var ( - stage Stage - ) - if err := k.Store.Find("ID", stageID, &stage); err != nil { - return stages, fmt.Errorf("loading stage: %v", err) +// StageForTicket returns the stage containing the specified ticket. +func (p *Project) StageForTicket(ticket Ticket) *Stage { + for ii, s := range p.Stages { + if s.Contains(ticket) { + return &p.Stages[ii] } - stages = append(stages, stage) } - return stages, nil + return &Stage{} } -// NextStage returns the stage that follows the specified one. -func (k Kanban) NextStage(projectID ID, current ID) (string, error) { - stage, err := k.NextStageForDirection(projectID, current, Forward) - return stage.Name, err +// FinalizeTicket renders the ticket "complete" ad moves it into an archive. +func (p *Project) FinalizeTicket(t Ticket) { + for _, s := range p.Stages { + if s.Contains(t) { + s.UnAssign(t) + p.Finalized = append(p.Finalized, t) + break + } + } } -// NextStage returns the stage that preceeds the specified one. -func (k Kanban) PreviousStage(projectID ID, current ID) (string, error) { - stage, err := k.NextStageForDirection(projectID, current, Backward) - return stage.Name, err +// Stage in the kanban pipeline, can hold a number of tickets. +type Stage struct { + Name string + Tickets []Ticket } -// NextStageForDirection gets the next stage in the given direction. -func (k Kanban) NextStageForDirection(projectID ID, current ID, dir Direction) (next Stage, err error) { - var ( - project Project - ) - if err := k.Store.Find("ID", projectID, &project); err != nil { - return Stage{}, fmt.Errorf("finding project: %v", err) - } - for ii, stage := range project.Stages { - if stage == current { - // @Todo bounds check. - return next, k.Store.Find("ID", project.Stages[ii+dir.Next()], &next) +// Assign appends a ticket id to the stage. +func (s *Stage) Assign(ticket Ticket) { + for _, t := range s.Tickets { + if t == ticket { + return } } - return next, err + s.Tickets = append(s.Tickets, ticket) } -// MoveStage moves a stage one place in the given direction. -func (k Kanban) MoveStage(projectID ID, id ID, dir Direction) error { - var ( - project Project - ) - if err := k.Store.Find("ID", projectID, &project); err != nil { - return fmt.Errorf("finding project: %v", err) - } - project.Stages.Swap(id, dir) - if err := k.Store.Save(&project); err != nil { - return fmt.Errorf("saving project: %v", err) +// UnAssign removes a ticket id from the stage. +func (s *Stage) UnAssign(ticket Ticket) { + for ii, t := range s.Tickets { + if t == ticket { + s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) + } } - return nil } -// Stage returns a stage by the given name. -// Creates an empty stage if it doesn't exist. -func (k *Kanban) Stage(name string) (stage Stage, err error) { - err = k.Store.Find("Name", name, &stage) - if errors.Is(err, storm.ErrNotFound) { - if err := k.Store.Save(&stage); err != nil { - return stage, err - } - return k.Stage(name) - } - return stage, err -} - -// Move a ticket to the specified stage. -// Assigns to the bottom of the target stage. -func (k *Kanban) Move(stageID ID, ticketID ID) error { - var ( - ticket Ticket - currentStage Stage - targetStage Stage - ) - if err := k.Store.Find("ID", ticketID, &ticket); err != nil { - return err - } - if err := k.Store.Find("ID", ticket.Stage, &currentStage); err != nil { - return err - } - if err := k.Store.Find("ID", stageID, &targetStage); err != nil { - return err - } - ticket.Stage = targetStage.ID - currentStage.UnAssign(ticketID) - targetStage.Assign(ticketID) - if err := k.Store.Save(&ticket); err != nil { - return err - } - if err := k.Store.Save(&currentStage); err != nil { - return err +// Stages is a list of Stage. +type Stages []Stage + +// Swap the specified stage in the given direction. +// Returns false when at a boundary, and therefore no swap can occur. +func (stages *Stages) Swap(stage string, dir Direction) bool { + ii, ok := stages.Index(stage) + if !ok { + return false } - if err := k.Store.Save(&targetStage); err != nil { - return err + if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 { + return false } - return nil + (*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii] + return true } -// Progress a ticket to the next stage. -func (k *Kanban) Progress(ticketID ID) error { - var ( - ticket Ticket - project Project - stageID ID - ) - if err := k.Store.Find("ID", ticketID, &ticket); err != nil { - return err - } - if err := k.Store.Find("ID", ticket.Project, &project); err != nil { - return err - } - for ii, id := range project.Stages { - if id == ticket.Stage { - stageID = project.Stages[ii+1] +// Find stage by name. +func (stages *Stages) Find(name string) *Stage { + for ii, s := range *stages { + if s.Name == name { + return &(*stages)[ii] } } - return k.Move(stageID, ticket.ID) -} - -// Regress a ticket to the previous stage. -func (k *Kanban) Regress(ticketID ID) error { - var ( - ticket Ticket - project Project - stageID ID - ) - if err := k.Store.Find("ID", ticketID, &ticket); err != nil { - return err - } - if err := k.Store.Find("ID", ticket.Project, &project); err != nil { - return err - } - for ii, id := range project.Stages { - if id == ticket.Stage { - stageID = project.Stages[ii-1] + return &Stage{} +} + +// Index returns the index postition for the stage, false if no stage exists. +func (stages *Stages) Index(name string) (int, bool) { + for ii, s := range *stages { + if s.Name == name { + return ii, true } } - return k.Move(stageID, ticket.ID) + return 0, false } -// Assign a ticket to a stage. -func (k *Kanban) Assign(name string, ticket Ticket) error { - var ( - stage Stage - ) - if err := k.Store.Find("Name", name, &stage); err != nil { - return fmt.Errorf("finding stage %q: %v", name, err) - } - stage.Assign(ticket.ID) - ticket.Stage = stage.ID - if err := k.Store.Save(&ticket); err != nil { - return fmt.Errorf("saving ticket: %v", err) - } - if err := k.Store.Update(&stage); err != nil { - return fmt.Errorf("saving stage: %v", err) +// 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 nil + return Ticket{} } -// Finalize a ticket. -// Either the ticket was completed, made irrelevant, or faulty in some manner. -func (k *Kanban) Finalize(ticketID ID) error { - var ( - ticket Ticket - ) - if err := k.Store.Find("ID", ticketID, &ticket); err != nil { - return fmt.Errorf("ticket not exist: %v", err) - } - if err := k.Store.DeleteStruct(&ticket); err != nil { - return fmt.Errorf("deleting active ticket: %v", err) +// Contains returns true if the specified ticket exists in the stage. +func (s *Stage) Contains(ticket Ticket) bool { + for _, t := range s.Tickets { + if t == ticket { + return true + } } - return k.Store.Save(FinalisedTicket(ticket)) + return false } -func (k *Kanban) Update(ticket Ticket) error { - return k.Store.Update(&ticket) +// Ticket in a stage. +type Ticket struct { + // Title of the ticket. + Title string + // Summary contains short and concise overview of the ticket. + Summary string + // Details contains the full details of the ticket. + Details string + // Created when the ticket was created. + Created time.Time } // Direction encodes mutually exclusive directions. @@ -306,11 +262,89 @@ func (dir Direction) Invert() Direction { return dir } -// None reports whether the ID represents a valid entity or is a zero value. -func (id ID) None() bool { - return id < 1 +// MapStorer implements in-memory storage for Projects. +type MapStorer struct { + Data map[string]Project + Err error +} + +var _ Storer = (*MapStorer)(nil) + +func (s *MapStorer) Create(p *Project) error { + if len(strings.TrimSpace(p.Name)) == 0 { + return fmt.Errorf("project name required") + } + if _, ok := s.Data[p.Name]; ok { + return fmt.Errorf("project %q exists", p.Name) + } + s.Data[p.Name] = *p + return nil +} + +func (s *MapStorer) Save(p *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 *MapStorer) Load(name string) (*Project, bool, error) { + if p, ok := s.Data[name]; ok { + return &p, ok, nil + } + return nil, false, nil +} + +func (s *MapStorer) List() (list []*Project, err error) { + for _, p := range s.Data { + list = append(list, &p) + } + return list, nil +} + +// StormStorer implements Project storage using storm db. +type StormStorer struct { + DB *storm.DB +} + +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)) +} + +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)) +} + +func (s *StormStorer) Load(name string) (*Project, bool, error) { + var p Project + if err := s.DB.Find("Name", name, &p); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return &p, false, nil + } else { + return &p, false, err + } + } + return &p, true, nil } -func (id ID) String() string { - return strconv.Itoa(int(id)) +func (s *StormStorer) List() (list []*Project, err error) { + return list, s.DB.All(&list) }