diff --git a/internal/services/app.go b/internal/services/app.go index e006f40..7f5b96f 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -2,6 +2,8 @@ package services import ( "bbrew/internal/models" + "bbrew/internal/ui" + "bbrew/internal/ui/theme" "context" "fmt" "github.com/gdamore/tcell/v2" @@ -22,35 +24,40 @@ type AppServiceInterface interface { } type AppService struct { - app *tview.Application + app *tview.Application + layout ui.LayoutInterface + theme *theme.Theme - // Data packages *[]models.Formula filteredPackages *[]models.Formula showOnlyInstalled bool brewVersion string - // Services BrewService BrewServiceInterface CommandService CommandServiceInterface SelfUpdateService SelfUpdateServiceInterface - LayoutService LayoutServiceInterface } -var NewAppService = func() AppServiceInterface { - return &AppService{ - app: tview.NewApplication(), // Initialize the application +func NewAppService() AppServiceInterface { + app := tview.NewApplication() + themeService := theme.NewTheme() + + appService := &AppService{ + app: app, + theme: themeService, + layout: ui.NewLayout(themeService), + packages: new([]models.Formula), filteredPackages: new([]models.Formula), - showOnlyInstalled: false, // Default to show all packages + showOnlyInstalled: false, brewVersion: "-", - // Services BrewService: NewBrewService(), CommandService: NewCommandService(), SelfUpdateService: NewSelfUpdateService(), - LayoutService: NewLayoutService(), } + + return appService } func (s *AppService) GetApp() *tview.Application { @@ -73,14 +80,14 @@ func (s *AppService) Boot() (err error) { } func (s *AppService) updateHomeBrew() { - s.LayoutService.SetNotificationMessageWarning("Updating Homebrew formulae...") + s.layout.GetNotifier().ShowWarning("Updating Homebrew formulae...") if err := s.CommandService.UpdateHomebrew(); err != nil { - s.LayoutService.SetNotificationMessageError("Could not update Homebrew formulae") + s.layout.GetNotifier().ShowError("Could not update Homebrew formulae") return } // Clear loading message and update results - s.LayoutService.SetNotificationMessageSuccess("Homebrew formulae updated successfully") + s.layout.GetNotifier().ShowSuccess("Homebrew formulae updated successfully") s.forceRefreshResults() } @@ -149,32 +156,23 @@ func (s *AppService) setDetails(info *models.Formula) { installedVersion, info.Versions.Stable, packagePrefix, dependencies, installedOnRequest, info.Outdated, ) - s.LayoutService.GetDetailsView().SetText( - fmt.Sprintf("%s\n\n%s", generalInfo, installInfo), - ) + s.layout.GetDetails().SetContent(fmt.Sprintf("%s\n\n%s", generalInfo, installInfo)) return } - s.LayoutService.GetDetailsView().SetText("") + s.layout.GetDetails().SetContent("") } func (s *AppService) forceRefreshResults() { s.app.QueueUpdateDraw(func() { _ = s.BrewService.LoadAllFormulae() - s.search(s.LayoutService.GetSearchField().GetText(), false) + s.search(s.layout.GetSearch().Field().GetText(), false) }) } func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { - headers := []string{"Name", "Version", "Description"} - s.LayoutService.GetResultTable().Clear() - - for i, header := range headers { - s.LayoutService.GetResultTable().SetCell(0, i, tview.NewTableCell(header). - SetTextColor(tcell.ColorBlue). - SetAlign(tview.AlignLeft). - SetSelectable(false)) - } + s.layout.GetTable().Clear() + s.layout.GetTable().SetTableHeaders("Name", "Version", "Description") for i, info := range *data { version := info.Versions.Stable @@ -192,21 +190,21 @@ func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { versionCell.SetTextColor(tcell.ColorOrange) } - s.LayoutService.GetResultTable().SetCell(i+1, 0, nameCell.SetExpansion(0)) - s.LayoutService.GetResultTable().SetCell(i+1, 1, versionCell.SetExpansion(0)) - s.LayoutService.GetResultTable().SetCell(i+1, 2, tview.NewTableCell(info.Description).SetSelectable(true).SetExpansion(1)) + s.layout.GetTable().View().SetCell(i+1, 0, nameCell.SetExpansion(0)) + s.layout.GetTable().View().SetCell(i+1, 1, versionCell.SetExpansion(0)) + s.layout.GetTable().View().SetCell(i+1, 2, tview.NewTableCell(info.Description).SetSelectable(true).SetExpansion(1)) } // Update the details view with the first item in the list if len(*data) > 0 { if scrollToTop { - s.LayoutService.GetResultTable().Select(1, 0) - s.LayoutService.GetResultTable().ScrollToBeginning() + s.layout.GetTable().View().Select(1, 0) + s.layout.GetTable().View().ScrollToBeginning() s.setDetails(&(*data)[0]) } // Update the filter counter - s.LayoutService.UpdateFilterCounterView(len(*s.packages), len(*s.filteredPackages)) + s.layout.GetSearch().UpdateCounter(len(*s.packages), len(*s.filteredPackages)) return } @@ -215,12 +213,8 @@ func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { func (s *AppService) BuildApp() { // Build the layout - s.LayoutService.SetNotificationView() - s.LayoutService.SetHeaderView(AppName, AppVersion, s.brewVersion) - s.LayoutService.SetLegendView() - s.LayoutService.SetDetailsView() - s.LayoutService.SetBuildOutputView() - s.LayoutService.SetFilterCounterView() + s.layout.Setup() + s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion) // Evaluate if there is a new version available go func() { @@ -230,7 +224,7 @@ func (s *AppService) BuildApp() { if latestVersion, err := s.SelfUpdateService.CheckForUpdates(ctx); err == nil && latestVersion != AppVersion { s.app.QueueUpdateDraw(func() { AppVersion = fmt.Sprintf("%s ([orange]New Version Available: %s[-])", AppVersion, latestVersion) - s.LayoutService.UpdateHeaderView(AppName, AppVersion, s.brewVersion) + s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion) }) } }() @@ -241,26 +235,23 @@ func (s *AppService) BuildApp() { s.setDetails(&(*s.filteredPackages)[row-1]) } } - s.LayoutService.SetResultTable(tableSelectionChangedFunc) + s.layout.GetTable().View().SetSelectionChangedFunc(tableSelectionChangedFunc) // Search field section inputDoneFunc := func(key tcell.Key) { if key == tcell.KeyEnter || key == tcell.KeyEscape { - s.app.SetFocus(s.LayoutService.GetResultTable()) + s.app.SetFocus(s.layout.GetTable().View()) } } changedFunc := func(text string) { s.search(text, true) } - s.LayoutService.SetSearchField(inputDoneFunc, changedFunc) - - // Set the grid layout (final step) - s.LayoutService.SetGrid() + s.layout.GetSearch().SetHandlers(inputDoneFunc, changedFunc) // Add key event handler and set the root view s.app.SetInputCapture(s.handleKeyEventInput) - s.app.SetRoot(s.LayoutService.GetGrid(), true) - s.app.SetFocus(s.LayoutService.GetResultTable()) + s.app.SetRoot(s.layout.Root(), true) + s.app.SetFocus(s.layout.GetTable().View()) go s.updateHomeBrew() // Update Async the Homebrew formulae s.setResults(s.packages, true) // Set the results diff --git a/internal/services/io.go b/internal/services/io.go index 15e5142..78454f5 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -1,12 +1,14 @@ package services import ( + "bbrew/internal/models" "fmt" "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" ) func (s *AppService) handleKeyEventInput(event *tcell.EventKey) *tcell.EventKey { - if s.LayoutService.GetSearchField().HasFocus() { + if s.layout.GetSearch().Field().HasFocus() { return event } @@ -26,7 +28,8 @@ func (s *AppService) handleKeyEventInput(event *tcell.EventKey) *tcell.EventKey }, tcell.KeyCtrlU: s.handleUpdateAllPackagesEvent, tcell.KeyEsc: func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) + s.app.SetRoot(s.layout.Root(), true) + s.app.SetFocus(s.layout.GetTable().View()) }, } @@ -39,7 +42,7 @@ func (s *AppService) handleKeyEventInput(event *tcell.EventKey) *tcell.EventKey } func (s *AppService) handleSearchFieldEvent() { - s.app.SetFocus(s.LayoutService.GetSearchField()) + s.app.SetFocus(s.layout.GetSearch().Field()) } func (s *AppService) handleQuitEvent() { @@ -49,102 +52,92 @@ func (s *AppService) handleQuitEvent() { func (s *AppService) handleFilterPackagesEvent() { s.showOnlyInstalled = !s.showOnlyInstalled if s.showOnlyInstalled { - s.LayoutService.GetSearchField().SetLabel("Search (Installed): ") + s.layout.GetSearch().Field().SetLabel("Search (Installed): ") } else { - s.LayoutService.GetSearchField().SetLabel("Search (All): ") + s.layout.GetSearch().Field().SetLabel("Search (All): ") } - s.search(s.LayoutService.GetSearchField().GetText(), true) + s.search(s.layout.GetSearch().Field().GetText(), true) +} + +func (s *AppService) showModal(text string, confirmFunc func(), cancelFunc func()) { + modal := s.layout.GetModal().Build(text, func() { + s.app.SetRoot(s.layout.Root(), true) + confirmFunc() + }, func() { + s.app.SetRoot(s.layout.Root(), true) + cancelFunc() + }) + s.app.SetRoot(modal, true) } func (s *AppService) handleInstallPackageEvent() { - row, _ := s.LayoutService.GetResultTable().GetSelection() + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.filteredPackages)[row-1] - modal := s.LayoutService.GenerateModal(fmt.Sprintf("Are you sure you want to install the package: %s?", info.Name), func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - s.LayoutService.GetOutputView().Clear() - go func() { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Installing %s...", info.Name)) - if err := s.CommandService.InstallPackage(info, s.app, s.LayoutService.GetOutputView()); err != nil { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Failed to install %s", info.Name)) - return - } - - s.LayoutService.SetNotificationMessageSuccess(fmt.Sprintf("%s Installed", info.Name)) - s.forceRefreshResults() - }() - }, func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - }) - s.app.SetRoot(modal, true).SetFocus(modal) + s.showModal( + fmt.Sprintf("Are you sure you want to install the package: %s?", info.Name), + s.createModalConfirmHandler(info, "Installing", s.CommandService.InstallPackage, "Installed"), + s.resetViewAfterModal, + ) } } func (s *AppService) handleRemovePackageEvent() { - row, _ := s.LayoutService.GetResultTable().GetSelection() + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.filteredPackages)[row-1] - modal := s.LayoutService.GenerateModal(fmt.Sprintf("Are you sure you want to remove the package: %s?", info.Name), func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - s.LayoutService.GetOutputView().Clear() - go func() { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Removing %s...", info.Name)) - if err := s.CommandService.RemovePackage(info, s.app, s.LayoutService.GetOutputView()); err != nil { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Failed to remove %s", info.Name)) - return - } - - s.LayoutService.SetNotificationMessageSuccess(fmt.Sprintf("%s Removed", info.Name)) - s.forceRefreshResults() - }() - }, func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - }) - s.app.SetRoot(modal, true).SetFocus(modal) + s.showModal( + fmt.Sprintf("Are you sure you want to remove the package: %s?", info.Name), + s.createModalConfirmHandler(info, "Removing", s.CommandService.RemovePackage, "Removed"), + s.resetViewAfterModal, + ) } } func (s *AppService) handleUpdatePackageEvent() { - row, _ := s.LayoutService.GetResultTable().GetSelection() + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.filteredPackages)[row-1] - modal := s.LayoutService.GenerateModal(fmt.Sprintf("Are you sure you want to update the package: %s?", info.Name), func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - s.LayoutService.GetOutputView().Clear() - go func() { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Updating %s...", info.Name)) - if err := s.CommandService.UpdatePackage(info, s.app, s.LayoutService.GetOutputView()); err != nil { - s.LayoutService.SetNotificationMessageWarning(fmt.Sprintf("Failed to update %s", info.Name)) - return - } - - s.LayoutService.SetNotificationMessageSuccess(fmt.Sprintf("%s Updated", info.Name)) - s.forceRefreshResults() - }() - }, func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - }) - s.app.SetRoot(modal, true).SetFocus(modal) + s.showModal( + fmt.Sprintf("Are you sure you want to update the package: %s?", info.Name), + s.createModalConfirmHandler(info, "Updating", s.CommandService.UpdatePackage, "Updated"), + s.resetViewAfterModal, + ) } } func (s *AppService) handleUpdateAllPackagesEvent() { - modal := s.LayoutService.GenerateModal("Are you sure you want to update all packages?", func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - s.LayoutService.GetOutputView().Clear() + s.showModal("Are you sure you want to update all packages?", func() { + s.layout.GetDetails().Clear() go func() { - s.LayoutService.SetNotificationMessageWarning("Updating all packages...") - if err := s.CommandService.UpdateAllPackages(s.app, s.LayoutService.GetOutputView()); err != nil { - s.LayoutService.SetNotificationMessageWarning("Failed to update all packages") + s.layout.GetNotifier().ShowWarning("Updating all packages...") + if err := s.CommandService.UpdateAllPackages(s.app, s.layout.GetDetails().View()); err != nil { + s.layout.GetNotifier().ShowError("Failed to update all packages") return } - - s.LayoutService.SetNotificationMessageSuccess("Updated all packages") + s.layout.GetNotifier().ShowSuccess("Updated all packages") s.forceRefreshResults() }() - }, func() { - s.app.SetRoot(s.LayoutService.GetGrid(), true).SetFocus(s.LayoutService.GetResultTable()) - }) - s.app.SetRoot(modal, true).SetFocus(modal) + }, s.resetViewAfterModal) +} + +func (s *AppService) resetViewAfterModal() { + s.app.SetFocus(s.layout.GetTable().View()) +} + +func (s *AppService) createModalConfirmHandler(info models.Formula, actionName string, action func(models.Formula, *tview.Application, *tview.TextView) error, completedAction string) func() { + return func() { + s.resetViewAfterModal() + s.layout.GetOutput().Clear() + go func() { + s.layout.GetNotifier().ShowWarning(fmt.Sprintf("%s %s...", actionName, info.Name)) + if err := action(info, s.app, s.layout.GetOutput().View()); err != nil { + s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to %s %s", actionName, info.Name)) + return + } + s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("%s %s", info.Name, completedAction)) + s.forceRefreshResults() + }() + } } diff --git a/internal/services/layout.go b/internal/services/layout.go deleted file mode 100644 index 1a10be2..0000000 --- a/internal/services/layout.go +++ /dev/null @@ -1,243 +0,0 @@ -package services - -import ( - "fmt" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type LayoutServiceInterface interface { - GetGrid() *tview.Grid - SetGrid() - - GetNotificationView() *tview.TextView - SetNotificationView() - SetNotificationMessageSuccess(message string) - SetNotificationMessageWarning(message string) - SetNotificationMessageError(message string) - ClearNotificationMessage() - - GetHeaderView() *tview.TextView - SetHeaderView(name, version, brewVersion string) - UpdateHeaderView(name, version, brewVersion string) - - GetLegendView() *tview.TextView - SetLegendView() - - GetResultTable() *tview.Table - SetResultTable(selectionChanged func(row, column int)) - - GetDetailsView() *tview.TextView - SetDetailsView() - - GetOutputView() *tview.TextView - SetBuildOutputView() - - GetSearchField() *tview.InputField - SetSearchField(done func(key tcell.Key), changed func(text string)) - - GetFilterCounterView() *tview.TextView - SetFilterCounterView() - UpdateFilterCounterView(total, filtered int) - - GenerateModal(text string, confirmFunc func(), cancelFunc func()) *tview.Modal -} - -type LayoutService struct { - header *tview.TextView - notification *tview.TextView - legend *tview.TextView - table *tview.Table - detailsView *tview.TextView - outputView *tview.TextView - searchField *tview.InputField - filterCounter *tview.TextView - grid *tview.Grid -} - -var NewLayoutService = func() LayoutServiceInterface { - return &LayoutService{} -} - -func (s *LayoutService) GetGrid() *tview.Grid { - return s.grid -} - -func (s *LayoutService) SetGrid() { - headerContent := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(s.header, 0, 1, false). - AddItem(s.notification, 0, 1, false) - - searchRow := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(s.searchField, 0, 1, false). - AddItem(s.filterCounter, 0, 1, false) - - filtersArea := tview.NewFrame(searchRow). - SetBorders(0, 0, 0, 0, 3, 3) - - tableFrame := tview.NewFrame(s.table). - SetBorders(0, 0, 0, 0, 3, 3) - - leftColumn := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(filtersArea, 2, 0, false). // Fixed height of 3 rows - AddItem(tableFrame, 0, 4, false) - - rightColumn := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(s.detailsView, 0, 2, false). - AddItem(s.outputView, 0, 1, false) - - mainContent := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(leftColumn, 0, 2, false). - AddItem(rightColumn, 0, 1, false) - - s.grid = tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(0). - SetBorders(true). - AddItem(headerContent, 0, 0, 1, 1, 0, 0, false). - AddItem(mainContent, 1, 0, 1, 1, 0, 0, true). - AddItem(s.legend, 2, 0, 1, 1, 0, 0, false) -} - -func (s *LayoutService) GetHeaderView() *tview.TextView { - return s.header -} - -func (s *LayoutService) SetHeaderView(name, version, brewVersion string) { - s.header = tview.NewTextView(). - SetText(fmt.Sprintf(" %s %s - %s", name, version, brewVersion)). - SetDynamicColors(true). - SetTextAlign(tview.AlignLeft) -} - -func (s *LayoutService) UpdateHeaderView(name, version, brewVersion string) { - s.GetHeaderView().SetText(fmt.Sprintf(" %s %s - %s", name, version, brewVersion)) -} - -func (s *LayoutService) GetLegendView() *tview.TextView { - return s.legend -} - -func (s *LayoutService) SetLegendView() { - legendText := tview.Escape( - "[/] Search | " + - "[f] Filter Installed | " + - "[i] Install | " + - "[u] Update | " + - "[ctrl+u] Update All | " + - "[r] Remove | " + - "[Esc] Back to Table | " + - "[q] Quit", - ) - - s.legend = tview.NewTextView(). - SetText(legendText). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter) -} - -func (s *LayoutService) GetResultTable() *tview.Table { - return s.table -} - -func (s *LayoutService) SetResultTable(selectionChanged func(row, column int)) { - s.table = tview.NewTable(). - SetBorders(false). - SetSelectable(true, false). - SetFixed(1, 0). - SetSelectionChangedFunc(selectionChanged) -} - -func (s *LayoutService) GetNotificationView() *tview.TextView { - return s.notification -} - -func (s *LayoutService) SetNotificationView() { - s.notification = tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignRight) -} - -func (s *LayoutService) SetNotificationMessageSuccess(message string) { - s.setNotificationMessage(message, tcell.ColorGreen) -} - -func (s *LayoutService) SetNotificationMessageWarning(message string) { - s.setNotificationMessage(message, tcell.ColorOrange) -} - -func (s *LayoutService) SetNotificationMessageError(message string) { - s.setNotificationMessage(message, tcell.ColorRed) -} - -func (s *LayoutService) setNotificationMessage(message string, color tcell.Color) { - s.GetNotificationView().SetTextColor(color).SetText(fmt.Sprintf(" %s ", message)) -} - -func (s *LayoutService) ClearNotificationMessage() { - s.GetNotificationView().Clear() -} - -func (s *LayoutService) GetDetailsView() *tview.TextView { - return s.detailsView -} - -func (s *LayoutService) SetDetailsView() { - s.detailsView = tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignLeft) - s.detailsView.SetTitle("Details").SetTitleColor(tcell.ColorYellowGreen).SetTitleAlign(tview.AlignLeft).SetBorder(true) -} - -func (s *LayoutService) GetOutputView() *tview.TextView { - return s.outputView -} - -func (s *LayoutService) SetBuildOutputView() { - s.outputView = tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignLeft) - s.outputView.SetBorder(true).SetTitle("Output").SetTitleColor(tcell.ColorYellowGreen).SetTitleAlign(tview.AlignLeft) -} - -func (s *LayoutService) GetSearchField() *tview.InputField { - return s.searchField -} - -func (s *LayoutService) SetSearchField(done func(key tcell.Key), changed func(text string)) { - s.searchField = tview.NewInputField(). - SetLabel("Search (All): "). - SetFieldBackgroundColor(tcell.ColorBlack). - SetFieldTextColor(tcell.ColorWhite). - SetLabelColor(tcell.ColorYellow). - SetFieldWidth(30). - SetDoneFunc(done). - SetChangedFunc(changed) -} - -func (s *LayoutService) GetFilterCounterView() *tview.TextView { - return s.filterCounter -} - -func (s *LayoutService) SetFilterCounterView() { - s.filterCounter = tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignRight) -} - -func (s *LayoutService) UpdateFilterCounterView(total, filtered int) { - s.filterCounter.SetText(fmt.Sprintf("Total: %d | Filtered: %d", total, filtered)) -} - -func (s *LayoutService) GenerateModal(text string, confirmFunc func(), cancelFunc func()) *tview.Modal { - return tview.NewModal(). - SetText(text). - AddButtons([]string{"Confirm", "Cancel"}). - SetBackgroundColor(tcell.ColorDarkSlateGray). - SetTextColor(tcell.ColorWhite). - SetButtonBackgroundColor(tcell.ColorGray). - SetButtonTextColor(tcell.ColorWhite). - SetDoneFunc(func(_ int, buttonLabel string) { - if buttonLabel == "Confirm" { - confirmFunc() - } else if buttonLabel == "Cancel" { - cancelFunc() - } - }) -} diff --git a/internal/ui/components/details.go b/internal/ui/components/details.go new file mode 100644 index 0000000..790357a --- /dev/null +++ b/internal/ui/components/details.go @@ -0,0 +1,38 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type Details struct { + view *tview.TextView + theme *theme.Theme +} + +func NewDetails(theme *theme.Theme) *Details { + details := &Details{ + view: tview.NewTextView(), + theme: theme, + } + + details.view.SetDynamicColors(true) + details.view.SetTextAlign(tview.AlignLeft) + details.view.SetTitle("Details") + details.view.SetTitleColor(theme.TitleColor) + details.view.SetTitleAlign(tview.AlignLeft) + details.view.SetBorder(true) + return details +} + +func (d *Details) SetContent(text string) { + d.view.SetText(text) +} + +func (d *Details) View() *tview.TextView { + return d.view +} + +func (d *Details) Clear() { + d.view.Clear() +} diff --git a/internal/ui/components/header.go b/internal/ui/components/header.go new file mode 100644 index 0000000..6017f31 --- /dev/null +++ b/internal/ui/components/header.go @@ -0,0 +1,31 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "fmt" + "github.com/rivo/tview" +) + +type Header struct { + view *tview.TextView + theme *theme.Theme +} + +func NewHeader(theme *theme.Theme) *Header { + header := &Header{ + view: tview.NewTextView(), + theme: theme, + } + + header.view.SetDynamicColors(true) + header.view.SetTextAlign(tview.AlignLeft) + return header +} + +func (h *Header) Update(name, version, brewVersion string) { + h.view.SetText(fmt.Sprintf(" %s %s - %s", name, version, brewVersion)) +} + +func (h *Header) View() *tview.TextView { + return h.view +} diff --git a/internal/ui/components/legend.go b/internal/ui/components/legend.go new file mode 100644 index 0000000..1d95fe5 --- /dev/null +++ b/internal/ui/components/legend.go @@ -0,0 +1,47 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type Legend struct { + view *tview.TextView + theme *theme.Theme +} + +func NewLegend(theme *theme.Theme) *Legend { + legendText := tview.Escape( + "[/] Search | " + + "[f] Filter Installed | " + + "[i] Install | " + + "[u] Update | " + + "[ctrl+u] Update All | " + + "[r] Remove | " + + "[Esc] Back to Table | " + + "[q] Quit", + ) + + legendView := tview.NewTextView(). + SetText(legendText). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetTextColor(theme.LegendColor) + + return &Legend{ + view: legendView, + theme: theme, + } +} + +func (l *Legend) View() *tview.TextView { + return l.view +} + +func (l *Legend) SetText(text string) { + l.view.SetText(text) +} + +func (l *Legend) Clear() { + l.view.Clear() +} diff --git a/internal/ui/components/modal.go b/internal/ui/components/modal.go new file mode 100644 index 0000000..8641b4b --- /dev/null +++ b/internal/ui/components/modal.go @@ -0,0 +1,44 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type Modal struct { + view *tview.Modal + theme *theme.Theme +} + +func NewModal(theme *theme.Theme) *Modal { + modal := tview.NewModal(). + SetBackgroundColor(theme.ModalBgColor). + SetTextColor(theme.DefaultTextColor). + SetButtonBackgroundColor(theme.ButtonBgColor). + SetButtonTextColor(theme.ButtonTextColor) + + return &Modal{ + view: modal, + theme: theme, + } +} + +func (m *Modal) View() *tview.Modal { + return m.view +} + +func (m *Modal) Build(text string, confirmFunc func(), cancelFunc func()) *tview.Modal { + m.view.ClearButtons() + m.view. + SetText(text). + AddButtons([]string{"Confirm", "Cancel"}). + SetDoneFunc(func(buttonIndex int, _ string) { + if buttonIndex == 0 { + confirmFunc() + } else if buttonIndex == 1 { + cancelFunc() + } + }) + + return m.view +} diff --git a/internal/ui/components/notifier.go b/internal/ui/components/notifier.go new file mode 100644 index 0000000..33532a7 --- /dev/null +++ b/internal/ui/components/notifier.go @@ -0,0 +1,43 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "fmt" + "github.com/rivo/tview" +) + +type Notifier struct { + view *tview.TextView + theme *theme.Theme +} + +func NewNotifier(theme *theme.Theme) *Notifier { + notifierView := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignRight) + + return &Notifier{ + view: notifierView, + theme: theme, + } +} + +func (n *Notifier) View() *tview.TextView { + return n.view +} + +func (n *Notifier) ShowSuccess(message string) { + n.view.SetTextColor(n.theme.SuccessColor).SetText(fmt.Sprintf(" %s ", message)) +} + +func (n *Notifier) ShowWarning(message string) { + n.view.SetTextColor(n.theme.WarningColor).SetText(fmt.Sprintf(" %s ", message)) +} + +func (n *Notifier) ShowError(message string) { + n.view.SetTextColor(n.theme.ErrorColor).SetText(fmt.Sprintf(" %s ", message)) +} + +func (n *Notifier) Clear() { + n.view.Clear() +} diff --git a/internal/ui/components/ouput.go b/internal/ui/components/ouput.go new file mode 100644 index 0000000..611fbb0 --- /dev/null +++ b/internal/ui/components/ouput.go @@ -0,0 +1,49 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type Output struct { + view *tview.TextView + theme *theme.Theme +} + +func NewOutput(theme *theme.Theme) *Output { + output := &Output{ + view: tview.NewTextView(), + theme: theme, + } + + output.view.SetDynamicColors(true) + output.view.SetScrollable(true) + output.view.SetWrap(true) + output.view.SetTextAlign(tview.AlignLeft) + output.view.SetBorder(true) + output.view.SetTitle("Output") + output.view.SetTitleColor(theme.TitleColor) + output.view.SetTitleAlign(tview.AlignLeft) + return output +} + +func (o *Output) View() *tview.TextView { + return o.view +} + +func (o *Output) Clear() { + o.view.Clear() +} + +func (o *Output) Write(text string) { + o.view.SetText(text) +} + +func (o *Output) Append(text string) { + currentText := o.view.GetText(true) + o.view.SetText(currentText + text) +} + +func (o *Output) ScrollToEnd() { + o.view.ScrollToEnd() +} diff --git a/internal/ui/components/search.go b/internal/ui/components/search.go new file mode 100644 index 0000000..f94fb1c --- /dev/null +++ b/internal/ui/components/search.go @@ -0,0 +1,48 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "fmt" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type Search struct { + field *tview.InputField + counter *tview.TextView + theme *theme.Theme +} + +func NewSearch(theme *theme.Theme) *Search { + search := &Search{ + field: tview.NewInputField(), + counter: tview.NewTextView(), + theme: theme, + } + + search.field.SetLabel("Search (All): ") + search.field.SetFieldBackgroundColor(theme.DefaultBgColor) + search.field.SetFieldTextColor(theme.DefaultTextColor) + search.field.SetLabelColor(theme.LabelColor) + search.field.SetFieldWidth(30) + search.counter.SetDynamicColors(true) + search.counter.SetTextAlign(tview.AlignRight) + return search +} + +func (s *Search) SetHandlers(done func(key tcell.Key), changed func(text string)) { + s.field.SetDoneFunc(done) + s.field.SetChangedFunc(changed) +} + +func (s *Search) UpdateCounter(total, filtered int) { + s.counter.SetText(fmt.Sprintf("Total: %d | Filtered: %d", total, filtered)) +} + +func (s *Search) Field() *tview.InputField { + return s.field +} + +func (s *Search) Counter() *tview.TextView { + return s.counter +} diff --git a/internal/ui/components/table.go b/internal/ui/components/table.go new file mode 100644 index 0000000..d493c32 --- /dev/null +++ b/internal/ui/components/table.go @@ -0,0 +1,46 @@ +package components + +import ( + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type Table struct { + view *tview.Table + theme *theme.Theme +} + +func NewTable(theme *theme.Theme) *Table { + table := &Table{ + view: tview.NewTable(), + theme: theme, + } + table.view.SetBorders(false) + table.view.SetSelectable(true, false) + table.view.SetFixed(1, 0) + return table +} + +func (t *Table) SetSelectionHandler(handler func(row, column int)) { + t.view.SetSelectionChangedFunc(handler) +} + +func (t *Table) View() *tview.Table { + return t.view +} + +func (t *Table) Clear() { + t.view.Clear() +} + +func (t *Table) SetTableHeaders(headers ...string) { + for i, header := range headers { + t.view.SetCell(0, i, &tview.TableCell{ + Text: header, + NotSelectable: true, + Align: tview.AlignLeft, + Color: t.theme.TableHeaderColor, + BackgroundColor: t.theme.DefaultBgColor, + }) + } +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..7f844f4 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,112 @@ +package ui + +import ( + "bbrew/internal/ui/components" + "bbrew/internal/ui/theme" + "github.com/rivo/tview" +) + +type LayoutInterface interface { + Setup() + Root() tview.Primitive + + GetHeader() *components.Header + GetSearch() *components.Search + GetTable() *components.Table + GetDetails() *components.Details + GetOutput() *components.Output + GetLegend() *components.Legend + GetNotifier() *components.Notifier + GetModal() *components.Modal +} + +type Layout struct { + mainContent *tview.Grid + header *components.Header + search *components.Search + table *components.Table + details *components.Details + output *components.Output + legend *components.Legend + notifier *components.Notifier + modal *components.Modal + theme *theme.Theme +} + +func NewLayout(theme *theme.Theme) *Layout { + return &Layout{ + mainContent: tview.NewGrid(), + header: components.NewHeader(theme), + search: components.NewSearch(theme), + table: components.NewTable(theme), + details: components.NewDetails(theme), + output: components.NewOutput(theme), + legend: components.NewLegend(theme), + notifier: components.NewNotifier(theme), + modal: components.NewModal(theme), + theme: theme, + } +} + +func (l *Layout) setupLayout() { + // Header + headerContent := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(l.header.View(), 0, 1, false). + AddItem(l.notifier.View(), 0, 1, false) + + // Search and filters + searchRow := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(l.search.Field(), 0, 1, false). + AddItem(l.search.Counter(), 0, 1, false) + + filtersArea := tview.NewFrame(searchRow). + SetBorders(0, 0, 0, 0, 3, 3) + + tableFrame := tview.NewFrame(l.table.View()). + SetBorders(0, 0, 0, 0, 3, 3) + + // Left column with search and table + leftColumn := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(filtersArea, 2, 0, false). + AddItem(tableFrame, 0, 4, false) + + // Right column with details and output + rightColumn := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(l.details.View(), 0, 2, false). + AddItem(l.output.View(), 0, 1, false) + + // Central content + mainContent := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(leftColumn, 0, 2, false). + AddItem(rightColumn, 0, 1, false) + + // Footer + footerContent := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(l.legend.View(), 0, 1, false) + + // Final layout + l.mainContent. + SetRows(1, 0, 1). + SetColumns(0). + SetBorders(true). + AddItem(headerContent, 0, 0, 1, 1, 0, 0, false). + AddItem(mainContent, 1, 0, 1, 1, 0, 0, true). + AddItem(footerContent, 2, 0, 1, 1, 0, 0, false) +} + +func (l *Layout) Setup() { + l.setupLayout() +} + +func (l *Layout) Root() tview.Primitive { + return l.mainContent +} + +func (l *Layout) GetHeader() *components.Header { return l.header } +func (l *Layout) GetSearch() *components.Search { return l.search } +func (l *Layout) GetTable() *components.Table { return l.table } +func (l *Layout) GetDetails() *components.Details { return l.details } +func (l *Layout) GetOutput() *components.Output { return l.output } +func (l *Layout) GetLegend() *components.Legend { return l.legend } +func (l *Layout) GetNotifier() *components.Notifier { return l.notifier } +func (l *Layout) GetModal() *components.Modal { return l.modal } diff --git a/internal/ui/theme/theme.go b/internal/ui/theme/theme.go new file mode 100644 index 0000000..da367cb --- /dev/null +++ b/internal/ui/theme/theme.go @@ -0,0 +1,39 @@ +package theme + +import "github.com/gdamore/tcell/v2" + +type Theme struct { + DefaultTextColor tcell.Color + DefaultBgColor tcell.Color + WarningColor tcell.Color + SuccessColor tcell.Color + ErrorColor tcell.Color + + TitleColor tcell.Color + LabelColor tcell.Color + ButtonBgColor tcell.Color + ButtonTextColor tcell.Color + + ModalBgColor tcell.Color + LegendColor tcell.Color + TableHeaderColor tcell.Color +} + +func NewTheme() *Theme { + return &Theme{ + DefaultTextColor: tcell.ColorWhite, + DefaultBgColor: tcell.ColorBlack, + WarningColor: tcell.ColorYellow, + SuccessColor: tcell.ColorGreen, + ErrorColor: tcell.ColorRed, + + TitleColor: tcell.ColorYellowGreen, + LabelColor: tcell.ColorYellow, + ButtonBgColor: tcell.ColorGray, + ButtonTextColor: tcell.ColorWhite, + + ModalBgColor: tcell.ColorDarkSlateGray, + LegendColor: tcell.ColorWhite, + TableHeaderColor: tcell.ColorBlue, + } +}