2023-05-06 12:49:40 +01:00
|
|
|
package ui
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
|
2024-01-08 04:52:13 +00:00
|
|
|
"github.com/gdamore/tcell/v2"
|
2023-05-06 12:49:40 +01:00
|
|
|
"github.com/rivo/tview"
|
|
|
|
)
|
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
type (
|
|
|
|
boardMovement int
|
|
|
|
boardMode int
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
next boardMovement = iota
|
|
|
|
previous
|
|
|
|
)
|
|
|
|
|
2023-05-06 12:49:40 +01:00
|
|
|
const (
|
2024-01-09 15:51:23 +00:00
|
|
|
normal boardMode = iota
|
|
|
|
selection
|
2023-05-06 12:49:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2024-01-08 04:52:13 +00:00
|
|
|
mainPage string = "main"
|
|
|
|
quitPage string = "quit"
|
|
|
|
addPage string = "add"
|
|
|
|
deleteCardPage string = "delete card"
|
2023-05-06 12:49:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
type UI struct {
|
|
|
|
*tview.Application
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
columns []*column
|
2023-05-06 12:49:40 +01:00
|
|
|
flex *tview.Flex
|
|
|
|
pages *tview.Pages
|
|
|
|
focusedColumn int
|
|
|
|
board board.Board
|
2024-01-09 15:51:23 +00:00
|
|
|
mode boardMode
|
2023-05-06 12:49:40 +01:00
|
|
|
quitModal *tview.Modal
|
|
|
|
addModal *modalInput
|
|
|
|
deleteCardModal *tview.Modal
|
2024-01-09 15:51:23 +00:00
|
|
|
statusSelection statusSelection
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// NewUI returns a new UI value.
|
2023-12-12 12:47:58 +00:00
|
|
|
func NewUI(path string) (UI, error) {
|
2024-01-09 15:51:23 +00:00
|
|
|
kanban, err := board.Open(path)
|
2023-12-12 12:47:58 +00:00
|
|
|
if err != nil {
|
|
|
|
return UI{}, fmt.Errorf("unable to open the project's board; %w", err)
|
|
|
|
}
|
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
userInterface := UI{
|
2023-05-06 12:49:40 +01:00
|
|
|
Application: tview.NewApplication(),
|
|
|
|
pages: tview.NewPages(),
|
|
|
|
flex: tview.NewFlex(),
|
|
|
|
quitModal: tview.NewModal(),
|
|
|
|
addModal: NewModalInput(),
|
|
|
|
focusedColumn: 0,
|
|
|
|
columns: nil,
|
2024-01-09 15:51:23 +00:00
|
|
|
board: kanban,
|
2023-05-06 12:49:40 +01:00
|
|
|
deleteCardModal: tview.NewModal(),
|
2024-01-09 15:51:23 +00:00
|
|
|
mode: normal,
|
|
|
|
statusSelection: statusSelection{0, 0, 0},
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
if err := userInterface.init(); err != nil {
|
2023-12-12 12:47:58 +00:00
|
|
|
return UI{}, fmt.Errorf("received an error after running the initialisation; %w", err)
|
|
|
|
}
|
2023-05-06 12:49:40 +01:00
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
return userInterface, nil
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// closeBoard closes the board.
|
|
|
|
func (u *UI) closeBoard() {
|
|
|
|
_ = u.board.Close()
|
|
|
|
}
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
// 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)
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
u.flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
|
|
key, letter := event.Key(), event.Rune()
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case letter == 'h' || key == tcell.KeyLeft:
|
2024-01-09 15:51:23 +00:00
|
|
|
u.shiftColumnFocus(previous)
|
2024-01-08 04:52:13 +00:00
|
|
|
case letter == 'l' || key == tcell.KeyRight:
|
2024-01-09 15:51:23 +00:00
|
|
|
u.shiftColumnFocus(next)
|
|
|
|
case letter == 'a':
|
|
|
|
if u.mode == normal {
|
|
|
|
u.pages.ShowPage(addPage)
|
|
|
|
u.SetFocus(u.addModal)
|
|
|
|
}
|
2024-01-08 04:52:13 +00:00
|
|
|
case letter == 'm':
|
2024-01-09 15:51:23 +00:00
|
|
|
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
|
|
|
|
}
|
2024-01-08 04:52:13 +00:00
|
|
|
case key == tcell.KeyCtrlD:
|
2024-01-09 15:51:23 +00:00
|
|
|
if u.mode == normal {
|
|
|
|
u.pages.ShowPage(deleteCardPage)
|
|
|
|
u.SetFocus(u.deleteCardModal)
|
|
|
|
}
|
2024-01-08 04:52:13 +00:00
|
|
|
case key == tcell.KeyCtrlQ:
|
2024-01-09 15:51:23 +00:00
|
|
|
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)
|
|
|
|
}
|
2024-01-08 04:52:13 +00:00
|
|
|
}
|
2023-05-06 12:49:40 +01:00
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
return event
|
|
|
|
})
|
|
|
|
|
|
|
|
u.pages.AddPage(mainPage, u.flex, true, true)
|
2023-05-06 12:49:40 +01:00
|
|
|
|
|
|
|
u.initQuitModal()
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.AddPage(quitPage, u.quitModal, false, false)
|
2023-05-06 12:49:40 +01:00
|
|
|
|
|
|
|
u.initAddInputModal()
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.AddPage(addPage, u.addModal, false, false)
|
2023-05-06 12:49:40 +01:00
|
|
|
|
|
|
|
u.initDeleteCardModal()
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.AddPage(deleteCardPage, u.deleteCardModal, false, false)
|
2023-05-06 12:49:40 +01:00
|
|
|
|
2023-12-12 12:47:58 +00:00
|
|
|
u.SetRoot(u.pages, true)
|
2023-05-06 12:49:40 +01:00
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
if err := u.refresh(false); err != nil {
|
2024-01-08 04:52:13 +00:00
|
|
|
return fmt.Errorf("error refreshing the board; %w", err)
|
2023-12-12 12:47:58 +00:00
|
|
|
}
|
2023-05-06 12:49:40 +01:00
|
|
|
|
2023-12-12 12:47:58 +00:00
|
|
|
return nil
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// initAddInputModal initialises the add input modal.
|
|
|
|
func (u *UI) initAddInputModal() {
|
|
|
|
doneFunc := func(text string, success bool) {
|
|
|
|
if success {
|
|
|
|
_ = u.newCard(text, "")
|
|
|
|
}
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.HidePage(addPage)
|
2023-05-06 12:49:40 +01:00
|
|
|
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()
|
2024-01-09 15:51:23 +00:00
|
|
|
_ = u.refresh(true)
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.HidePage(deleteCardPage)
|
2023-05-06 12:49:40 +01:00
|
|
|
u.setColumnFocus()
|
|
|
|
}
|
|
|
|
|
|
|
|
u.deleteCardModal.SetText("Do you want to delete this card?").
|
|
|
|
AddButtons([]string{"Confirm", "Cancel"}).
|
|
|
|
SetDoneFunc(doneFunc)
|
|
|
|
}
|
|
|
|
|
2024-01-08 04:52:13 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2023-05-06 12:49:40 +01:00
|
|
|
// initQuitModal initialises the quit modal.
|
|
|
|
func (u *UI) initQuitModal() {
|
|
|
|
doneFunc := func(_ int, buttonLabel string) {
|
|
|
|
switch buttonLabel {
|
|
|
|
case "Quit":
|
|
|
|
u.shutdown()
|
|
|
|
default:
|
2024-01-08 04:52:13 +00:00
|
|
|
u.pages.HidePage(quitPage)
|
2023-05-06 12:49:40 +01:00
|
|
|
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,
|
|
|
|
NewContent: content,
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := u.board.CreateCard(args); err != nil {
|
|
|
|
return fmt.Errorf("unable to create card, %w", err)
|
|
|
|
}
|
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
_ = u.refresh(false)
|
2024-01-08 04:52:13 +00:00
|
|
|
|
|
|
|
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 {
|
2024-01-09 15:51:23 +00:00
|
|
|
column := newColumn(statusList[i].ID, statusList[i].Name, u.boardMode)
|
2024-01-08 04:52:13 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-01-09 15:51:23 +00:00
|
|
|
func (u *UI) shiftColumnFocus(movement boardMovement) {
|
|
|
|
switch movement {
|
|
|
|
case next:
|
2024-01-08 04:52:13 +00:00
|
|
|
if u.focusedColumn == len(u.columns)-1 {
|
|
|
|
u.focusedColumn = 0
|
|
|
|
} else {
|
|
|
|
u.focusedColumn++
|
|
|
|
}
|
2024-01-09 15:51:23 +00:00
|
|
|
case previous:
|
2024-01-08 04:52:13 +00:00
|
|
|
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.
|
2024-01-09 15:51:23 +00:00
|
|
|
func (u *UI) refresh(updateFocusedColumnOnly bool) error {
|
2024-01-08 04:52:13 +00:00
|
|
|
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()
|
2023-05-06 12:49:40 +01:00
|
|
|
}
|
2024-01-09 15:51:23 +00:00
|
|
|
|
|
|
|
func (u *UI) boardMode() boardMode {
|
|
|
|
return u.mode
|
|
|
|
}
|