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.
This commit is contained in:
Vito Castellano 2025-12-28 19:31:48 +01:00
commit f703f5de3b
No known key found for this signature in database
GPG key ID: E13085DB38BC5819
3 changed files with 173 additions and 12 deletions

View file

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

View file

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

View file

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