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 flex *tview.Flex pages *tview.Pages focusedColumn int board board.Board mode boardMode quitModal *tview.Modal cardForm *cardForm deleteCardModal *tview.Modal statusSelection statusSelection view *cardView } // 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(), cardForm: newCardForm(), focusedColumn: 0, columns: nil, board: kanban, deleteCardModal: tview.NewModal(), mode: normal, statusSelection: statusSelection{0, 0, 0}, view: newCardView(), } 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(u.inputCapture()) u.pages.AddPage(mainPage, u.flex, 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) if err := u.refresh(false); err != nil { return fmt.Errorf("error refreshing the board; %w", err) } 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, _ := u.getFocusedCard() 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 { 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, _ := u.getFocusedCard() 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.newCard(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.SetText("Do you want to delete this card?"). AddButtons([]string{"Confirm", "Cancel"}). SetDoneFunc(doneFunc) } // 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) } // newCard creates and saves a new card to the database. func (u *UI) newCard(title, description string) error { args := board.CardArgs{ NewTitle: title, NewDescription: description, } if _, err := u.board.CreateCard(args); err != nil { return fmt.Errorf("unable to create card, %w", err) } _ = u.refresh(false) return nil } // editFocusedCard saves and edited card to the database. func (u *UI) editFocusedCard(title, description string) error { cardID := u.focusedCardID() args := board.UpdateCardArgs{ CardID: cardID, CardArgs: board.CardArgs{ NewTitle: title, NewDescription: description, }, } if err := u.board.UpdateCard(args); err != nil { return fmt.Errorf("unable to edit card with ID: %d; %w", cardID, err) } _ = u.refresh(true) return nil } // getFocusedCard retrieves the details of the focused card. func (u *UI) getFocusedCard() (board.Card, error) { cardID := u.focusedCardID() card, err := u.board.Card(cardID) if err != nil { return board.Card{}, fmt.Errorf("unable to retrieve the card with card ID %d, %w", cardID, err) } return card, nil } // deleteFocusedCard deletes the focused card from the board. func (u *UI) deleteFocusedCard() { args := board.DeleteCardArgs{ CardID: u.focusedCardID(), StatusID: u.focusedStatusID(), } _ = u.board.DeleteCard(args) } // initColumns initialises the columns of the Kanban board. 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() } // 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 }