kanban

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

commit cdb4b35aa8395f42b9878083e03c7f05dad0b75c
parent 40b17b11d9b2508060926717cdbeff4d2feb4dd6
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Mon, 12 Apr 2021 18:16:57 +0800

feat(ux): navigate tickets with keyboard

Diffstat:
Mcmd/kanban/control/rail.go | 2+-
Mcmd/kanban/ui.go | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcmd/kanban/widgets.go | 49++++++++++++++++++++++++++-----------------------
3 files changed, 132 insertions(+), 76 deletions(-)

diff --git a/cmd/kanban/control/rail.go b/cmd/kanban/control/rail.go @@ -30,7 +30,7 @@ type RailChild struct { } // Destination is a rail item that represents a navigatable object. -// Destinations are pab+dded by default. +// Destinations are padded by default. func Destination(name string, w layout.Widget) RailChild { return RailChild{ Name: name, diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go @@ -87,11 +87,12 @@ type UI struct { ProjectForm ProjectForm ArchiveProjectConfirmation ArchiveProjectConfirmation - // FocusedTicket struct { - // ID kanban.ID - // Index int - // Stage kanban.ID - // } + // Focus tracks the focused ticket for keyboard navigation. + Focus struct { + Stage int + Ticket int + T *kanban.Ticket + } CreateProjectBtn widget.Clickable EditProjectBtn widget.Clickable @@ -127,51 +128,39 @@ 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 } // Update state based on events. // -// TODO(jfm): investigate best way to handle errors. Some errors are for the user +// @TODO(jfm): investigate best way to handle errors. Some errors are for the user // and others are for the devs. // Currently errors are just printed; not great for windowed applications. +// +// @BUG(jfm): ui not recieving keyboard events. func (ui *UI) Update(gtx C) { if ui.Project != ui.previous { ui.sync() } for _, event := range gtx.Events(ui) { if k, ok := event.(key.Event); ok { - switch k.Name { - case key.NameEscape: - ui.Clear() - case key.NameEnter, key.NameReturn: - // @Cleanup - // on "enter" we want to launch edit form for the focused ticket. - // - // var ( - // t kanban.Ticket - // ) - // if err := ui.Project.Find("ID", ui.FocusedTicket, &t); err != nil { - // fmt.Printf("error: %v\n", err) - // } else { - // ui.InspectTicket(t) - // } - case key.NameDownArrow: - ui.Refocus(NextTicket) - case key.NameUpArrow: - ui.Refocus(PreviousTicket) - case key.NameRightArrow: - ui.Refocus(NextStage) - case key.NameLeftArrow: - ui.Refocus(PreviousStage) + if k.State == key.Press { + switch k.Name { + case key.NameEscape: + ui.Clear() + case key.NameEnter, key.NameReturn: + if t := ui.Focus.T; t != nil { + ui.EditTicket(*t) + } + case key.NameDownArrow: + ui.Refocus(NextTicket) + case key.NameUpArrow: + ui.Refocus(PreviousTicket) + case key.NameRightArrow: + ui.Refocus(NextStage) + case key.NameLeftArrow: + ui.Refocus(PreviousStage) + } } } } @@ -201,11 +190,18 @@ func (ui *UI) Update(gtx C) { } ui.Clear() } - if p, ok := ui.Rail.Selected(); ok { - if ui.Project == nil || ui.Project.Name != p { - project, ok := ui.Projects.Find(p) - if ok { - ui.Project = project + // @CLEANUP(jfm): Unclear code. If no active project or the selected + // project does not match active project, make the selected project the + // active project. + // ID goes through a string representation bc no generics, probably a better + // way to structure that. + if id, ok := ui.Rail.Selected(); ok { + if ui.Project == nil || ui.Project.ID.String() != id { + if id, err := uuid.Parse(id); err == nil { + project, ok := ui.Projects.Find(id) + if ok { + ui.Project = project + } } } } @@ -297,7 +293,15 @@ func (ui *UI) Update(gtx C) { // Layout UI. func (ui *UI) Layout(gtx C) D { + // NOTE(jfm): modal implies a more specific key focus. + // + // @BUG(jfm): ESC cannot clear when inputs steal key focus. + // SOLUTION: make the inputs repsond to esc by cancelling focus of the input. + // Then somehow give focus back to ui. key.InputOp{Tag: ui}.Add(gtx.Ops) + if ui.Modal == nil { + key.FocusOp{Tag: ui}.Add(gtx.Ops) + } return layout.Flex{ Axis: layout.Horizontal, }.Layout( @@ -305,7 +309,6 @@ func (ui *UI) Layout(gtx C) D { layout.Rigid(func(gtx C) D { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y gtx.Constraints.Max.X = gtx.Px(unit.Dp(80)) - gtx.Constraints.Min.X = 0 return ui.layoutRail(gtx) }), layout.Flexed(1, func(gtx C) D { @@ -320,7 +323,7 @@ func (ui *UI) layoutRail(gtx C) D { ) for _, p := range ui.Projects { p := p - rc = append(rc, control.Destination(p.Name, func(gtx C) D { + rc = append(rc, control.Destination(p.ID.String(), func(gtx C) D { return layout.Stack{ Alignment: layout.Center, }.Layout( @@ -332,7 +335,7 @@ func (ui *UI) layoutRail(gtx C) D { }), layout.Expanded(func(gtx C) D { cs := gtx.Constraints - if ui.Project != nil && ui.Project.Name == p.Name { + if isActive := ui.Project != nil && ui.Project.ID == p.ID; isActive { return util.Rect{ Color: color.NRGBA{A: 100}, Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)), @@ -430,13 +433,17 @@ func (ui *UI) layoutContent(gtx C) D { panel := ui.Panels[ii] panels = append(panels, layout.Flexed(1, func(gtx C) D { return panel.Layout(gtx, ui.Th, func() (tickets []layout.ListElement) { - for _, ticket := range stage.Tickets { - ticket := ticket - t := (*Ticket)(ui.TicketStates.New(ticket.Title, unsafe.Pointer(&Ticket{}))) + for ii := range stage.Tickets { + ticket := stage.Tickets[ii] + t := (*Ticket)(ui.TicketStates.New(ticket.ID.String(), unsafe.Pointer(&Ticket{}))) t.Ticket = ticket t.Stage = stage.Name tickets = append(tickets, func(gtx C, index int) D { - return t.Layout(gtx, ui.Th, false) + var focused bool + if ui.Focus.T != nil && ui.Focus.T.ID == t.ID { + focused = true + } + return t.Layout(gtx, ui.Th, focused) }) } return tickets @@ -471,7 +478,53 @@ const ( // Refocus to the ticket in the given direction. // Allows movement between tickets and stages in sequential order. -func (ui *UI) Refocus(d Direction) {} +// +// Stages and Tickets form a 2D array, so we can simply iterate over that with +// an index for each dimension. +func (ui *UI) Refocus(d Direction) { + if len(ui.Project.Stages) == 0 { + return + } + if ui.Focus.T == nil { + if stage := &ui.Project.Stages[ui.Focus.Stage]; len(stage.Tickets) > 0 { + ui.Focus.T = &stage.Tickets[ui.Focus.Ticket] + } + return + } + switch d { + case NextTicket: + ui.Focus.Ticket++ + if ui.Focus.Ticket > len(ui.Project.Stages[ui.Focus.Stage].Tickets)-1 { + ui.Focus.Ticket = 0 + } + case PreviousTicket: + ui.Focus.Ticket-- + if ui.Focus.Ticket < 0 { + ui.Focus.Ticket = len(ui.Project.Stages[ui.Focus.Stage].Tickets) - 1 + } + } + if len(ui.Project.Stages) > 1 { + switch d { + case NextStage: + ui.Focus.Stage++ + ui.Focus.Ticket = 0 + if ui.Focus.Stage > len(ui.Project.Stages)-1 { + ui.Focus.Stage = 0 + } + case PreviousStage: + ui.Focus.Stage-- + ui.Focus.Ticket = 0 + if ui.Focus.Stage < 0 { + ui.Focus.Stage = len(ui.Project.Stages) - 1 + } + } + } + if stage := &ui.Project.Stages[ui.Focus.Stage]; len(stage.Tickets) == 0 { + ui.Refocus(d) + } else { + ui.Focus.T = &stage.Tickets[ui.Focus.Ticket] + } +} // Clear resets navigational state. func (ui *UI) Clear() { @@ -549,9 +602,9 @@ type Projects []kanban.Project // Find and return project by name. // Boolean indicates whether the project exists. -func (plist Projects) Find(name string) (*kanban.Project, bool) { +func (plist Projects) Find(id uuid.UUID) (*kanban.Project, bool) { for ii := range plist { - if plist[ii].Name == name { + if plist[ii].ID == id { return &plist[ii], true } } diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go @@ -137,9 +137,32 @@ func (f *ProjectForm) Mode() Mode { } func (f *ProjectForm) Layout(gtx C, th *material.Theme) D { - var title = "Create a new Project" - if f.Project != nil { + var ( + title = "Create a new Project" + actions = []control.Action{ + { + Clickable: &f.SubmitBtn, + Label: "Submit", + Fg: th.ContrastFg, + Bg: th.ContrastBg, + }, + { + Clickable: &f.CancelBtn, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + } + ) + if f.Mode() == ModeEdit { title = "Edit Project" + actions = append(actions, control.Action{ + Clickable: &f.Delete.Button, + Label: "Archive", + Fg: th.ContrastFg, + Bg: color.NRGBA{R: 200, A: 200}, + Float: control.FloatRight, + }) } return control.Card{ Title: title, @@ -153,27 +176,7 @@ func (f *ProjectForm) Layout(gtx C, th *material.Theme) D { }), ) }, - Actions: []control.Action{ - { - Clickable: &f.SubmitBtn, - Label: "Submit", - Fg: th.ContrastFg, - Bg: th.ContrastBg, - }, - { - Clickable: &f.CancelBtn, - Label: "Cancel", - Fg: th.Fg, - Bg: th.Bg, - }, - { - Clickable: &f.Delete.Button, - Label: "Archive", - Fg: th.ContrastFg, - Bg: color.NRGBA{R: 200, A: 200}, - Float: control.FloatRight, - }, - }, + Actions: actions, }.Layout(gtx, th) }