From c7bb499f0d583334064cb0cada00e49a6fd64118 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Mon, 8 Jan 2024 04:52:13 +0000 Subject: [PATCH] feat(ui): New primitive for columns Created a new tview primitive for the columns for better control over style and function. --- README.asciidoc | 4 +- internal/ui/column.go | 221 +++++++++------- internal/ui/{modalinput.go => modal_input.go} | 0 internal/ui/ui.go | 236 +++++++++++++----- 4 files changed, 306 insertions(+), 155 deletions(-) rename internal/ui/{modalinput.go => modal_input.go} (100%) diff --git a/README.asciidoc b/README.asciidoc index ce16d1d..536c853 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -29,7 +29,7 @@ You can visit the https://magefile.org[website] for instructions on how to insta === Install with Mage -TBC +_TBC_ === Install with Go @@ -47,7 +47,7 @@ go install ./cmd/pelican To create a new Kanban project with Pelican, simply run the following command: [source,console] ---- -pelican ./project.pelican +pelican project.pelican ---- This will create a new BoltDB database file called `project.pelican` in your current directory diff --git a/internal/ui/column.go b/internal/ui/column.go index 706aba9..2f9d57b 100644 --- a/internal/ui/column.go +++ b/internal/ui/column.go @@ -2,119 +2,168 @@ package ui import ( "fmt" - "strconv" "codeflow.dananglin.me.uk/apollo/pelican/internal/board" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) -type column struct { - statusID int - cards *tview.List +type card struct { + id int + wrappedTitle []string + created string + height int } -func (u *UI) newColumn(status board.Status) (column, error) { - cardList := tview.NewList() +type column struct { + *tview.Box - cardList.SetBorder(true) - cardList.ShowSecondaryText(false) - cardList.SetTitle(" " + status.Name + " ") - cardList.SetHighlightFullLine(true) - cardList.SetSelectedFocusOnly(true) - cardList.SetWrapAround(false) + statusID int + cards []card + focusedCard int +} - cardList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { - case 'a': - u.pages.ShowPage(addPageName) - u.SetFocus(u.addModal) - case 'h': - u.shiftColumnFocus(shiftLeft) - case 'l': - u.shiftColumnFocus(shiftRight) - case 'j': - cur := cardList.GetCurrentItem() - if cur == cardList.GetItemCount()-1 { - cur = 0 - } else { - cur++ - } - cardList.SetCurrentItem(cur) - case 'k': - cur := cardList.GetCurrentItem() - cur-- - cardList.SetCurrentItem(cur) - case 'm': - u.pages.ShowPage(movePageName) - u.SetFocus(u.move) +func newColumn(statusID int, statusName string) *column { + column := column{ + Box: tview.NewBox(), + statusID: statusID, + cards: nil, + focusedCard: 0, + } + + column.SetBorder(true) + column.SetTitle(" " + statusName + " ") + column.SetBorderColor(tcell.ColorOrangeRed.TrueColor()) + + return &column +} + +func (c *column) addCard(cardID int, title, created string) { + wrappedTitle := tview.WordWrap(title, 40) + metadataHeight := 1 + height := len(wrappedTitle) + metadataHeight + + card := card{ + id: cardID, + wrappedTitle: wrappedTitle, + created: created, + height: height, + } + + c.cards = append(c.cards, card) +} + +func (c *column) shiftCardFocus(shift int) { + numCards := len(c.cards) + + switch shift { + case shiftToNext: + if c.focusedCard == numCards-1 { + c.focusedCard = 0 + } else { + c.focusedCard++ } - - switch event.Key() { - case tcell.KeyCtrlQ: - u.pages.ShowPage(quitPageName) - u.SetFocus(u.quitModal) - case tcell.KeyCtrlD: - u.pages.ShowPage(deleteCardPageName) - u.SetFocus(u.deleteCardModal) + case shiftToPrevious: + if c.focusedCard == 0 { + c.focusedCard = numCards - 1 + } else { + c.focusedCard-- } + } +} - return event - }) +func (c *column) clear() { + c.cards = nil + c.focusedCard = 0 +} + +func (c *column) update(kanban board.Board) error { + c.clear() + + status, err := kanban.Status(c.statusID) + if err != nil { + return fmt.Errorf("unable to retrieve details about Status %d from the database; %w", c.statusID, err) + } if status.CardIds != nil && len(status.CardIds) > 0 { - cards, err := u.board.CardList(status.CardIds) + cards, err := kanban.CardList(status.CardIds) if err != nil { - return column{}, fmt.Errorf("unable to get the card list. %w", err) + return fmt.Errorf("unable to get the list of cards from Status %d; %w", c.statusID, err) } - for _, c := range cards { - id := strconv.Itoa(c.ID) - cardList.AddItem(fmt.Sprintf("[%s] %s", id, c.Title), id, 0, nil) + for _, card := range cards { + c.addCard(card.ID, card.Title, card.Created) } } - u.flex.AddItem(cardList, 0, 1, false) - - c := column{ - statusID: status.ID, - cards: cardList, - } - - return c, nil + return nil } -func (u *UI) setColumnFocus() { - u.SetFocus(u.columns[u.focusedColumn].cards) +// InputHandler returns the handler for this primitive. +func (c *column) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return c.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) { + key, letter := event.Key(), event.Rune() + switch { + case key == tcell.KeyDown || letter == 'j': + c.shiftCardFocus(shiftToNext) + case key == tcell.KeyUp || letter == 'k': + c.shiftCardFocus(shiftToPrevious) + } + }) } -func (u *UI) shiftColumnFocus(s int) { - switch s { - case shiftRight: - if u.focusedColumn == len(u.columns)-1 { - u.focusedColumn = 0 +func (c *column) Draw(screen tcell.Screen) { + c.DrawForSubclass(screen, c) + x, y, width, _ := c.GetInnerRect() + + xOffset := x + 1 + cursor := y + gap := 1 + + for i := 0; i < len(c.cards); i++ { + var style tcell.Style + var vertLine rune + + if c.HasFocus() && i == c.focusedCard { + vertLine = tview.BoxDrawingsHeavyVertical + style = tcell.StyleDefault.Foreground(tcell.ColorBlue.TrueColor()) } else { - u.focusedColumn++ + vertLine = tview.BoxDrawingsLightVertical + style = tcell.StyleDefault.Foreground(tcell.ColorYellow.TrueColor()) } - case shiftLeft: - if u.focusedColumn == 0 { - u.focusedColumn = len(u.columns) - 1 - } else { - u.focusedColumn-- + + // Draw a vertical line down the left side of each card + for cy := cursor; cy < cursor+c.cards[i].height; cy++ { + screen.SetContent(xOffset, cy, vertLine, nil, style) } + + // Print the card's title + for j := range c.cards[i].wrappedTitle { + tview.Print( + screen, + c.cards[i].wrappedTitle[j], + xOffset+2, + cursor+j, + width, + tview.AlignLeft|tview.AlignTop, + tcell.ColorGreen.TrueColor(), + ) + } + + // Print the card's metadata + cal := "📅" + cardTextFmt := "#%d %s%s" + + tview.Print( + screen, + fmt.Sprintf(cardTextFmt, c.cards[i].id, cal, c.cards[i].created), + xOffset+2, + cursor+len(c.cards[i].wrappedTitle), + width, + tview.AlignLeft|tview.AlignTop, + tcell.ColorGrey.TrueColor(), + ) + + cursor = cursor + c.cards[i].height + gap } - - u.setColumnFocus() -} - -func (u *UI) updateColumns(statusList []board.Status) { - u.flex.Clear() - - columns := make([]column, len(statusList)) - - for i := range statusList { - columns[i], _ = u.newColumn(statusList[i]) - } - - u.columns = columns } diff --git a/internal/ui/modalinput.go b/internal/ui/modal_input.go similarity index 100% rename from internal/ui/modalinput.go rename to internal/ui/modal_input.go diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 10f96bd..d25a6ad 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,26 +5,27 @@ import ( "strconv" "codeflow.dananglin.me.uk/apollo/pelican/internal/board" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) const ( - shiftLeft int = iota - shiftRight + shiftToNext int = iota + shiftToPrevious ) const ( - mainPageName string = "main" - quitPageName string = "quit" - addPageName string = "add" - movePageName string = "move" - deleteCardPageName string = "delete card" + mainPage string = "main" + quitPage string = "quit" + addPage string = "add" + movePage string = "move" + deleteCardPage string = "delete card" ) type UI struct { *tview.Application - columns []column + columns []*column flex *tview.Flex pages *tview.Pages focusedColumn int @@ -67,39 +68,53 @@ func (u *UI) closeBoard() { _ = u.board.Close() } -// deleteCard deletes a card from the board. -func (u *UI) deleteCard() { - currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem() - _, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem) - cardID, _ := strconv.Atoi(cardIDText) - - statusID := u.columns[u.focusedColumn].statusID - - args := board.DeleteCardArgs{ - CardID: cardID, - StatusID: statusID, - } - - _ = u.board.DeleteCard(args) -} - // init initialises the UI. func (u *UI) init() error { - u.pages.AddPage(mainPageName, u.flex, true, true) + 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(quitPageName, u.quitModal, false, false) + u.pages.AddPage(quitPage, u.quitModal, false, false) u.initAddInputModal() - u.pages.AddPage(addPageName, u.addModal, false, false) + u.pages.AddPage(addPage, u.addModal, false, false) u.initDeleteCardModal() - u.pages.AddPage(deleteCardPageName, u.deleteCardModal, false, false) + u.pages.AddPage(deleteCardPage, u.deleteCardModal, false, false) u.SetRoot(u.pages, true) - if err := u.refresh(); err != nil { - return fmt.Errorf("error refreshing the board, %w", err) + if err := u.refresh(false, true); err != nil { + return fmt.Errorf("error refreshing the board; %w", err) } return nil @@ -112,7 +127,7 @@ func (u *UI) initAddInputModal() { _ = u.newCard(text, "") } - u.pages.HidePage(addPageName) + u.pages.HidePage(addPage) u.setColumnFocus() } @@ -124,10 +139,10 @@ func (u *UI) initDeleteCardModal() { doneFunc := func(_ int, buttonLabel string) { if buttonLabel == "Confirm" { u.deleteCard() - _ = u.refresh() + _ = u.refresh(true, false) } - u.pages.HidePage(deleteCardPageName) + u.pages.HidePage(deleteCardPage) u.setColumnFocus() } @@ -136,6 +151,20 @@ func (u *UI) initDeleteCardModal() { 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) { @@ -143,7 +172,7 @@ func (u *UI) initQuitModal() { case "Quit": u.shutdown() default: - u.pages.HidePage(quitPageName) + u.pages.HidePage(quitPage) u.setColumnFocus() } } @@ -164,36 +193,14 @@ func (u *UI) newCard(title, content string) error { return fmt.Errorf("unable to create card, %w", err) } - _ = u.refresh() + _ = u.refresh(false, false) return nil } -// refresh refreshes the UI. -func (u *UI) refresh() error { - statusList, err := u.board.StatusList() - if err != nil { - return fmt.Errorf("unable to get the status list, %w", err) - } - - u.updateColumns(statusList) - - u.updateMovePage(statusList) - - u.setColumnFocus() - - return nil -} - -// shutdown shuts down the application. -func (u *UI) shutdown() { - u.closeBoard() - u.Stop() -} - -func (u *UI) updateMovePage(statusList []board.Status) { - if u.pages.HasPage(movePageName) { - u.pages.RemovePage(movePageName) +func (u *UI) updateMovePage() error { + if u.pages.HasPage(movePage) { + u.pages.RemovePage(movePage) } move := tview.NewFlex() @@ -206,7 +213,7 @@ func (u *UI) updateMovePage(statusList []board.Status) { statusSelection.SetWrapAround(false) doneFunc := func() { - u.pages.HidePage(movePageName) + u.pages.HidePage(movePage) u.setColumnFocus() } @@ -220,9 +227,8 @@ func (u *UI) updateMovePage(statusList []board.Status) { nextStatusID = 0 } - currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem() - _, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem) - cardID, _ := strconv.Atoi(cardIDText) + focusedCard := u.columns[u.focusedColumn].focusedCard + cardID := u.columns[u.focusedColumn].cards[focusedCard].id args := board.MoveToStatusArgs{ CardID: cardID, @@ -231,12 +237,17 @@ func (u *UI) updateMovePage(statusList []board.Status) { } _ = u.board.MoveToStatus(args) - u.pages.HidePage(movePageName) - _ = u.refresh() + 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) @@ -246,5 +257,96 @@ func (u *UI) updateMovePage(statusList []board.Status) { u.move = move - u.pages.AddPage(movePageName, move, false, false) + 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() }