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 }