Dan Anglin
f956b7da59
Change the content field to description for the card type in preparation for supporting card notes.
322 lines
6.8 KiB
Go
322 lines
6.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
type (
|
|
boardMovement int
|
|
boardMode int
|
|
)
|
|
|
|
const (
|
|
next boardMovement = iota
|
|
previous
|
|
)
|
|
|
|
const (
|
|
normal boardMode = iota
|
|
selection
|
|
)
|
|
|
|
const (
|
|
mainPage string = "main"
|
|
quitPage string = "quit"
|
|
addPage string = "add"
|
|
deleteCardPage string = "delete card"
|
|
)
|
|
|
|
type UI struct {
|
|
*tview.Application
|
|
|
|
columns []*column
|
|
flex *tview.Flex
|
|
pages *tview.Pages
|
|
focusedColumn int
|
|
board board.Board
|
|
mode boardMode
|
|
quitModal *tview.Modal
|
|
addModal *modalInput
|
|
deleteCardModal *tview.Modal
|
|
statusSelection statusSelection
|
|
}
|
|
|
|
// NewUI returns a new UI value.
|
|
func NewUI(path string) (UI, error) {
|
|
kanban, err := board.Open(path)
|
|
if err != nil {
|
|
return UI{}, fmt.Errorf("unable to open the project's board; %w", err)
|
|
}
|
|
|
|
userInterface := UI{
|
|
Application: tview.NewApplication(),
|
|
pages: tview.NewPages(),
|
|
flex: tview.NewFlex(),
|
|
quitModal: tview.NewModal(),
|
|
addModal: newModalInput(),
|
|
focusedColumn: 0,
|
|
columns: nil,
|
|
board: kanban,
|
|
deleteCardModal: tview.NewModal(),
|
|
mode: normal,
|
|
statusSelection: statusSelection{0, 0, 0},
|
|
}
|
|
|
|
if err := userInterface.init(); err != nil {
|
|
return UI{}, fmt.Errorf("received an error after running the initialisation; %w", err)
|
|
}
|
|
|
|
return userInterface, nil
|
|
}
|
|
|
|
// closeBoard closes the board.
|
|
func (u *UI) closeBoard() {
|
|
_ = u.board.Close()
|
|
}
|
|
|
|
// init initialises the UI.
|
|
func (u *UI) init() error {
|
|
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 == 'h' || key == tcell.KeyLeft:
|
|
u.shiftColumnFocus(previous)
|
|
case letter == 'l' || key == tcell.KeyRight:
|
|
u.shiftColumnFocus(next)
|
|
case letter == 'c':
|
|
if u.mode == normal {
|
|
u.pages.ShowPage(addPage)
|
|
u.SetFocus(u.addModal)
|
|
}
|
|
case letter == 'm':
|
|
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:
|
|
if u.mode == normal {
|
|
u.pages.ShowPage(deleteCardPage)
|
|
u.SetFocus(u.deleteCardModal)
|
|
}
|
|
case key == tcell.KeyCtrlQ:
|
|
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
|
|
})
|
|
|
|
u.pages.AddPage(mainPage, u.flex, true, true)
|
|
|
|
u.initQuitModal()
|
|
u.pages.AddPage(quitPage, u.quitModal, false, false)
|
|
|
|
u.initAddInputModal()
|
|
u.pages.AddPage(addPage, u.addModal, false, false)
|
|
|
|
u.initDeleteCardModal()
|
|
u.pages.AddPage(deleteCardPage, u.deleteCardModal, false, false)
|
|
|
|
u.SetRoot(u.pages, true)
|
|
|
|
if err := u.refresh(false); err != nil {
|
|
return fmt.Errorf("error refreshing the board; %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// initAddInputModal initialises the add input modal.
|
|
func (u *UI) initAddInputModal() {
|
|
doneFunc := func(text, description string, success bool) {
|
|
if success {
|
|
_ = u.newCard(text, description)
|
|
}
|
|
|
|
u.pages.HidePage(addPage)
|
|
u.setColumnFocus()
|
|
}
|
|
|
|
u.addModal.SetDoneFunc(doneFunc)
|
|
}
|
|
|
|
// initDeleteCardModal initialises the modal for deleting cards.
|
|
func (u *UI) initDeleteCardModal() {
|
|
doneFunc := func(_ int, buttonLabel string) {
|
|
if buttonLabel == "Confirm" {
|
|
u.deleteCard()
|
|
_ = u.refresh(true)
|
|
}
|
|
|
|
u.pages.HidePage(deleteCardPage)
|
|
u.setColumnFocus()
|
|
}
|
|
|
|
u.deleteCardModal.SetText("Do you want to delete this card?").
|
|
AddButtons([]string{"Confirm", "Cancel"}).
|
|
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) {
|
|
switch buttonLabel {
|
|
case "Quit":
|
|
u.shutdown()
|
|
default:
|
|
u.pages.HidePage(quitPage)
|
|
u.setColumnFocus()
|
|
}
|
|
}
|
|
|
|
u.quitModal.SetText("Do you want to quit the application?").
|
|
AddButtons([]string{"Quit", "Cancel"}).
|
|
SetDoneFunc(doneFunc)
|
|
}
|
|
|
|
// newCard creates and saves a new card to the database.
|
|
func (u *UI) newCard(title, content string) error {
|
|
args := board.CardArgs{
|
|
NewTitle: title,
|
|
NewDescription: content,
|
|
}
|
|
|
|
if _, err := u.board.CreateCard(args); err != nil {
|
|
return fmt.Errorf("unable to create card, %w", err)
|
|
}
|
|
|
|
_ = u.refresh(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.boardMode)
|
|
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(movement boardMovement) {
|
|
switch movement {
|
|
case next:
|
|
if u.focusedColumn == len(u.columns)-1 {
|
|
u.focusedColumn = 0
|
|
} else {
|
|
u.focusedColumn++
|
|
}
|
|
case previous:
|
|
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 bool) error {
|
|
if updateFocusedColumnOnly {
|
|
if err := u.updateColumn(u.focusedColumn); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := u.updateAllColumns(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
u.setColumnFocus()
|
|
|
|
return nil
|
|
}
|
|
|
|
// shutdown shuts down the application.
|
|
func (u *UI) shutdown() {
|
|
u.closeBoard()
|
|
u.Stop()
|
|
}
|
|
|
|
func (u *UI) boardMode() boardMode {
|
|
return u.mode
|
|
}
|