mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 20:15:49 +02:00
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user