feat: new UI layout management (#6)

* created new components

* enhanced new layout

* improved theme
This commit is contained in:
Vito 2025-02-24 22:53:09 +01:00 committed by GitHub
commit 7d2c082ea5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 600 additions and 362 deletions

View file

@ -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

View file

@ -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()
}()
}
}

View file

@ -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()
}
})
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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,
})
}
}

112
internal/ui/layout.go Normal file
View file

@ -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 }

View file

@ -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,
}
}