pelican/internal/ui/ui.go
Dan Anglin 1bf60d2b1f
All checks were successful
/ test (pull_request) Successful in 33s
/ lint (pull_request) Successful in 35s
feat(ui): add a status bar
This commit adds a status bar at the bottom of the application for
displaying confirmation and error messages to the user.

Resolves apollo/pelican#19
2024-01-13 12:34:16 +00:00

476 lines
11 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"
cardFormPage string = "card form"
deleteCardModalPage string = "delete card modal"
viewPage string = "view"
)
type UI struct {
*tview.Application
columns []*column
baseFlex *tview.Flex
columnFlex *tview.Flex
pages *tview.Pages
focusedColumn int
board board.Board
mode boardMode
quitModal *tview.Modal
cardForm *cardForm
deleteCardModal *tview.Modal
statusSelection statusSelection
view *cardView
statusbar *statusbar
}
// 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(),
baseFlex: tview.NewFlex(),
columnFlex: tview.NewFlex(),
quitModal: tview.NewModal(),
cardForm: newCardForm(),
focusedColumn: 0,
columns: nil,
board: kanban,
deleteCardModal: tview.NewModal(),
mode: normal,
statusSelection: statusSelection{0, 0, 0},
view: newCardView(),
statusbar: newStatusbar(),
}
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 {
if err := u.initColumns(); err != nil {
return fmt.Errorf("error initialising the status columns; %w", err)
}
u.columnFlex.SetInputCapture(u.inputCapture())
u.initStatusbar()
u.baseFlex.SetDirection(tview.FlexRow)
u.baseFlex.AddItem(u.columnFlex, 0, 1, true)
u.baseFlex.AddItem(u.statusbar, 2, 1, false)
u.pages.AddPage(mainPage, u.baseFlex, true, true)
u.initQuitModal()
u.pages.AddPage(quitPage, u.quitModal, false, false)
u.initCardForm()
u.pages.AddPage(cardFormPage, u.cardForm, false, false)
u.initDeleteCardModal()
u.pages.AddPage(deleteCardModalPage, u.deleteCardModal, false, false)
u.initView()
u.pages.AddPage(viewPage, u.view, false, false)
u.SetRoot(u.pages, true)
u.refresh(false)
return nil
}
func (u *UI) inputCapture() func(event *tcell.EventKey) *tcell.EventKey {
return 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.cardForm.mode = create
u.cardForm.updateInputFields("", "")
u.cardForm.frame.SetTitle(" Create Card ")
u.pages.ShowPage(cardFormPage)
u.SetFocus(u.cardForm)
}
case letter == 'm':
if u.mode == normal {
u.statusSelection.cardID = u.focusedCardID()
u.statusSelection.currentStatusID = u.focusedStatusID()
u.mode = selection
}
case letter == 'e':
if u.mode == normal {
u.cardForm.mode = edit
card, ok := u.getFocusedCard()
if !ok {
break
}
u.cardForm.updateInputFields(card.Title, card.Description)
u.cardForm.frame.SetTitle(" Edit Card ")
u.pages.ShowPage(cardFormPage)
u.SetFocus(u.cardForm)
}
case key == tcell.KeyCtrlD:
if u.mode == normal {
card, ok := u.getFocusedCard()
if !ok {
break
}
text := fmt.Sprintf("Do you want to delete '%s'?", card.Title)
u.deleteCardModal.SetText(text)
u.pages.ShowPage(deleteCardModalPage)
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:
switch u.mode {
case normal:
card, ok := u.getFocusedCard()
if !ok {
break
}
status := u.focusedStatusName()
u.view.print(card.ID, card.Title, status, card.Created, card.Description)
u.pages.ShowPage(viewPage)
u.SetFocus(u.view)
case selection:
u.statusSelection.nextStatusID = u.focusedStatusID()
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
}
}
// initCardForm initialises the card form.
func (u *UI) initCardForm() {
doneFunc := func(title, description string, success bool, mode cardFormMode) {
if success {
switch mode {
case create:
u.saveCard(title, description)
case edit:
u.editFocusedCard(title, description)
}
}
u.pages.HidePage(cardFormPage)
u.setColumnFocus()
}
u.cardForm.setDoneFunc(doneFunc)
}
// initDeleteCardModal initialises the modal for deleting cards.
func (u *UI) initDeleteCardModal() {
doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" {
u.deleteFocusedCard()
u.refresh(true)
}
u.pages.HidePage(deleteCardModalPage)
u.setColumnFocus()
}
u.deleteCardModal.AddButtons([]string{"Confirm", "Cancel"}).SetDoneFunc(doneFunc)
u.deleteCardModal.SetBorder(true).SetBorderColor(tcell.ColorOrangeRed)
u.deleteCardModal.SetBackgroundColor(tcell.ColorBlack.TrueColor())
u.deleteCardModal.SetButtonBackgroundColor(tcell.ColorBlueViolet.TrueColor())
u.deleteCardModal.SetTextColor(tcell.ColorWhite.TrueColor())
}
// 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)
}
// initView initialises the view window for displaying the card.
func (u *UI) initView() {
doneFunc := func(key tcell.Key) {
if key == tcell.KeyEsc {
u.pages.HidePage(viewPage)
u.view.Clear()
u.setColumnFocus()
}
}
u.view.setDoneFunc(doneFunc)
}
func (u *UI) initStatusbar() {
changedFunc := func() {
u.Draw()
}
u.statusbar.SetChangedFunc(changedFunc)
}
// saveCard creates and saves a new card to the database.
func (u *UI) saveCard(title, description string) {
args := board.CardArgs{
NewTitle: title,
NewDescription: description,
}
_, err := u.board.CreateCard(args)
if err != nil {
u.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to create card: %v.", err))
return
}
u.statusbar.displayMessage(infoLevel, "Card created successfully.")
u.refresh(false)
}
// editFocusedCard saves and edited card to the database.
func (u *UI) editFocusedCard(title, description string) {
cardID := u.focusedCardID()
args := board.UpdateCardArgs{
CardID: cardID,
CardArgs: board.CardArgs{
NewTitle: title,
NewDescription: description,
},
}
if err := u.board.UpdateCard(args); err != nil {
u.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to edit card: %v.", err))
return
}
u.statusbar.displayMessage(infoLevel, "Card edited successfully.")
u.refresh(true)
}
// getFocusedCard retrieves the details of the focused card.
func (u *UI) getFocusedCard() (board.Card, bool) {
cardID := u.focusedCardID()
card, err := u.board.Card(cardID)
if err != nil {
u.statusbar.displayMessage(
errorLevel,
fmt.Sprintf("Failed to retrieve the card with card ID %d, %v.", cardID, err),
)
return card, false
}
return card, true
}
// deleteFocusedCard deletes the focused card from the board.
func (u *UI) deleteFocusedCard() {
args := board.DeleteCardArgs{
CardID: u.focusedCardID(),
StatusID: u.focusedStatusID(),
}
if err := u.board.DeleteCard(args); err != nil {
u.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to delete card: %v.", err))
return
}
u.statusbar.displayMessage(infoLevel, "Card deleted successfully.")
}
// initColumns initialises the columns of the Kanban board.
func (u *UI) initColumns() error {
u.columnFlex.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.columnFlex.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) {
if updateFocusedColumnOnly {
if err := u.updateColumn(u.focusedColumn); err != nil {
u.statusbar.displayMessage(
errorLevel,
fmt.Sprintf("Failed to update status column %q: %v", u.focusedStatusName(), err),
)
return
}
} else {
if err := u.updateAllColumns(); err != nil {
u.statusbar.displayMessage(
errorLevel,
fmt.Sprintf("Failed to update status columns: %v", err),
)
return
}
}
u.setColumnFocus()
}
// shutdown shuts down the application.
func (u *UI) shutdown() {
u.closeBoard()
u.Stop()
}
// boardMode returns the current board mode.
func (u *UI) boardMode() boardMode {
return u.mode
}
// focusedCardID returns the ID of the card in focus.
func (u *UI) focusedCardID() int {
focusedCard := u.columns[u.focusedColumn].focusedCard
id := u.columns[u.focusedColumn].cards[focusedCard].id
return id
}
// focusedStatusID returns the ID of the status (column) in focus.
func (u *UI) focusedStatusID() int {
return u.columns[u.focusedColumn].statusID
}
// focusedStatusName returns the name of the status (column) in focus.
func (u *UI) focusedStatusName() string {
return u.columns[u.focusedColumn].statusName
}