feat(ui): New primitive for columns
All checks were successful
/ test (pull_request) Successful in 3m50s
/ lint (pull_request) Successful in 3m55s

Created a new tview primitive for the columns for better control over
style and function.
This commit is contained in:
Dan Anglin 2024-01-08 04:52:13 +00:00
parent af7f120616
commit c7bb499f0d
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
4 changed files with 306 additions and 155 deletions

View file

@ -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

View file

@ -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 column struct {
statusID int
cards *tview.List
type card struct {
id int
wrappedTitle []string
created string
height int
}
func (u *UI) newColumn(status board.Status) (column, error) {
cardList := tview.NewList()
type column struct {
*tview.Box
cardList.SetBorder(true)
cardList.ShowSecondaryText(false)
cardList.SetTitle(" " + status.Name + " ")
cardList.SetHighlightFullLine(true)
cardList.SetSelectedFocusOnly(true)
cardList.SetWrapAround(false)
statusID int
cards []card
focusedCard int
}
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
} else {
cur++
}
cardList.SetCurrentItem(cur)
case 'k':
cur := cardList.GetCurrentItem()
cur--
cardList.SetCurrentItem(cur)
case 'm':
u.pages.ShowPage(movePageName)
u.SetFocus(u.move)
func newColumn(statusID int, statusName string) *column {
column := column{
Box: tview.NewBox(),
statusID: statusID,
cards: nil,
focusedCard: 0,
}
column.SetBorder(true)
column.SetTitle(" " + statusName + " ")
column.SetBorderColor(tcell.ColorOrangeRed.TrueColor())
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 {
c.focusedCard++
}
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)
case shiftToPrevious:
if c.focusedCard == 0 {
c.focusedCard = numCards - 1
} else {
c.focusedCard--
}
}
}
return event
})
func (c *column) clear() {
c.cards = nil
c.focusedCard = 0
}
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 c, nil
return nil
}
func (u *UI) setColumnFocus() {
u.SetFocus(u.columns[u.focusedColumn].cards)
// 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) shiftColumnFocus(s int) {
switch s {
case shiftRight:
if u.focusedColumn == len(u.columns)-1 {
u.focusedColumn = 0
func (c *column) Draw(screen tcell.Screen) {
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
if c.HasFocus() && i == c.focusedCard {
vertLine = tview.BoxDrawingsHeavyVertical
style = tcell.StyleDefault.Foreground(tcell.ColorBlue.TrueColor())
} else {
u.focusedColumn++
vertLine = tview.BoxDrawingsLightVertical
style = tcell.StyleDefault.Foreground(tcell.ColorYellow.TrueColor())
}
case shiftLeft:
if u.focusedColumn == 0 {
u.focusedColumn = len(u.columns) - 1
} else {
u.focusedColumn--
// 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)
}
// 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(),
)
}
// 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
}
u.setColumnFocus()
}
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])
}
u.columns = columns
}

View file

@ -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()
}