Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-18 16:15:03 +02:00
parent 5f99fa7521
commit aa6496901f
48 changed files with 2001 additions and 3626 deletions
+91
View File
@@ -0,0 +1,91 @@
# Helpbar
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows.
## Quick start
```go
import "github.com/anotherhadi/ilovetui/helpbar"
type model struct {
help helpbar.Model
keys keyMap
h int
}
func newModel() model {
keys := defaultKeyMap()
return model{
help: helpbar.New(
helpbar.WithToggle(keys.Help), // '?' expands/collapses
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
),
keys: keys,
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.h = msg.Height
m.help.SetWidth(msg.Width)
case tea.KeyPressMsg:
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.focused().HelpBindings()...)
body := renderBody(m.h - lipgloss.Height(bar))
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
}
```
## Global vs. contextual bindings
Bindings come from two places, and they render in this order:
1. The `WithToggle` binding, so the way to expand the bar always leads.
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
3. Contextual bindings, passed to `View` at render time.
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
```go
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
return m.content.HelpBindings()
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
```
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
budgets width for something that won't be drawn.
## Reserving room for the bar
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
a fixed number of rows:
```go
body := m.height - m.help.Height(contextual...)
```
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
and takes zero rows.
## Styling
Defaults come from the shared `style` theme. Override with `WithStyles`:
```go
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
```
## Examples
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
- `examples/app` full app with a single help bar
+185
View File
@@ -0,0 +1,185 @@
// 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
}