mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
aa6496901f
Signed-off-by: Hadi <hadi@example.fr>
186 lines
6.4 KiB
Go
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
|
|
}
|