diff --git a/internal/services/io.go b/internal/services/io.go index 36a5339..5274d7d 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -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()) { @@ -55,6 +56,7 @@ type IOService struct { ActionUpdateAll *IOAction ActionInstallAll *IOAction ActionRemoveAll *IOAction + ActionHelp *IOAction ActionBack *IOAction ActionQuit *IOAction } @@ -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) @@ -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) @@ -108,6 +112,7 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionUpdate, s.ActionRemove, s.ActionUpdateAll, + s.ActionHelp, s.ActionBack, s.ActionQuit, } @@ -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, "") } @@ -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, "") diff --git a/internal/ui/components/help.go b/internal/ui/components/help.go new file mode 100644 index 0000000..6cea17c --- /dev/null +++ b/internal/ui/components/help.go @@ -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()) +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 18e3902..d0a952e 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -19,6 +19,7 @@ type LayoutInterface interface { GetLegend() *components.Legend GetNotifier() *components.Notifier GetModal() *components.Modal + GetHelpScreen() *components.HelpScreen } type Layout struct { @@ -31,6 +32,7 @@ type Layout struct { legend *components.Legend notifier *components.Notifier modal *components.Modal + helpScreen *components.HelpScreen theme *theme.Theme } @@ -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, } } @@ -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 }