kanban

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

commit def222fd39720544427000bf6ea72c0ddd6241cc
parent 6c05603f522ff84f681f92b7c6b0b4c724542f2d
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Tue,  3 Nov 2020 17:41:55 +0800

feat: move tickets between stages

Diffstat:
Mcmd/kanban/main.go | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Micons/icons.go | 5+++++
Mkanban.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
3 files changed, 216 insertions(+), 46 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -6,6 +6,7 @@ import ( "image/color" "log" "os" + "unsafe" "git.sr.ht/~jackmordaunt/kanban" @@ -31,7 +32,22 @@ func main() { ui := &UI{ Window: w, Th: th, - Engine: &kanban.Engine{}, + Engine: &kanban.Engine{ + Stages: []kanban.Stage{ + { + Name: "Todo", + }, + { + Name: "In Progress", + }, + { + Name: "Testing", + }, + { + Name: "Done", + }, + }, + }, // TODO: render dynamically from storage. Panels: []Panel{ { @@ -75,12 +91,12 @@ type ( // this object. type UI struct { *app.Window - Engine *kanban.Engine - Th *material.Theme - Panels []Panel - Tickets []Ticket - Modal layout.Widget - TicketForm TicketForm + Engine *kanban.Engine + Th *material.Theme + Panels []Panel + TicketStates Map + Modal layout.Widget + TicketForm TicketForm } func (ui *UI) Loop() error { @@ -111,6 +127,37 @@ func (ui *UI) Update(gtx C) { } } } + for _, state := range ui.TicketStates.List() { + state := (*Ticket)(state) + if state.NextButton.Clicked() { + if err := func() error { + next, err := ui.Engine.NextStage(state.Stage) + if err != nil { + return fmt.Errorf("getting next stage: %w", err) + } + if err := ui.Engine.Move(next, state.Ticket.ID); err != nil { + return fmt.Errorf("moving ticket: %s", err) + } + return nil + }(); err != nil { + fmt.Printf("error: %s\n", err) + } + } + if state.PrevButton.Clicked() { + if err := func() error { + next, err := ui.Engine.PreviousStage(state.Stage) + if err != nil { + return fmt.Errorf("getting previous stage: %w", err) + } + if err := ui.Engine.Move(next, state.Ticket.ID); err != nil { + return fmt.Errorf("moving ticket: %s", err) + } + return nil + }(); err != nil { + fmt.Printf("error: %s\n", err) + } + } + } if ui.TicketForm.Submit.Clicked() { ticket, err := ui.TicketForm.Validate() if err != nil { @@ -134,18 +181,20 @@ func (ui *UI) Layout(gtx C) D { return layout.Stack{}.Layout( gtx, layout.Stacked(func(gtx C) D { + ui.TicketStates.Begin() var panels = make([]layout.FlexChild, len(ui.Panels)) - for ii := range ui.Panels { - panel := &ui.Panels[ii] - panels[ii] = layout.Flexed(1, func(gtx C) D { - panel := panel + for kk := range ui.Panels { + panel := &ui.Panels[kk] + panels[kk] = layout.Flexed(1, func(gtx C) D { stage, _ := ui.Engine.Stage(panel.Label) - var cards = make([]layout.Widget, len(stage.Tickets)) + var cards = make([]layout.ListElement, len(stage.Tickets)) for ii, ticket := range stage.Tickets { - ticket := ticket - cards[ii] = func(gtx C) D { - // TODO: ticket state needs to live somewhere. - return (&TicketStyle{Ticket: ticket}).Layout(gtx, ui.Th) + id := ticket.ID.String() + cards[ii] = func(gtx C, ii int) D { + t := (*Ticket)(ui.TicketStates.Next(id, unsafe.Pointer(&Ticket{}))) + t.Ticket = stage.Tickets[ii] + t.Stage = stage.Name + return t.Layout(gtx, ui.Th) } } return panel.Layout(gtx, ui.Th, cards...) @@ -348,10 +397,11 @@ type Panel struct { Color color.RGBA Thickness unit.Value CreateTicket widget.Clickable + layout.List } -func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D { +func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D { return widget.Border{ Color: color.RGBA{A: 200}, Width: unit.Dp(0.5), @@ -419,7 +469,7 @@ func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D { Width: unit.Dp(0.5), Color: color.RGBA{A: 200}, }.Layout(gtx, func(gtx C) D { - return tickets[ii](gtx) + return tickets[ii](gtx, ii) }) }) }) @@ -430,16 +480,15 @@ func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D { }) } -// TicketStyle renders a ticket control. -type TicketStyle struct { - kanban.Ticket -} - +// Ticket renders a ticket control. type Ticket struct { - MoveButton widget.Clickable + kanban.Ticket + Stage string + NextButton widget.Clickable + PrevButton widget.Clickable } -func (t *TicketStyle) Layout(gtx C, th *material.Theme) D { +func (t *Ticket) Layout(gtx C, th *material.Theme) D { return layout.Flex{ Axis: layout.Horizontal, }.Layout( @@ -492,15 +541,84 @@ func (t *TicketStyle) Layout(gtx C, th *material.Theme) D { }), layout.Rigid(func(gtx C) D { // bottom controls - return Rect{ - Color: color.RGBA{A: 100}, - Size: f32.Point{ - X: float32(gtx.Constraints.Max.X), - Y: float32(gtx.Px(unit.Dp(15))), - }, - }.Layout(gtx) + gtx.Constraints.Max = image.Point{ + X: gtx.Constraints.Max.X, + Y: gtx.Px(unit.Dp(20)), + } + gtx.Constraints.Min = image.Point{ + Y: gtx.Px(unit.Dp(20)), + } + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return Rect{ + Color: color.RGBA{A: 100}, + Size: f32.Point{ + X: layout.FPt(gtx.Constraints.Max).X, + Y: layout.FPt(gtx.Constraints.Min).Y, + }, + }.Layout(gtx) + }), + layout.Stacked(func(gtx C) D { + return layout.UniformInset(unit.Dp(2.5)).Layout(gtx, func(gtx C) D { + inset := layout.Inset{Left: unit.Dp(2), Right: unit.Dp(2)} + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Max} + }), + layout.Flexed(1, func(gtx C) D { + return inset.Layout(gtx, func(gtx C) D { + btn := material.IconButton(th, &t.PrevButton, icons.BackIcon) + btn.Size = unit.Dp(15) + gtx.Constraints.Max.X = gtx.Px(unit.Dp(15)) + return btn.Layout(gtx) + }) + }), + layout.Flexed(1, func(gtx C) D { + return inset.Layout(gtx, func(gtx C) D { + btn := material.IconButton(th, &t.NextButton, icons.ForwardIcon) + btn.Size = unit.Dp(15) + gtx.Constraints.Max.X = gtx.Px(unit.Dp(15)) + return btn.Layout(gtx) + }) + }), + ) + }) + }), + ) }), ) }), ) } + +// Map of arbitrary data. +type Map struct { + data map[string]unsafe.Pointer +} + +func (m *Map) Begin() { + if m.data == nil { + m.data = make(map[string]unsafe.Pointer) + } +} + +func (m *Map) Next(k string, v unsafe.Pointer) unsafe.Pointer { + if _, ok := m.data[k]; !ok { + m.data[k] = v + } + return m.data[k] +} + +func (m *Map) List() []unsafe.Pointer { + list := []unsafe.Pointer{} + for _, v := range m.data { + if v != nil { + list = append(list, v) + } + } + return list +} diff --git a/icons/icons.go b/icons/icons.go @@ -10,6 +10,11 @@ var BackIcon *widget.Icon = func() *widget.Icon { return icon }() +var ForwardIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.NavigationArrowForward) + return icon +}() + var ClearIcon *widget.Icon = func() *widget.Icon { icon, _ := widget.NewIcon(icons.ContentClear) return icon diff --git a/kanban.go b/kanban.go @@ -1,41 +1,83 @@ package kanban +import ( + "fmt" + "strconv" +) + // Kanban engine that drives the model. type Engine struct { - stages []Stage + Stages []Stage +} + +// ListStages returns a list of stages. +func (eng Engine) ListStages() ([]Stage, error) { + return eng.Stages, nil +} + +// NextStage returns the stage that follows the specified one. +func (eng Engine) NextStage(current string) (string, error) { + for ii, stage := range eng.Stages { + if stage.Name == current && ii < len(eng.Stages)-1 { + return eng.Stages[ii+1].Name, nil + } + } + return current, fmt.Errorf("no more stages after: %q", current) } -// Stages returns a list of stages. -func (eng Engine) Stages() ([]Stage, error) { - return eng.stages, nil +// NextStage returns the stage that preceeds the specified one. +func (eng Engine) PreviousStage(current string) (string, error) { + for ii, stage := range eng.Stages { + if stage.Name == current && ii > 0 { + return eng.Stages[ii-1].Name, nil + } + } + return current, fmt.Errorf("no more stages before: %q", current) } // Stage returns a stage by the given name. // Creates an empty stage if it doesn't exist. func (eng *Engine) Stage(name string) (Stage, error) { - for _, stage := range eng.stages { + for _, stage := range eng.Stages { if stage.Name == name { return stage, nil } } - eng.stages = append(eng.stages, Stage{Name: name}) - return eng.stages[len(eng.stages)-1], nil + eng.Stages = append(eng.Stages, Stage{Name: name}) + return eng.Stages[len(eng.Stages)-1], nil } func (eng *Engine) Move(stage string, ticket ID) error { - return nil + var found *Ticket + for ii := range eng.Stages { + stage := eng.Stages[ii] + for kk := range stage.Tickets { + t := stage.Tickets[kk] + if t.ID == ticket { + found = &t + if err := eng.Delete(ticket); err != nil { + return err + } + break + } + } + } + if found == nil { + return fmt.Errorf("ticket %q does not exist", ticket) + } + return eng.Assign(stage, *found) } func (eng *Engine) Assign(stage string, ticket Ticket) error { ticket.ID = eng.nextID() - for ii := range eng.stages { - s := &eng.stages[ii] + for ii := range eng.Stages { + s := &eng.Stages[ii] if s.Name == stage { s.Tickets = append(s.Tickets, ticket) return nil } } - eng.stages = append(eng.stages, Stage{ + eng.Stages = append(eng.Stages, Stage{ Name: stage, Tickets: []Ticket{ticket}, }) @@ -43,7 +85,8 @@ func (eng *Engine) Assign(stage string, ticket Ticket) error { } func (eng *Engine) Delete(ticket ID) error { - for _, s := range eng.stages { + for kk := range eng.Stages { + s := &eng.Stages[kk] for ii := range s.Tickets { if s.Tickets[ii].ID == ticket { s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) @@ -55,7 +98,7 @@ func (eng *Engine) Delete(ticket ID) error { } func (eng *Engine) Update(ticket Ticket) error { - for _, s := range eng.stages { + for _, s := range eng.Stages { for ii := range s.Tickets { if s.Tickets[ii].ID == ticket.ID { s.Tickets[ii] = ticket @@ -68,7 +111,7 @@ func (eng *Engine) Update(ticket Ticket) error { func (eng *Engine) nextID() ID { var max int - for _, stage := range eng.stages { + for _, stage := range eng.Stages { for _, t := range stage.Tickets { if int(t.ID) > max { max = int(t.ID) @@ -95,3 +138,7 @@ type Ticket struct { Details string References []ID } + +func (id ID) String() string { + return strconv.Itoa(int(id)) +}