feat(ui): add help screen with keyboard shortcuts overlay (#44)
Some checks are pending
Quality / golangci-lint (push) Waiting to run
Quality / Build (push) Waiting to run
Quality / Build-1 (push) Waiting to run
Security / Go Vulnerability Check (push) Waiting to run
Security / Security Scanner (push) Waiting to run

* 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.

* 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:38:54 +01:00 committed by GitHub
commit 5e893fe950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 186 additions and 22 deletions

View file

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

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 }