kanban

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

commit 5f4e99bf4057f718a186e85aa9a0a01674b19c69
parent 9b03d878573d85bec88905adda4375d2077b380f
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Sat, 27 Feb 2021 19:30:54 +0800

ref: use material card as layout primitive

Signed-off-by: Jack Mordaunt <jackmordaunt@gmail.com>

Diffstat:
Mcmd/kanban/control/card.go | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcmd/kanban/ui.go | 30+++++-------------------------
Mcmd/kanban/widgets.go | 297++++++++++++++++++++++++++++++++-----------------------------------------------
3 files changed, 212 insertions(+), 235 deletions(-)

diff --git a/cmd/kanban/control/card.go b/cmd/kanban/control/card.go @@ -1,21 +1,34 @@ package control import ( + "image" "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" ) -// Card lays the content out with a title for context. +// Card implements "https://material.io/components/cards". type Card struct { - Title string + // Media image.Image + Title string + Subtitle string + Body layout.Widget + Actions []Action } -func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D { +type Action struct { + *widget.Clickable + Label string + Fg color.NRGBA + Bg color.NRGBA +} + +func (c Card) Layout(gtx C, th *material.Theme) D { + // @cleanup: spacing strategy is adhoc. return layout.Stack{}.Layout( gtx, layout.Expanded(func(gtx C) D { @@ -26,36 +39,75 @@ func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D { }.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) - }) - }), - ) + return layout.Inset{ + Bottom: unit.Dp(20), + Left: unit.Dp(15), + Right: unit.Dp(15), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: unit.Dp(20), + Bottom: unit.Dp(20), + }.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return material.H5(th, c.Title).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if c.Subtitle == "" { + return D{} + } + return D{Size: image.Point{Y: gtx.Px(unit.Dp(10))}} + }), + layout.Rigid(func(gtx C) D { + if c.Subtitle == "" { + return D{} + } + return material.Body1(th, c.Subtitle).Layout(gtx) + }), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if c.Body == nil { + return D{} + } + return c.Body(gtx) + }), + layout.Rigid(func(gtx C) D { + return D{Size: image.Point{Y: gtx.Px(unit.Dp(20))}} + }), + layout.Rigid(func(gtx C) D { + if len(c.Actions) < 1 { + return D{} + } + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + func() (actions []layout.FlexChild) { + for ii := range c.Actions { + action := &c.Actions[ii] + actions = append(actions, layout.Rigid(func(gtx C) D { + btn := material.Button(th, action.Clickable, action.Label) + btn.Color = action.Fg + btn.Background = action.Bg + return btn.Layout(gtx) + })) + } + return actions + }()..., + ) + }), + ) + }) }), ) } diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go @@ -457,11 +457,7 @@ func (ui *UI) Clear() { func (ui *UI) InspectTicket(t kanban.Ticket) { ui.TicketDetails.Ticket = t ui.Modal = func(gtx C) D { - return control.Card{ - Title: t.Title, - }.Layout(gtx, ui.Th, func(gtx C) D { - return ui.TicketDetails.Layout(gtx, ui.Th) - }) + return ui.TicketDetails.Layout(gtx, ui.Th) } } @@ -469,11 +465,7 @@ func (ui *UI) InspectTicket(t kanban.Ticket) { 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, "") - }) + return ui.TicketForm.Layout(gtx, ui.Th, "") } } @@ -481,11 +473,7 @@ func (ui *UI) EditTicket(t kanban.Ticket) { func (ui *UI) AddTicket(stage string) { ui.TicketForm.Title.Focus() 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) - }) + return ui.TicketForm.Layout(gtx, ui.Th, stage) } } @@ -493,11 +481,7 @@ func (ui *UI) AddTicket(stage string) { func (ui *UI) CreateProject() { ui.ProjectForm.Name.Focus() 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) - }) + return ui.ProjectForm.Layout(gtx, ui.Th) } } @@ -505,11 +489,7 @@ func (ui *UI) CreateProject() { 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) - }) + return ui.DeleteDialog.Layout(gtx, ui.Th) } } diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go @@ -14,8 +14,10 @@ import ( "gioui.org/widget/material" "gioui.org/x/component" "git.sr.ht/~jackmordaunt/kanban" + "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/control" "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util" "git.sr.ht/~jackmordaunt/kanban/icons" + "github.com/google/uuid" ) // TicketForm renders the form for ticket information. @@ -54,47 +56,44 @@ func (f TicketForm) Submit() kanban.Ticket { func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D { form.Stage = stage form.Title.SingleLine = true - 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.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.SubmitBtn, "Submit").Layout(gtx) - }), - ) - }) - }), - ) + return control.Card{ + Title: func() string { + if form.Ticket.ID == uuid.Nil { + return "Add Ticket" + } + return "Edit Ticket" + }(), + Body: func(gtx C) D { + 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") + }), + ) + }, + Actions: []control.Action{ + { + Clickable: &form.SubmitBtn, + Label: "Submit", + Fg: th.ContrastFg, + Bg: th.ContrastBg, + }, + { + Clickable: &form.CancelBtn, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + }, + }.Layout(gtx, th) } // ProjectForm renders a form for manipulating projects. @@ -105,41 +104,33 @@ type ProjectForm struct { } 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) - }), - ) - }) - }), - ) + return control.Card{ + Title: "Create a new Project", + Body: func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return form.Name.Layout(gtx, th, "Project Name") + }), + ) + }, + Actions: []control.Action{ + { + Clickable: &form.Submit, + Label: "Submit", + Fg: th.ContrastFg, + Bg: th.ContrastBg, + }, + { + Clickable: &form.Cancel, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + }, + }.Layout(gtx, th) } // DeleteDialog prompts the user with an option to delete a ticket. @@ -150,49 +141,37 @@ type DeleteDialog struct { } 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.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 { - btn := material.Button(th, &d.Ok, "Delete") - btn.Background = color.NRGBA{R: 200, A: 255} - return btn.Layout(gtx) - }), - ) - }) - }), - ) + return control.Card{ + Title: "Are you sure?", + Body: func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return material.Body1( + th, + fmt.Sprintf("Delete ticket %q?", d.Title), + ).Layout(gtx) + }), + ) + }, + Actions: []control.Action{ + { + Clickable: &d.Ok, + Label: "Delete", + Fg: th.ContrastFg, + Bg: color.NRGBA{R: 200, A: 255}, + }, + { + Clickable: &d.Cancel, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + }, + }.Layout(gtx, th) } // Ticket renders a ticket control. @@ -270,8 +249,6 @@ func (t *Ticket) Layout(gtx C, th *material.Theme, focused bool) D { }) } -// @todo: de-emphasize details content. -// @todo: summarize based on card size rather than content length. func (t *Ticket) content(gtx C, th *material.Theme) D { macro := op.Record(gtx.Ops) dims := layout.Inset{ @@ -289,12 +266,9 @@ func (t *Ticket) content(gtx C, th *material.Theme) D { }), 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) - }) - }), - layout.Rigid(func(gtx C) D { - return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D { - return material.Body1(th, summarize(t.Details, 0)).Layout(gtx) + l := material.Body1(th, t.Summary) + l.Color = component.WithAlpha(l.Color, 200) + return l.Layout(gtx) }) }), ) @@ -429,54 +403,25 @@ type TicketDetails struct { } 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 control.Card{ + Title: t.Title, + Subtitle: t.Summary, + Body: 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) - }), - ) - }) - }), - ) -} - -// summarize truncates string s after n amount of characters, and appends an -// elipsis to indicate missing content. -func summarize(s string, n int) string { - if n == 0 { - n = 70 - } - if len(s) < n { - return s - } - return strings.TrimSpace(s[:n]) + string('\u2026') + }, + Actions: []control.Action{ + { + Clickable: &t.Edit, + Label: "Edit", + Fg: th.ContrastFg, + Bg: th.ContrastBg, + }, + { + Clickable: &t.Cancel, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + }, + }.Layout(gtx, th) }