Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions internal/services/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ const (

// IOAction represents an input/output action that can be triggered by a key event.
type IOAction struct {
Key tcell.Key
Rune rune
Name string
KeySlug string
Action func()
Key tcell.Key
Rune rune
Name string
KeySlug string
Action func()
HideFromLegend bool // If true, this action won't appear in the legend bar
}

func (k *IOAction) SetAction(action func()) {
Expand Down Expand Up @@ -55,6 +56,7 @@ type IOService struct {
ActionUpdateAll *IOAction
ActionInstallAll *IOAction
ActionRemoveAll *IOAction
ActionHelp *IOAction
ActionBack *IOAction
ActionQuit *IOAction
}
Expand All @@ -69,17 +71,18 @@ var NewIOService = func(appService *AppService) IOServiceInterface {
// Initialize key actions with their respective keys, runes, and names.
s.ActionSearch = &IOAction{Key: tcell.KeyRune, Rune: '/', KeySlug: "/", Name: "Search"}
s.ActionFilterInstalled = &IOAction{Key: tcell.KeyRune, Rune: 'f', KeySlug: "f", Name: "Installed"}
s.ActionFilterOutdated = &IOAction{Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Outdated"}
s.ActionFilterLeaves = &IOAction{Key: tcell.KeyRune, Rune: 'l', KeySlug: "l", Name: "Leaves"}
s.ActionFilterCasks = &IOAction{Key: tcell.KeyRune, Rune: 'c', KeySlug: "c", Name: "Casks"}
s.ActionFilterOutdated = &IOAction{Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Outdated", HideFromLegend: true}
s.ActionFilterLeaves = &IOAction{Key: tcell.KeyRune, Rune: 'l', KeySlug: "l", Name: "Leaves", HideFromLegend: true}
s.ActionFilterCasks = &IOAction{Key: tcell.KeyRune, Rune: 'c', KeySlug: "c", Name: "Casks", HideFromLegend: true}
s.ActionInstall = &IOAction{Key: tcell.KeyRune, Rune: 'i', KeySlug: "i", Name: "Install"}
s.ActionUpdate = &IOAction{Key: tcell.KeyRune, Rune: 'u', KeySlug: "u", Name: "Update"}
s.ActionRemove = &IOAction{Key: tcell.KeyRune, Rune: 'r', KeySlug: "r", Name: "Remove"}
s.ActionUpdateAll = &IOAction{Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All"}
s.ActionUpdateAll = &IOAction{Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All", HideFromLegend: true}
s.ActionInstallAll = &IOAction{Key: tcell.KeyCtrlA, Rune: 0, KeySlug: "ctrl+a", Name: "Install All (Brewfile)"}
s.ActionRemoveAll = &IOAction{Key: tcell.KeyCtrlR, Rune: 0, KeySlug: "ctrl+r", Name: "Remove All (Brewfile)"}
s.ActionBack = &IOAction{Key: tcell.KeyEsc, Rune: 0, KeySlug: "esc", Name: "Back to Table"}
s.ActionQuit = &IOAction{Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit"}
s.ActionHelp = &IOAction{Key: tcell.KeyRune, Rune: '?', KeySlug: "?", Name: "Help"}
s.ActionBack = &IOAction{Key: tcell.KeyEsc, Rune: 0, KeySlug: "esc", Name: "Back to Table", HideFromLegend: true}
s.ActionQuit = &IOAction{Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit", HideFromLegend: true}

// Define actions for each key input,
s.ActionSearch.SetAction(s.handleSearchFieldEvent)
Expand All @@ -93,6 +96,7 @@ var NewIOService = func(appService *AppService) IOServiceInterface {
s.ActionUpdateAll.SetAction(s.handleUpdateAllPackagesEvent)
s.ActionInstallAll.SetAction(s.handleInstallAllPackagesEvent)
s.ActionRemoveAll.SetAction(s.handleRemoveAllPackagesEvent)
s.ActionHelp.SetAction(s.handleHelpEvent)
s.ActionBack.SetAction(s.handleBack)
s.ActionQuit.SetAction(s.handleQuitEvent)

Expand All @@ -108,6 +112,7 @@ var NewIOService = func(appService *AppService) IOServiceInterface {
s.ActionUpdate,
s.ActionRemove,
s.ActionUpdateAll,
s.ActionHelp,
s.ActionBack,
s.ActionQuit,
}
Expand All @@ -119,9 +124,11 @@ var NewIOService = func(appService *AppService) IOServiceInterface {

// updateLegendEntries updates the legend entries based on current keyActions
func (s *IOService) updateLegendEntries() {
s.legendEntries = make([]struct{ KeySlug, Name string }, len(s.keyActions))
for i, input := range s.keyActions {
s.legendEntries[i] = struct{ KeySlug, Name string }{KeySlug: input.KeySlug, Name: input.Name}
s.legendEntries = make([]struct{ KeySlug, Name string }, 0, len(s.keyActions))
for _, input := range s.keyActions {
if !input.HideFromLegend {
s.legendEntries = append(s.legendEntries, struct{ KeySlug, Name string }{KeySlug: input.KeySlug, Name: input.Name})
}
}
s.layout.GetLegend().SetLegend(s.legendEntries, "")
}
Expand Down Expand Up @@ -179,6 +186,23 @@ func (s *IOService) handleQuitEvent() {
s.appService.GetApp().Stop()
}

// handleHelpEvent shows the help screen with all keyboard shortcuts.
func (s *IOService) handleHelpEvent() {
helpScreen := s.layout.GetHelpScreen()
helpScreen.SetBrewfileMode(s.appService.IsBrewfileMode())
helpPages := helpScreen.Build(s.layout.Root())

// Set up key handler to close help on any key press
helpPages.SetInputCapture(func(_ *tcell.EventKey) *tcell.EventKey {
// Close help and return to main view
s.appService.GetApp().SetRoot(s.layout.Root(), true)
s.appService.GetApp().SetFocus(s.layout.GetTable().View())
return nil
})

s.appService.GetApp().SetRoot(helpPages, true)
}

// handleFilterEvent toggles the filter for installed or outdated packages based on the provided filter type.
func (s *IOService) handleFilterEvent(filterType FilterType) {
s.layout.GetLegend().SetLegend(s.legendEntries, "")
Expand Down
136 changes: 136 additions & 0 deletions internal/ui/components/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package components

import (
"bbrew/internal/ui/theme"
"fmt"
"strings"

"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)

// HelpScreen displays a modal overlay with all keyboard shortcuts
type HelpScreen struct {
pages *tview.Pages
theme *theme.Theme
isBrewfile bool
}

// NewHelpScreen creates a new help screen component
func NewHelpScreen(theme *theme.Theme) *HelpScreen {
return &HelpScreen{
theme: theme,
}
}

// View returns the help screen pages (for overlay functionality)
func (h *HelpScreen) View() *tview.Pages {
return h.pages
}

// SetBrewfileMode sets whether Brewfile-specific commands should be shown
func (h *HelpScreen) SetBrewfileMode(enabled bool) {
h.isBrewfile = enabled
}

// Build creates the help screen as an overlay on top of the main content
func (h *HelpScreen) Build(mainContent tview.Primitive) *tview.Pages {
content := h.buildHelpContent()

textView := tview.NewTextView().
SetDynamicColors(true).
SetText(content).
SetTextAlign(tview.AlignLeft)

textView.SetBackgroundColor(h.theme.ModalBgColor)
textView.SetTextColor(h.theme.DefaultTextColor)

// Create a frame around the text
frame := tview.NewFrame(textView).
SetBorders(1, 1, 1, 1, 2, 2)
frame.SetBackgroundColor(h.theme.ModalBgColor)
frame.SetBorderColor(h.theme.BorderColor)
frame.SetBorder(true).
SetTitle(" Help ").
SetTitleAlign(tview.AlignCenter)

// Calculate box dimensions
boxHeight := 22
boxWidth := 55
if h.isBrewfile {
boxHeight = 26 // Extra space for Brewfile section
}

// Center the frame in a flex layout
centered := tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(frame, boxHeight, 0, true).
AddItem(nil, 0, 1, false),
boxWidth, 0, true).
AddItem(nil, 0, 1, false)

// Create pages with main content as background and help as overlay
h.pages = tview.NewPages().
AddPage("main", mainContent, true, true).
AddPage("help", centered, true, true)

return h.pages
}

// buildHelpContent generates the formatted help text
func (h *HelpScreen) buildHelpContent() string {
var sb strings.Builder

// Navigation section
sb.WriteString(h.formatSection("NAVIGATION"))
sb.WriteString(h.formatKey("↑/↓, j/k", "Navigate list"))
sb.WriteString(h.formatKey("/", "Focus search"))
sb.WriteString(h.formatKey("Esc", "Back to table"))
sb.WriteString(h.formatKey("q", "Quit"))
sb.WriteString("\n")

// Filters section
sb.WriteString(h.formatSection("FILTERS"))
sb.WriteString(h.formatKey("f", "Toggle installed"))
sb.WriteString(h.formatKey("o", "Toggle outdated"))
sb.WriteString(h.formatKey("l", "Toggle leaves"))
sb.WriteString(h.formatKey("c", "Toggle casks"))
sb.WriteString("\n")

// Actions section
sb.WriteString(h.formatSection("ACTIONS"))
sb.WriteString(h.formatKey("i", "Install selected"))
sb.WriteString(h.formatKey("u", "Update selected"))
sb.WriteString(h.formatKey("r", "Remove selected"))
sb.WriteString(h.formatKey("Ctrl+U", "Update all"))

// Brewfile section (only if in Brewfile mode)
if h.isBrewfile {
sb.WriteString("\n")
sb.WriteString(h.formatSection("BREWFILE"))
sb.WriteString(h.formatKey("Ctrl+A", "Install all"))
sb.WriteString(h.formatKey("Ctrl+R", "Remove all"))
}

sb.WriteString("\n")
sb.WriteString(fmt.Sprintf("[%s]Press any key to close[-]", h.getColorTag(h.theme.LegendColor)))

return sb.String()
}

// formatSection formats a section header
func (h *HelpScreen) formatSection(title string) string {
return fmt.Sprintf("[%s::b]%s[-:-:-]\n", h.getColorTag(h.theme.SuccessColor), title)
}

// formatKey formats a key-description pair
func (h *HelpScreen) formatKey(key, description string) string {
return fmt.Sprintf(" [%s]%-12s[-] %s\n", h.getColorTag(h.theme.WarningColor), key, description)
}

// getColorTag converts a tcell.Color to a tview color tag
func (h *HelpScreen) getColorTag(color tcell.Color) string {
return fmt.Sprintf("#%06x", color.Hex())
}
20 changes: 12 additions & 8 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type LayoutInterface interface {
GetLegend() *components.Legend
GetNotifier() *components.Notifier
GetModal() *components.Modal
GetHelpScreen() *components.HelpScreen
}

type Layout struct {
Expand All @@ -31,6 +32,7 @@ type Layout struct {
legend *components.Legend
notifier *components.Notifier
modal *components.Modal
helpScreen *components.HelpScreen
theme *theme.Theme
}

Expand All @@ -45,6 +47,7 @@ func NewLayout(theme *theme.Theme) LayoutInterface {
legend: components.NewLegend(theme),
notifier: components.NewNotifier(theme),
modal: components.NewModal(theme),
helpScreen: components.NewHelpScreen(theme),
theme: theme,
}
}
Expand Down Expand Up @@ -103,11 +106,12 @@ 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 }
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 }
func (l *Layout) GetHelpScreen() *components.HelpScreen { return l.helpScreen }
Loading