From b7d959d9f2d1c1ca24d355e0c07573671cd0e5b4 Mon Sep 17 00:00:00 2001 From: Vito Castellano Date: Sun, 28 Dec 2025 16:19:39 +0100 Subject: [PATCH 1/2] feat(legend): add HideFromLegend flag to reduce legend size Hide Back and Quit actions from legend bar as they are intuitive keys. This prepares for a more compact legend with a dedicated help screen. --- internal/services/io.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/services/io.go b/internal/services/io.go index 36a5339..92369fb 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()) { @@ -78,8 +79,8 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionUpdateAll = &IOAction{Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All"} 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.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) @@ -119,9 +120,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, "") } From f703f5de3bd282eca2c6b2d7770364f49fc8ddb8 Mon Sep 17 00:00:00 2001 From: Vito Castellano Date: Sun, 28 Dec 2025 19:31:48 +0100 Subject: [PATCH 2/2] feat(ui): add help screen with keyboard shortcuts overlay Add a help modal accessible via '?' key that displays all keyboard shortcuts organized by category (Navigation, Filters, Actions, Brewfile). Reduce legend bar clutter by hiding less common actions and showing only essential shortcuts. Add HideFromLegend flag to IOAction struct for flexible legend management. --- internal/services/io.go | 29 ++++++- internal/ui/components/help.go | 136 +++++++++++++++++++++++++++++++++ internal/ui/layout.go | 20 +++-- 3 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 internal/ui/components/help.go diff --git a/internal/services/io.go b/internal/services/io.go index 92369fb..5274d7d 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -56,6 +56,7 @@ type IOService struct { ActionUpdateAll *IOAction ActionInstallAll *IOAction ActionRemoveAll *IOAction + ActionHelp *IOAction ActionBack *IOAction ActionQuit *IOAction } @@ -70,15 +71,16 @@ 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.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} @@ -94,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) @@ -109,6 +112,7 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionUpdate, s.ActionRemove, s.ActionUpdateAll, + s.ActionHelp, s.ActionBack, s.ActionQuit, } @@ -182,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 }