feat: New primitive for columns #10
4 changed files with 306 additions and 155 deletions
|
@ -29,7 +29,7 @@ You can visit the https://magefile.org[website] for instructions on how to insta
|
|||
|
||||
=== Install with Mage
|
||||
|
||||
TBC
|
||||
_TBC_
|
||||
|
||||
=== Install with Go
|
||||
|
||||
|
@ -47,7 +47,7 @@ go install ./cmd/pelican
|
|||
To create a new Kanban project with Pelican, simply run the following command:
|
||||
[source,console]
|
||||
----
|
||||
pelican ./project.pelican
|
||||
pelican project.pelican
|
||||
----
|
||||
|
||||
This will create a new BoltDB database file called `project.pelican` in your current directory
|
||||
|
|
|
@ -2,119 +2,168 @@ package ui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type card struct {
|
||||
id int
|
||||
wrappedTitle []string
|
||||
created string
|
||||
height int
|
||||
}
|
||||
|
||||
type column struct {
|
||||
*tview.Box
|
||||
|
||||
statusID int
|
||||
cards *tview.List
|
||||
cards []card
|
||||
focusedCard int
|
||||
}
|
||||
|
||||
func (u *UI) newColumn(status board.Status) (column, error) {
|
||||
cardList := tview.NewList()
|
||||
func newColumn(statusID int, statusName string) *column {
|
||||
column := column{
|
||||
Box: tview.NewBox(),
|
||||
statusID: statusID,
|
||||
cards: nil,
|
||||
focusedCard: 0,
|
||||
}
|
||||
|
||||
cardList.SetBorder(true)
|
||||
cardList.ShowSecondaryText(false)
|
||||
cardList.SetTitle(" " + status.Name + " ")
|
||||
cardList.SetHighlightFullLine(true)
|
||||
cardList.SetSelectedFocusOnly(true)
|
||||
cardList.SetWrapAround(false)
|
||||
column.SetBorder(true)
|
||||
column.SetTitle(" " + statusName + " ")
|
||||
column.SetBorderColor(tcell.ColorOrangeRed.TrueColor())
|
||||
|
||||
cardList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
u.pages.ShowPage(addPageName)
|
||||
u.SetFocus(u.addModal)
|
||||
case 'h':
|
||||
u.shiftColumnFocus(shiftLeft)
|
||||
case 'l':
|
||||
u.shiftColumnFocus(shiftRight)
|
||||
case 'j':
|
||||
cur := cardList.GetCurrentItem()
|
||||
if cur == cardList.GetItemCount()-1 {
|
||||
cur = 0
|
||||
return &column
|
||||
}
|
||||
|
||||
func (c *column) addCard(cardID int, title, created string) {
|
||||
wrappedTitle := tview.WordWrap(title, 40)
|
||||
metadataHeight := 1
|
||||
height := len(wrappedTitle) + metadataHeight
|
||||
|
||||
card := card{
|
||||
id: cardID,
|
||||
wrappedTitle: wrappedTitle,
|
||||
created: created,
|
||||
height: height,
|
||||
}
|
||||
|
||||
c.cards = append(c.cards, card)
|
||||
}
|
||||
|
||||
func (c *column) shiftCardFocus(shift int) {
|
||||
numCards := len(c.cards)
|
||||
|
||||
switch shift {
|
||||
case shiftToNext:
|
||||
if c.focusedCard == numCards-1 {
|
||||
c.focusedCard = 0
|
||||
} else {
|
||||
cur++
|
||||
c.focusedCard++
|
||||
}
|
||||
case shiftToPrevious:
|
||||
if c.focusedCard == 0 {
|
||||
c.focusedCard = numCards - 1
|
||||
} else {
|
||||
c.focusedCard--
|
||||
}
|
||||
}
|
||||
cardList.SetCurrentItem(cur)
|
||||
case 'k':
|
||||
cur := cardList.GetCurrentItem()
|
||||
cur--
|
||||
cardList.SetCurrentItem(cur)
|
||||
case 'm':
|
||||
u.pages.ShowPage(movePageName)
|
||||
u.SetFocus(u.move)
|
||||
}
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyCtrlQ:
|
||||
u.pages.ShowPage(quitPageName)
|
||||
u.SetFocus(u.quitModal)
|
||||
case tcell.KeyCtrlD:
|
||||
u.pages.ShowPage(deleteCardPageName)
|
||||
u.SetFocus(u.deleteCardModal)
|
||||
func (c *column) clear() {
|
||||
c.cards = nil
|
||||
c.focusedCard = 0
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
func (c *column) update(kanban board.Board) error {
|
||||
c.clear()
|
||||
|
||||
status, err := kanban.Status(c.statusID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retrieve details about Status %d from the database; %w", c.statusID, err)
|
||||
}
|
||||
|
||||
if status.CardIds != nil && len(status.CardIds) > 0 {
|
||||
cards, err := u.board.CardList(status.CardIds)
|
||||
cards, err := kanban.CardList(status.CardIds)
|
||||
if err != nil {
|
||||
return column{}, fmt.Errorf("unable to get the card list. %w", err)
|
||||
return fmt.Errorf("unable to get the list of cards from Status %d; %w", c.statusID, err)
|
||||
}
|
||||
|
||||
for _, c := range cards {
|
||||
id := strconv.Itoa(c.ID)
|
||||
cardList.AddItem(fmt.Sprintf("[%s] %s", id, c.Title), id, 0, nil)
|
||||
for _, card := range cards {
|
||||
c.addCard(card.ID, card.Title, card.Created)
|
||||
}
|
||||
}
|
||||
|
||||
u.flex.AddItem(cardList, 0, 1, false)
|
||||
|
||||
c := column{
|
||||
statusID: status.ID,
|
||||
cards: cardList,
|
||||
return nil
|
||||
}
|
||||
|
||||
return c, nil
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (c *column) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return c.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) {
|
||||
key, letter := event.Key(), event.Rune()
|
||||
switch {
|
||||
case key == tcell.KeyDown || letter == 'j':
|
||||
c.shiftCardFocus(shiftToNext)
|
||||
case key == tcell.KeyUp || letter == 'k':
|
||||
c.shiftCardFocus(shiftToPrevious)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UI) setColumnFocus() {
|
||||
u.SetFocus(u.columns[u.focusedColumn].cards)
|
||||
}
|
||||
func (c *column) Draw(screen tcell.Screen) {
|
||||
c.DrawForSubclass(screen, c)
|
||||
x, y, width, _ := c.GetInnerRect()
|
||||
|
||||
func (u *UI) shiftColumnFocus(s int) {
|
||||
switch s {
|
||||
case shiftRight:
|
||||
if u.focusedColumn == len(u.columns)-1 {
|
||||
u.focusedColumn = 0
|
||||
xOffset := x + 1
|
||||
cursor := y
|
||||
gap := 1
|
||||
|
||||
for i := 0; i < len(c.cards); i++ {
|
||||
var style tcell.Style
|
||||
var vertLine rune
|
||||
|
||||
if c.HasFocus() && i == c.focusedCard {
|
||||
vertLine = tview.BoxDrawingsHeavyVertical
|
||||
style = tcell.StyleDefault.Foreground(tcell.ColorBlue.TrueColor())
|
||||
} else {
|
||||
u.focusedColumn++
|
||||
}
|
||||
case shiftLeft:
|
||||
if u.focusedColumn == 0 {
|
||||
u.focusedColumn = len(u.columns) - 1
|
||||
} else {
|
||||
u.focusedColumn--
|
||||
}
|
||||
vertLine = tview.BoxDrawingsLightVertical
|
||||
style = tcell.StyleDefault.Foreground(tcell.ColorYellow.TrueColor())
|
||||
}
|
||||
|
||||
u.setColumnFocus()
|
||||
// Draw a vertical line down the left side of each card
|
||||
for cy := cursor; cy < cursor+c.cards[i].height; cy++ {
|
||||
screen.SetContent(xOffset, cy, vertLine, nil, style)
|
||||
}
|
||||
|
||||
func (u *UI) updateColumns(statusList []board.Status) {
|
||||
u.flex.Clear()
|
||||
|
||||
columns := make([]column, len(statusList))
|
||||
|
||||
for i := range statusList {
|
||||
columns[i], _ = u.newColumn(statusList[i])
|
||||
// Print the card's title
|
||||
for j := range c.cards[i].wrappedTitle {
|
||||
tview.Print(
|
||||
screen,
|
||||
c.cards[i].wrappedTitle[j],
|
||||
xOffset+2,
|
||||
cursor+j,
|
||||
width,
|
||||
tview.AlignLeft|tview.AlignTop,
|
||||
tcell.ColorGreen.TrueColor(),
|
||||
)
|
||||
}
|
||||
|
||||
u.columns = columns
|
||||
// Print the card's metadata
|
||||
cal := "đź“…"
|
||||
cardTextFmt := "#%d %s%s"
|
||||
|
||||
tview.Print(
|
||||
screen,
|
||||
fmt.Sprintf(cardTextFmt, c.cards[i].id, cal, c.cards[i].created),
|
||||
xOffset+2,
|
||||
cursor+len(c.cards[i].wrappedTitle),
|
||||
width,
|
||||
tview.AlignLeft|tview.AlignTop,
|
||||
tcell.ColorGrey.TrueColor(),
|
||||
)
|
||||
|
||||
cursor = cursor + c.cards[i].height + gap
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,26 +5,27 @@ import (
|
|||
"strconv"
|
||||
|
||||
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const (
|
||||
shiftLeft int = iota
|
||||
shiftRight
|
||||
shiftToNext int = iota
|
||||
shiftToPrevious
|
||||
)
|
||||
|
||||
const (
|
||||
mainPageName string = "main"
|
||||
quitPageName string = "quit"
|
||||
addPageName string = "add"
|
||||
movePageName string = "move"
|
||||
deleteCardPageName string = "delete card"
|
||||
mainPage string = "main"
|
||||
quitPage string = "quit"
|
||||
addPage string = "add"
|
||||
movePage string = "move"
|
||||
deleteCardPage string = "delete card"
|
||||
)
|
||||
|
||||
type UI struct {
|
||||
*tview.Application
|
||||
|
||||
columns []column
|
||||
columns []*column
|
||||
flex *tview.Flex
|
||||
pages *tview.Pages
|
||||
focusedColumn int
|
||||
|
@ -67,39 +68,53 @@ func (u *UI) closeBoard() {
|
|||
_ = u.board.Close()
|
||||
}
|
||||
|
||||
// deleteCard deletes a card from the board.
|
||||
func (u *UI) deleteCard() {
|
||||
currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem()
|
||||
_, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem)
|
||||
cardID, _ := strconv.Atoi(cardIDText)
|
||||
|
||||
statusID := u.columns[u.focusedColumn].statusID
|
||||
|
||||
args := board.DeleteCardArgs{
|
||||
CardID: cardID,
|
||||
StatusID: statusID,
|
||||
}
|
||||
|
||||
_ = u.board.DeleteCard(args)
|
||||
}
|
||||
|
||||
// init initialises the UI.
|
||||
func (u *UI) init() error {
|
||||
u.pages.AddPage(mainPageName, u.flex, true, true)
|
||||
err := u.initColumns()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error initialising the status columns; %w", err)
|
||||
}
|
||||
|
||||
u.flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
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)
|
||||
case letter == 'l' || key == tcell.KeyRight:
|
||||
u.shiftColumnFocus(shiftToNext)
|
||||
case letter == 'm':
|
||||
u.pages.ShowPage(movePage)
|
||||
u.SetFocus(u.move)
|
||||
case key == tcell.KeyCtrlD:
|
||||
u.pages.ShowPage(deleteCardPage)
|
||||
u.SetFocus(u.deleteCardModal)
|
||||
case key == tcell.KeyCtrlQ:
|
||||
u.pages.ShowPage(quitPage)
|
||||
u.SetFocus(u.quitModal)
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
|
||||
u.pages.AddPage(mainPage, u.flex, true, true)
|
||||
|
||||
u.initQuitModal()
|
||||
u.pages.AddPage(quitPageName, u.quitModal, false, false)
|
||||
u.pages.AddPage(quitPage, u.quitModal, false, false)
|
||||
|
||||
u.initAddInputModal()
|
||||
u.pages.AddPage(addPageName, u.addModal, false, false)
|
||||
u.pages.AddPage(addPage, u.addModal, false, false)
|
||||
|
||||
u.initDeleteCardModal()
|
||||
u.pages.AddPage(deleteCardPageName, u.deleteCardModal, false, false)
|
||||
u.pages.AddPage(deleteCardPage, u.deleteCardModal, false, false)
|
||||
|
||||
u.SetRoot(u.pages, true)
|
||||
|
||||
if err := u.refresh(); err != nil {
|
||||
return fmt.Errorf("error refreshing the board, %w", err)
|
||||
if err := u.refresh(false, true); err != nil {
|
||||
return fmt.Errorf("error refreshing the board; %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -112,7 +127,7 @@ func (u *UI) initAddInputModal() {
|
|||
_ = u.newCard(text, "")
|
||||
}
|
||||
|
||||
u.pages.HidePage(addPageName)
|
||||
u.pages.HidePage(addPage)
|
||||
u.setColumnFocus()
|
||||
}
|
||||
|
||||
|
@ -124,10 +139,10 @@ func (u *UI) initDeleteCardModal() {
|
|||
doneFunc := func(_ int, buttonLabel string) {
|
||||
if buttonLabel == "Confirm" {
|
||||
u.deleteCard()
|
||||
_ = u.refresh()
|
||||
_ = u.refresh(true, false)
|
||||
}
|
||||
|
||||
u.pages.HidePage(deleteCardPageName)
|
||||
u.pages.HidePage(deleteCardPage)
|
||||
u.setColumnFocus()
|
||||
}
|
||||
|
||||
|
@ -136,6 +151,20 @@ func (u *UI) initDeleteCardModal() {
|
|||
SetDoneFunc(doneFunc)
|
||||
}
|
||||
|
||||
// deleteCard deletes a card from the board.
|
||||
func (u *UI) deleteCard() {
|
||||
statusID := u.columns[u.focusedColumn].statusID
|
||||
focusedCard := u.columns[u.focusedColumn].focusedCard
|
||||
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
|
||||
|
||||
args := board.DeleteCardArgs{
|
||||
CardID: cardID,
|
||||
StatusID: statusID,
|
||||
}
|
||||
|
||||
_ = u.board.DeleteCard(args)
|
||||
}
|
||||
|
||||
// initQuitModal initialises the quit modal.
|
||||
func (u *UI) initQuitModal() {
|
||||
doneFunc := func(_ int, buttonLabel string) {
|
||||
|
@ -143,7 +172,7 @@ func (u *UI) initQuitModal() {
|
|||
case "Quit":
|
||||
u.shutdown()
|
||||
default:
|
||||
u.pages.HidePage(quitPageName)
|
||||
u.pages.HidePage(quitPage)
|
||||
u.setColumnFocus()
|
||||
}
|
||||
}
|
||||
|
@ -164,36 +193,14 @@ func (u *UI) newCard(title, content string) error {
|
|||
return fmt.Errorf("unable to create card, %w", err)
|
||||
}
|
||||
|
||||
_ = u.refresh()
|
||||
_ = u.refresh(false, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refresh refreshes the UI.
|
||||
func (u *UI) refresh() error {
|
||||
statusList, err := u.board.StatusList()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get the status list, %w", err)
|
||||
}
|
||||
|
||||
u.updateColumns(statusList)
|
||||
|
||||
u.updateMovePage(statusList)
|
||||
|
||||
u.setColumnFocus()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdown shuts down the application.
|
||||
func (u *UI) shutdown() {
|
||||
u.closeBoard()
|
||||
u.Stop()
|
||||
}
|
||||
|
||||
func (u *UI) updateMovePage(statusList []board.Status) {
|
||||
if u.pages.HasPage(movePageName) {
|
||||
u.pages.RemovePage(movePageName)
|
||||
func (u *UI) updateMovePage() error {
|
||||
if u.pages.HasPage(movePage) {
|
||||
u.pages.RemovePage(movePage)
|
||||
}
|
||||
|
||||
move := tview.NewFlex()
|
||||
|
@ -206,7 +213,7 @@ func (u *UI) updateMovePage(statusList []board.Status) {
|
|||
statusSelection.SetWrapAround(false)
|
||||
|
||||
doneFunc := func() {
|
||||
u.pages.HidePage(movePageName)
|
||||
u.pages.HidePage(movePage)
|
||||
u.setColumnFocus()
|
||||
}
|
||||
|
||||
|
@ -220,9 +227,8 @@ func (u *UI) updateMovePage(statusList []board.Status) {
|
|||
nextStatusID = 0
|
||||
}
|
||||
|
||||
currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem()
|
||||
_, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem)
|
||||
cardID, _ := strconv.Atoi(cardIDText)
|
||||
focusedCard := u.columns[u.focusedColumn].focusedCard
|
||||
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
|
||||
|
||||
args := board.MoveToStatusArgs{
|
||||
CardID: cardID,
|
||||
|
@ -231,12 +237,17 @@ func (u *UI) updateMovePage(statusList []board.Status) {
|
|||
}
|
||||
_ = u.board.MoveToStatus(args)
|
||||
|
||||
u.pages.HidePage(movePageName)
|
||||
_ = u.refresh()
|
||||
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)
|
||||
|
@ -246,5 +257,96 @@ func (u *UI) updateMovePage(statusList []board.Status) {
|
|||
|
||||
u.move = move
|
||||
|
||||
u.pages.AddPage(movePageName, move, false, false)
|
||||
u.pages.AddPage(movePage, move, false, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UI) initColumns() error {
|
||||
u.flex.Clear()
|
||||
|
||||
statusList, err := u.board.StatusList()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retrieve the list of statuses; %w", err)
|
||||
}
|
||||
|
||||
columns := make([]*column, len(statusList))
|
||||
|
||||
for i := range statusList {
|
||||
column := newColumn(statusList[i].ID, statusList[i].Name)
|
||||
u.flex.AddItem(column, 50, 1, true)
|
||||
columns[i] = column
|
||||
}
|
||||
|
||||
u.columns = columns
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UI) updateAllColumns() error {
|
||||
for i := range u.columns {
|
||||
if err := u.updateColumn(i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UI) updateColumn(index int) error {
|
||||
if err := u.columns[index].update(u.board); err != nil {
|
||||
return fmt.Errorf("unable to update column; %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UI) shiftColumnFocus(s int) {
|
||||
switch s {
|
||||
case shiftToNext:
|
||||
if u.focusedColumn == len(u.columns)-1 {
|
||||
u.focusedColumn = 0
|
||||
} else {
|
||||
u.focusedColumn++
|
||||
}
|
||||
case shiftToPrevious:
|
||||
if u.focusedColumn == 0 {
|
||||
u.focusedColumn = len(u.columns) - 1
|
||||
} else {
|
||||
u.focusedColumn--
|
||||
}
|
||||
}
|
||||
|
||||
u.setColumnFocus()
|
||||
}
|
||||
|
||||
func (u *UI) setColumnFocus() {
|
||||
u.SetFocus(u.columns[u.focusedColumn])
|
||||
}
|
||||
|
||||
// refresh refreshes the UI.
|
||||
func (u *UI) refresh(updateFocusedColumnOnly, updateMovePage bool) error {
|
||||
if updateFocusedColumnOnly {
|
||||
if err := u.updateColumn(u.focusedColumn); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := u.updateAllColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if updateMovePage {
|
||||
_ = u.updateMovePage()
|
||||
}
|
||||
|
||||
u.setColumnFocus()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdown shuts down the application.
|
||||
func (u *UI) shutdown() {
|
||||
u.closeBoard()
|
||||
u.Stop()
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue