Files
Hadi aa6496901f up
Signed-off-by: Hadi <hadi@example.fr>
2026-08-18 16:15:03 +02:00

186 lines
6.4 KiB
Go

// Package helpbar is a responsive help bar: a single line of key bindings
// that expands, on demand, into a multi-column view reflowed to use as many
// columns as the available width allows.
//
// It has no dependency on any particular layout or container - it's just a
// component that takes a width and some bindings and returns a string, so it
// works as well under a plain lipgloss.JoinVertical as anywhere else.
//
// Bindings come from two places. Global ones (quit, toggle help, whatever
// your app reserves for itself) are set once via WithGlobal and always shown
// first. Contextual ones are passed to View at render time, so the bar can
// track whatever component currently has focus:
//
// bar := m.help.View(m.focused().HelpBindings()...)
// body := m.height - lipgloss.Height(bar)
package helpbar
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
// Model is a help bar. The zero value isn't usable - build one with New.
type Model struct {
// ShowAll switches between the one-line short view and the full
// multi-column view. Set it directly, or let WithToggle bind a key to
// it and have Update flip it for you.
ShowAll bool
help help.Model
global []key.Binding
toggle key.Binding
width int
}
// Option configures a Model at construction.
type Option func(*Model)
// WithGlobal sets the bindings shown before the contextual ones on every
// render - the keys your app reserves for itself regardless of what's
// focused.
func WithGlobal(bindings ...key.Binding) Option {
return func(m *Model) { m.global = bindings }
}
// WithToggle makes Update flip ShowAll when b matches, and lists b ahead of
// every other binding (including WithGlobal's) so the way to expand the bar
// is always the first thing shown. Without it, Update ignores key presses
// and toggling ShowAll is entirely up to the caller.
func WithToggle(b key.Binding) Option {
return func(m *Model) { m.toggle = b }
}
// WithStyles overrides the themed default styles.
func WithStyles(s help.Styles) Option {
return func(m *Model) { m.help.Styles = s }
}
// New builds a help bar themed from the shared style package.
func New(opts ...Option) Model {
m := Model{help: bubbles.NewHelp()}
for _, opt := range opts {
opt(&m)
}
return m
}
// SetWidth sets the width the bar renders within. Nothing is shown until
// this is called with a positive value - typically from your
// tea.WindowSizeMsg handler.
func (m *Model) SetWidth(w int) {
m.width = w
m.help.SetWidth(w)
}
// Width returns the width last given to SetWidth.
func (m Model) Width() int { return m.width }
// Update flips ShowAll when a key press matches WithToggle's binding. It's
// optional: a Model built without WithToggle ignores every message, and you
// can always set ShowAll yourself instead.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
m.ShowAll = !m.ShowAll
}
return m, nil
}
// View renders the bar: one line while ShowAll is false, otherwise the full
// multi-column view. contextual bindings follow the toggle and global ones,
// and are meant to change from render to render as focus moves.
//
// Returns "" when no width has been set or nothing is left to show, in which
// case the bar occupies no rows at all.
func (m Model) View(contextual ...key.Binding) string {
bindings := m.bindings(contextual)
if len(bindings) == 0 || m.width <= 0 {
return ""
}
if !m.ShowAll {
return m.help.ShortHelpView(bindings)
}
return m.help.FullHelpView(m.columns(bindings))
}
// Height is the number of rows View would take for the same bindings. Use it
// to work out how much room is left for the rest of your UI. View is the
// source of truth: this is exactly lipgloss.Height of its output, so the two
// can't disagree about where the bar begins.
func (m Model) Height(contextual ...key.Binding) int {
view := m.View(contextual...)
if view == "" {
return 0
}
return lipgloss.Height(view)
}
// bindings is the full ordered list: the toggle first (so the way to expand
// the bar always leads), then global, then contextual. Disabled bindings are
// dropped here so column reflow never budgets width for something
// help.FullHelpView will skip anyway.
func (m Model) bindings(contextual []key.Binding) []key.Binding {
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
if m.toggle.Enabled() {
all = append(all, m.toggle)
}
for _, b := range append(append([]key.Binding{}, m.global...), contextual...) {
if b.Enabled() {
all = append(all, b)
}
}
return all
}
// columns reflows bindings into as many columns as fit within the bar's
// width, which is the same as using as few rows as possible. It walks row
// counts upward and returns the first arrangement that fits, so the result is
// the widest (fewest-rows) layout the width allows.
//
// help.FullHelpView fills each column top to bottom, so a group is a column,
// not a row.
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
if m.width <= 0 {
return [][]key.Binding{bindings}
}
for rows := 1; rows < len(bindings); rows++ {
groups := chunkColumns(bindings, rows)
if m.renderedWidth(groups) <= m.width {
return groups
}
}
// Everything in one column: the narrowest arrangement possible. It may
// still overflow, in which case help.FullHelpView truncates as usual.
return chunkColumns(bindings, len(bindings))
}
// renderedWidth measures what FullHelpView would actually produce for
// groups, by asking it - rather than reimplementing its column and separator
// arithmetic here, which would silently drift the moment upstream changes a
// style or a separator.
//
// The width is zeroed first because that's what disables FullHelpView's own
// truncation (see its shouldAddItem): at width 0 it lays every column out in
// full, which is the untruncated width this needs to measure.
func (m Model) renderedWidth(groups [][]key.Binding) int {
unbounded := m.help
unbounded.SetWidth(0)
return lipgloss.Width(unbounded.FullHelpView(groups))
}
// chunkColumns slices bindings into consecutive groups of at most rows each.
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
if rows < 1 {
rows = 1
}
groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows)
for i := 0; i < len(bindings); i += rows {
groups = append(groups, bindings[i:min(i+rows, len(bindings))])
}
return groups
}