package ui import ( "fmt" "strconv" "codeflow.dananglin.me.uk/apollo/pelican/internal/board" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) const ( shiftToNext int = iota shiftToPrevious ) const ( mainPage string = "main" quitPage string = "quit" addPage string = "add" movePage string = "move" deleteCardPage string = "delete card" ) type UI struct { *tview.Application columns []*column flex *tview.Flex pages *tview.Pages focusedColumn int board board.Board quitModal *tview.Modal addModal *modalInput move *tview.Flex deleteCardModal *tview.Modal } // NewUI returns a new UI value. func NewUI(path string) (UI, error) { b, err := board.Open(path) if err != nil { return UI{}, fmt.Errorf("unable to open the project's board; %w", err) } ui := UI{ Application: tview.NewApplication(), pages: tview.NewPages(), flex: tview.NewFlex(), quitModal: tview.NewModal(), addModal: NewModalInput(), focusedColumn: 0, columns: nil, move: nil, board: b, deleteCardModal: tview.NewModal(), } if err := ui.init(); err != nil { return UI{}, fmt.Errorf("received an error after running the initialisation; %w", err) } return ui, 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 == 'a': u.pages.ShowPage(addPage) u.SetFocus(u.addModal) case letter == 'h' || key == tcell.KeyLeft: u.shiftColumnFocus(shiftToPrevious) case letter == 'l' || key == tcell.KeyRight: u.shiftColumnFocus(shiftToNext) case letter == 'm': u.pages.ShowPage(movePage) u.SetFocus(u.move) case key == tcell.KeyCtrlD: u.pages.ShowPage(deleteCardPage) u.SetFocus(u.deleteCardModal) case key == tcell.KeyCtrlQ: u.pages.ShowPage(quitPage) u.SetFocus(u.quitModal) } 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, true); 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 string, success bool) { if success { _ = u.newCard(text, "") } 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, false) } 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, NewContent: content, } if _, err := u.board.CreateCard(args); err != nil { return fmt.Errorf("unable to create card, %w", err) } _ = u.refresh(false, false) return nil } func (u *UI) updateMovePage() error { if u.pages.HasPage(movePage) { u.pages.RemovePage(movePage) } move := tview.NewFlex() statusSelection := tview.NewList() statusSelection.SetBorder(true) statusSelection.ShowSecondaryText(false) statusSelection.SetHighlightFullLine(true) statusSelection.SetSelectedFocusOnly(true) statusSelection.SetWrapAround(false) doneFunc := func() { u.pages.HidePage(movePage) u.setColumnFocus() } statusSelection.SetDoneFunc(doneFunc) selectedFunc := func(_ int, _, secondary string, _ rune) { currentStatusID := u.columns[u.focusedColumn].statusID nextStatusID, err := strconv.Atoi(secondary) if err != nil { nextStatusID = 0 } focusedCard := u.columns[u.focusedColumn].focusedCard cardID := u.columns[u.focusedColumn].cards[focusedCard].id args := board.MoveToStatusArgs{ CardID: cardID, CurrentStatusID: currentStatusID, NextStatusID: nextStatusID, } _ = u.board.MoveToStatus(args) u.pages.HidePage(movePage) _ = u.refresh(false, false) } statusSelection.SetSelectedFunc(selectedFunc) statusList, err := u.board.StatusList() if err != nil { return fmt.Errorf("unable to get the list of statuses; %w", err) } for _, status := range statusList { id := strconv.Itoa(status.ID) statusSelection.AddItem(fmt.Sprintf("\u25C9 %s", status.Name), id, 0, nil) } move.AddItem(statusSelection, 0, 1, true) u.move = move u.pages.AddPage(movePage, move, false, 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.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(s int) { switch s { case shiftToNext: if u.focusedColumn == len(u.columns)-1 { u.focusedColumn = 0 } else { u.focusedColumn++ } case shiftToPrevious: 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, updateMovePage bool) error { if updateFocusedColumnOnly { if err := u.updateColumn(u.focusedColumn); err != nil { return err } } else { if err := u.updateAllColumns(); err != nil { return err } } if updateMovePage { _ = u.updateMovePage() } u.setColumnFocus() return nil } // shutdown shuts down the application. func (u *UI) shutdown() { u.closeBoard() u.Stop() }