feat(ui): add new board mode 'Status Selection'
All checks were successful
/ test (pull_request) Successful in 30s
/ lint (pull_request) Successful in 43s

Replace the existing 'move' tview Page with a new board mode called
'Status Selection'. This mode is used to move cards between statuses.

Resolves apollo/pelican#11
This commit is contained in:
Dan Anglin 2024-01-09 15:51:23 +00:00
parent c7bb499f0d
commit ff676e8dc5
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
3 changed files with 157 additions and 124 deletions

View file

@ -18,22 +18,23 @@ type card struct {
type column struct {
*tview.Box
statusID int
cards []card
focusedCard int
statusID int
cards []card
focusedCard int
getBoardModeFunc func() boardMode
}
func newColumn(statusID int, statusName string) *column {
func newColumn(statusID int, statusName string, getBoardModeFunc func() boardMode) *column {
column := column{
Box: tview.NewBox(),
statusID: statusID,
cards: nil,
focusedCard: 0,
Box: tview.NewBox(),
statusID: statusID,
cards: nil,
focusedCard: 0,
getBoardModeFunc: getBoardModeFunc,
}
column.SetBorder(true)
column.SetTitle(" " + statusName + " ")
column.SetBorderColor(tcell.ColorOrangeRed.TrueColor())
return &column
}
@ -53,17 +54,17 @@ func (c *column) addCard(cardID int, title, created string) {
c.cards = append(c.cards, card)
}
func (c *column) shiftCardFocus(shift int) {
func (c *column) shiftCardFocus(movement boardMovement) {
numCards := len(c.cards)
switch shift {
case shiftToNext:
switch movement {
case next:
if c.focusedCard == numCards-1 {
c.focusedCard = 0
} else {
c.focusedCard++
}
case shiftToPrevious:
case previous:
if c.focusedCard == 0 {
c.focusedCard = numCards - 1
} else {
@ -105,26 +106,48 @@ func (c *column) InputHandler() func(event *tcell.EventKey, setFocus func(p tvie
key, letter := event.Key(), event.Rune()
switch {
case key == tcell.KeyDown || letter == 'j':
c.shiftCardFocus(shiftToNext)
c.shiftCardFocus(next)
case key == tcell.KeyUp || letter == 'k':
c.shiftCardFocus(shiftToPrevious)
c.shiftCardFocus(previous)
}
})
}
func (c *column) Draw(screen tcell.Screen) {
var mode boardMode
// Update border before calling c.DrawForSubclass()
if c.getBoardModeFunc == nil {
mode = normal
} else {
mode = c.getBoardModeFunc()
}
if mode == selection {
if c.HasFocus() {
c.SetBorderColor(tcell.ColorTurquoise.TrueColor())
} else {
c.SetBorderColor(tcell.ColorOrangeRed.TrueColor())
}
} else {
c.SetBorderColor(tcell.ColorOrangeRed.TrueColor())
}
c.DrawForSubclass(screen, c)
x, y, width, _ := c.GetInnerRect()
xOffset := x + 1
cursor := y
gap := 1
for i := 0; i < len(c.cards); i++ {
var style tcell.Style
var vertLine rune
for ind := 0; ind < len(c.cards); ind++ {
var (
style tcell.Style
vertLine rune
)
if c.HasFocus() && i == c.focusedCard {
if c.HasFocus() && ind == c.focusedCard && mode == normal {
vertLine = tview.BoxDrawingsHeavyVertical
style = tcell.StyleDefault.Foreground(tcell.ColorBlue.TrueColor())
} else {
@ -133,15 +156,15 @@ func (c *column) Draw(screen tcell.Screen) {
}
// Draw a vertical line down the left side of each card
for cy := cursor; cy < cursor+c.cards[i].height; cy++ {
for cy := cursor; cy < cursor+c.cards[ind].height; cy++ {
screen.SetContent(xOffset, cy, vertLine, nil, style)
}
// Print the card's title
for j := range c.cards[i].wrappedTitle {
for j := range c.cards[ind].wrappedTitle {
tview.Print(
screen,
c.cards[i].wrappedTitle[j],
c.cards[ind].wrappedTitle[j],
xOffset+2,
cursor+j,
width,
@ -156,14 +179,34 @@ func (c *column) Draw(screen tcell.Screen) {
tview.Print(
screen,
fmt.Sprintf(cardTextFmt, c.cards[i].id, cal, c.cards[i].created),
fmt.Sprintf(cardTextFmt, c.cards[ind].id, cal, c.cards[ind].created),
xOffset+2,
cursor+len(c.cards[i].wrappedTitle),
cursor+len(c.cards[ind].wrappedTitle),
width,
tview.AlignLeft|tview.AlignTop,
tcell.ColorGrey.TrueColor(),
)
cursor = cursor + c.cards[i].height + gap
cursor = cursor + c.cards[ind].height + gap
}
// draw a 'placeholder card' if in status selection board mode
if mode == selection && c.HasFocus() {
vertLine := tview.BoxDrawingsHeavyVertical
style := tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor())
for cy := cursor; cy < cursor+1; cy++ {
screen.SetContent(xOffset, cy, vertLine, nil, style)
}
tview.Print(
screen,
"Move the card to this column",
xOffset+2,
cursor,
width,
tview.AlignLeft|tview.AlignTop,
tcell.ColorWhite.TrueColor(),
)
}
}

View file

@ -0,0 +1,20 @@
package ui
import "codeflow.dananglin.me.uk/apollo/pelican/internal/board"
type statusSelection struct {
cardID int
currentStatusID int
nextStatusID int
}
func (s statusSelection) moveCard(kanban board.Board) {
moveArgs := board.MoveToStatusArgs{
CardID: s.cardID,
CurrentStatusID: s.currentStatusID,
NextStatusID: s.nextStatusID,
}
// TODO: grab error for status line.
_ = kanban.MoveToStatus(moveArgs)
}

View file

@ -2,23 +2,31 @@ package ui
import (
"fmt"
"strconv"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type (
boardMovement int
boardMode int
)
const (
shiftToNext int = iota
shiftToPrevious
next boardMovement = iota
previous
)
const (
normal boardMode = iota
selection
)
const (
mainPage string = "main"
quitPage string = "quit"
addPage string = "add"
movePage string = "move"
deleteCardPage string = "delete card"
)
@ -30,20 +38,21 @@ type UI struct {
pages *tview.Pages
focusedColumn int
board board.Board
mode boardMode
quitModal *tview.Modal
addModal *modalInput
move *tview.Flex
deleteCardModal *tview.Modal
statusSelection statusSelection
}
// NewUI returns a new UI value.
func NewUI(path string) (UI, error) {
b, err := board.Open(path)
kanban, err := board.Open(path)
if err != nil {
return UI{}, fmt.Errorf("unable to open the project's board; %w", err)
}
ui := UI{
userInterface := UI{
Application: tview.NewApplication(),
pages: tview.NewPages(),
flex: tview.NewFlex(),
@ -51,16 +60,17 @@ func NewUI(path string) (UI, error) {
addModal: NewModalInput(),
focusedColumn: 0,
columns: nil,
move: nil,
board: b,
board: kanban,
deleteCardModal: tview.NewModal(),
mode: normal,
statusSelection: statusSelection{0, 0, 0},
}
if err := ui.init(); err != nil {
if err := userInterface.init(); err != nil {
return UI{}, fmt.Errorf("received an error after running the initialisation; %w", err)
}
return ui, nil
return userInterface, nil
}
// closeBoard closes the board.
@ -79,22 +89,46 @@ func (u *UI) init() error {
key, letter := event.Key(), event.Rune()
switch {
case letter == 'a':
u.pages.ShowPage(addPage)
u.SetFocus(u.addModal)
case letter == 'h' || key == tcell.KeyLeft:
u.shiftColumnFocus(shiftToPrevious)
u.shiftColumnFocus(previous)
case letter == 'l' || key == tcell.KeyRight:
u.shiftColumnFocus(shiftToNext)
u.shiftColumnFocus(next)
case letter == 'a':
if u.mode == normal {
u.pages.ShowPage(addPage)
u.SetFocus(u.addModal)
}
case letter == 'm':
u.pages.ShowPage(movePage)
u.SetFocus(u.move)
if u.mode == normal {
focusedCard := u.columns[u.focusedColumn].focusedCard
u.statusSelection.cardID = u.columns[u.focusedColumn].cards[focusedCard].id
u.statusSelection.currentStatusID = u.columns[u.focusedColumn].statusID
u.mode = selection
}
case key == tcell.KeyCtrlD:
u.pages.ShowPage(deleteCardPage)
u.SetFocus(u.deleteCardModal)
if u.mode == normal {
u.pages.ShowPage(deleteCardPage)
u.SetFocus(u.deleteCardModal)
}
case key == tcell.KeyCtrlQ:
u.pages.ShowPage(quitPage)
u.SetFocus(u.quitModal)
if u.mode == normal {
u.pages.ShowPage(quitPage)
u.SetFocus(u.quitModal)
}
case key == tcell.KeyESC:
if u.mode != normal {
u.mode = normal
}
case key == tcell.KeyEnter:
if u.mode == selection {
u.statusSelection.nextStatusID = u.columns[u.focusedColumn].statusID
if u.statusSelection.currentStatusID != u.statusSelection.nextStatusID {
u.statusSelection.moveCard(u.board)
}
u.statusSelection = statusSelection{0, 0, 0}
u.mode = normal
_ = u.refresh(false)
}
}
return event
@ -113,7 +147,7 @@ func (u *UI) init() error {
u.SetRoot(u.pages, true)
if err := u.refresh(false, true); err != nil {
if err := u.refresh(false); err != nil {
return fmt.Errorf("error refreshing the board; %w", err)
}
@ -139,7 +173,7 @@ func (u *UI) initDeleteCardModal() {
doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" {
u.deleteCard()
_ = u.refresh(true, false)
_ = u.refresh(true)
}
u.pages.HidePage(deleteCardPage)
@ -193,71 +227,7 @@ func (u *UI) newCard(title, content string) error {
return fmt.Errorf("unable to create card, %w", err)
}
_ = u.refresh(false, false)
return nil
}
func (u *UI) updateMovePage() error {
if u.pages.HasPage(movePage) {
u.pages.RemovePage(movePage)
}
move := tview.NewFlex()
statusSelection := tview.NewList()
statusSelection.SetBorder(true)
statusSelection.ShowSecondaryText(false)
statusSelection.SetHighlightFullLine(true)
statusSelection.SetSelectedFocusOnly(true)
statusSelection.SetWrapAround(false)
doneFunc := func() {
u.pages.HidePage(movePage)
u.setColumnFocus()
}
statusSelection.SetDoneFunc(doneFunc)
selectedFunc := func(_ int, _, secondary string, _ rune) {
currentStatusID := u.columns[u.focusedColumn].statusID
nextStatusID, err := strconv.Atoi(secondary)
if err != nil {
nextStatusID = 0
}
focusedCard := u.columns[u.focusedColumn].focusedCard
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
args := board.MoveToStatusArgs{
CardID: cardID,
CurrentStatusID: currentStatusID,
NextStatusID: nextStatusID,
}
_ = u.board.MoveToStatus(args)
u.pages.HidePage(movePage)
_ = u.refresh(false, false)
}
statusSelection.SetSelectedFunc(selectedFunc)
statusList, err := u.board.StatusList()
if err != nil {
return fmt.Errorf("unable to get the list of statuses; %w", err)
}
for _, status := range statusList {
id := strconv.Itoa(status.ID)
statusSelection.AddItem(fmt.Sprintf("\u25C9 %s", status.Name), id, 0, nil)
}
move.AddItem(statusSelection, 0, 1, true)
u.move = move
u.pages.AddPage(movePage, move, false, false)
_ = u.refresh(false)
return nil
}
@ -273,7 +243,7 @@ func (u *UI) initColumns() error {
columns := make([]*column, len(statusList))
for i := range statusList {
column := newColumn(statusList[i].ID, statusList[i].Name)
column := newColumn(statusList[i].ID, statusList[i].Name, u.boardMode)
u.flex.AddItem(column, 50, 1, true)
columns[i] = column
}
@ -301,15 +271,15 @@ func (u *UI) updateColumn(index int) error {
return nil
}
func (u *UI) shiftColumnFocus(s int) {
switch s {
case shiftToNext:
func (u *UI) shiftColumnFocus(movement boardMovement) {
switch movement {
case next:
if u.focusedColumn == len(u.columns)-1 {
u.focusedColumn = 0
} else {
u.focusedColumn++
}
case shiftToPrevious:
case previous:
if u.focusedColumn == 0 {
u.focusedColumn = len(u.columns) - 1
} else {
@ -325,7 +295,7 @@ func (u *UI) setColumnFocus() {
}
// refresh refreshes the UI.
func (u *UI) refresh(updateFocusedColumnOnly, updateMovePage bool) error {
func (u *UI) refresh(updateFocusedColumnOnly bool) error {
if updateFocusedColumnOnly {
if err := u.updateColumn(u.focusedColumn); err != nil {
return err
@ -336,10 +306,6 @@ func (u *UI) refresh(updateFocusedColumnOnly, updateMovePage bool) error {
}
}
if updateMovePage {
_ = u.updateMovePage()
}
u.setColumnFocus()
return nil
@ -350,3 +316,7 @@ func (u *UI) shutdown() {
u.closeBoard()
u.Stop()
}
func (u *UI) boardMode() boardMode {
return u.mode
}