mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
add help
This commit is contained in:
@@ -48,11 +48,8 @@ func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
// cleanup function to be invoked when program is terminated.
|
||||
return ch, func() {
|
||||
cancel()
|
||||
// Wait for relays to finish before closing channel, to avoid sends
|
||||
// to a closed channel, which would result in a panic.
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
119
internal/tui/components/core/help.go
Normal file
119
internal/tui/components/core/help.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
)
|
||||
|
||||
type HelpCmp interface {
|
||||
tea.Model
|
||||
SetBindings(bindings []key.Binding)
|
||||
Height() int
|
||||
}
|
||||
|
||||
const (
|
||||
helpWidgetHeight = 12
|
||||
)
|
||||
|
||||
type helpCmp struct {
|
||||
width int
|
||||
bindings []key.Binding
|
||||
}
|
||||
|
||||
func (m *helpCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *helpCmp) View() string {
|
||||
helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
|
||||
helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
|
||||
// Compile list of bindings to render
|
||||
bindings := removeDuplicateBindings(m.bindings)
|
||||
// Enumerate through each group of bindings, populating a series of
|
||||
// pairs of columns, one for keys, one for descriptions
|
||||
var (
|
||||
pairs []string
|
||||
width int
|
||||
rows = helpWidgetHeight - 2
|
||||
)
|
||||
for i := 0; i < len(bindings); i += rows {
|
||||
var (
|
||||
keys []string
|
||||
descs []string
|
||||
)
|
||||
for j := i; j < min(i+rows, len(bindings)); j++ {
|
||||
keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
|
||||
descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
|
||||
}
|
||||
// Render pair of columns; beyond the first pair, render a three space
|
||||
// left margin, in order to visually separate the pairs.
|
||||
var cols []string
|
||||
if len(pairs) > 0 {
|
||||
cols = []string{" "}
|
||||
}
|
||||
cols = append(cols,
|
||||
strings.Join(keys, "\n"),
|
||||
strings.Join(descs, "\n"),
|
||||
)
|
||||
|
||||
pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
|
||||
// check whether it exceeds the maximum width avail (the width of the
|
||||
// terminal, subtracting 2 for the borders).
|
||||
width += lipgloss.Width(pair)
|
||||
if width > m.width-2 {
|
||||
break
|
||||
}
|
||||
pairs = append(pairs, pair)
|
||||
}
|
||||
|
||||
// Join pairs of columns and enclose in a border
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
|
||||
return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(m.width - 2).Render(content)
|
||||
}
|
||||
|
||||
func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]key.Binding, 0, len(bindings))
|
||||
|
||||
// Process bindings in reverse order
|
||||
for i := len(bindings) - 1; i >= 0; i-- {
|
||||
b := bindings[i]
|
||||
k := strings.Join(b.Keys(), " ")
|
||||
if _, ok := seen[k]; ok {
|
||||
// duplicate, skip
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
// Add to the beginning of result to maintain original order
|
||||
result = append([]key.Binding{b}, result...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *helpCmp) SetBindings(bindings []key.Binding) {
|
||||
m.bindings = bindings
|
||||
}
|
||||
|
||||
func (m helpCmp) Height() int {
|
||||
return helpWidgetHeight
|
||||
}
|
||||
|
||||
func NewHelpCmp() HelpCmp {
|
||||
return &helpCmp{
|
||||
width: 0,
|
||||
bindings: make([]key.Binding, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/kujtimiihoxha/termai/internal/version"
|
||||
)
|
||||
|
||||
type statusCmp struct {
|
||||
err error
|
||||
info string
|
||||
width int
|
||||
}
|
||||
|
||||
func (m statusCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
case util.ErrorMsg:
|
||||
m.err = msg
|
||||
case util.InfoMsg:
|
||||
m.info = string(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var (
|
||||
versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
|
||||
helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
|
||||
)
|
||||
|
||||
func (m statusCmp) View() string {
|
||||
status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
|
||||
|
||||
if m.err != nil {
|
||||
status += styles.Regular.Padding(0, 1).
|
||||
Background(styles.Red).
|
||||
Foreground(styles.Text).
|
||||
Width(m.availableFooterMsgWidth()).
|
||||
Render(m.err.Error())
|
||||
} else if m.info != "" {
|
||||
status += styles.Padded.
|
||||
Foreground(styles.Base).
|
||||
Background(styles.Green).
|
||||
Width(m.availableFooterMsgWidth()).
|
||||
Render(m.info)
|
||||
} else {
|
||||
status += styles.Padded.
|
||||
Foreground(styles.Base).
|
||||
Background(styles.LightGrey).
|
||||
Width(m.availableFooterMsgWidth()).
|
||||
Render(m.info)
|
||||
}
|
||||
|
||||
status += versionWidget
|
||||
return status
|
||||
}
|
||||
|
||||
func (m statusCmp) availableFooterMsgWidth() int {
|
||||
// -2 to accommodate padding
|
||||
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget))
|
||||
}
|
||||
|
||||
func NewStatusCmp() tea.Model {
|
||||
return &statusCmp{}
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@ func (b *bentoLayout) GetSize() (int, int) {
|
||||
return b.width, b.height
|
||||
}
|
||||
|
||||
func (b bentoLayout) Init() tea.Cmd {
|
||||
func (b *bentoLayout) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
b.SetSize(msg.Width, msg.Height)
|
||||
@@ -106,7 +106,7 @@ func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b bentoLayout) View() string {
|
||||
func (b *bentoLayout) View() string {
|
||||
if b.width <= 0 || b.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -310,14 +310,14 @@ func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
|
||||
p := make(map[paneID]SinglePaneLayout, len(panes))
|
||||
for id, pane := range panes {
|
||||
// Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
|
||||
if _, ok := pane.(SinglePaneLayout); !ok {
|
||||
if sp, ok := pane.(SinglePaneLayout); !ok {
|
||||
p[id] = NewSinglePane(
|
||||
pane,
|
||||
WithSinglePaneFocusable(true),
|
||||
WithSinglePaneBordered(true),
|
||||
)
|
||||
} else {
|
||||
p[id] = pane.(SinglePaneLayout)
|
||||
p[id] = sp
|
||||
}
|
||||
}
|
||||
if len(p) == 0 {
|
||||
|
||||
@@ -30,11 +30,11 @@ type singlePaneLayout struct {
|
||||
|
||||
type SinglePaneOption func(*singlePaneLayout)
|
||||
|
||||
func (s singlePaneLayout) Init() tea.Cmd {
|
||||
func (s *singlePaneLayout) Init() tea.Cmd {
|
||||
return s.content.Init()
|
||||
}
|
||||
|
||||
func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.SetSize(msg.Width, msg.Height)
|
||||
@@ -45,7 +45,7 @@ func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s singlePaneLayout) View() string {
|
||||
func (s *singlePaneLayout) View() string {
|
||||
style := lipgloss.NewStyle().Width(s.width).Height(s.height)
|
||||
if s.bordered {
|
||||
style = style.Width(s.width).Height(s.height)
|
||||
|
||||
@@ -3,14 +3,19 @@ package tui
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/page"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
Logs key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
Logs key.Binding
|
||||
Return key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
@@ -18,14 +23,22 @@ var keys = keyMap{
|
||||
key.WithKeys("L"),
|
||||
key.WithHelp("L", "logs"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
Return: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "back"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("backspace"),
|
||||
key.WithHelp("backspace", "back"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("ctrl+c", "q"),
|
||||
key.WithHelp("ctrl+c/q", "quit"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
}
|
||||
|
||||
type appModel struct {
|
||||
@@ -34,6 +47,9 @@ type appModel struct {
|
||||
previousPage page.PageID
|
||||
pages map[page.PageID]tea.Model
|
||||
loadedPages map[page.PageID]bool
|
||||
status tea.Model
|
||||
help core.HelpCmp
|
||||
showHelp bool
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -45,28 +61,61 @@ func (a appModel) Init() tea.Cmd {
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
msg.Height -= 1 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
||||
a.status, _ = a.status.Update(msg)
|
||||
|
||||
uh, _ := a.help.Update(msg)
|
||||
a.help = uh.(core.HelpCmp)
|
||||
|
||||
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = p
|
||||
return a, cmd
|
||||
case util.InfoMsg:
|
||||
a.status, _ = a.status.Update(msg)
|
||||
case util.ErrorMsg:
|
||||
a.status, _ = a.status.Update(msg)
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, keys.Quit) {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, tea.Quit
|
||||
}
|
||||
if key.Matches(msg, keys.Back) {
|
||||
case key.Matches(msg, keys.Back):
|
||||
if a.previousPage != "" {
|
||||
return a, a.moveToPage(a.previousPage)
|
||||
}
|
||||
case key.Matches(msg, keys.Return):
|
||||
if a.showHelp {
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
if key.Matches(msg, keys.Logs) {
|
||||
case key.Matches(msg, keys.Logs):
|
||||
return a, a.moveToPage(page.LogsPage)
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||
if p != nil {
|
||||
a.pages[a.currentPage] = p
|
||||
}
|
||||
a.pages[a.currentPage] = p
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *appModel) ToggleHelp() {
|
||||
if a.showHelp {
|
||||
a.showHelp = false
|
||||
a.height += a.help.Height()
|
||||
} else {
|
||||
a.showHelp = true
|
||||
a.height -= a.help.Height()
|
||||
}
|
||||
|
||||
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
|
||||
sizable.SetSize(a.width, a.height)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
@@ -83,13 +132,30 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
return a.pages[a.currentPage].View()
|
||||
components := []string{
|
||||
a.pages[a.currentPage].View(),
|
||||
}
|
||||
|
||||
if a.showHelp {
|
||||
bindings := layout.KeyMapToSlice(keys)
|
||||
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
|
||||
bindings = append(bindings, p.BindingKeys()...)
|
||||
}
|
||||
a.help.SetBindings(bindings)
|
||||
components = append(components, a.help.View())
|
||||
}
|
||||
|
||||
components = append(components, a.status.View())
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Top, components...)
|
||||
}
|
||||
|
||||
func New() tea.Model {
|
||||
return &appModel{
|
||||
currentPage: page.ReplPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(),
|
||||
help: core.NewHelpCmp(),
|
||||
pages: map[page.PageID]tea.Model{
|
||||
page.LogsPage: page.NewLogsPage(),
|
||||
page.InitPage: page.NewInitPage(),
|
||||
|
||||
18
internal/tui/util/util.go
Normal file
18
internal/tui/util/util.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
func CmdHandler(msg tea.Msg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func ReportError(err error) tea.Cmd {
|
||||
return CmdHandler(ErrorMsg(err))
|
||||
}
|
||||
|
||||
type (
|
||||
InfoMsg string
|
||||
ErrorMsg error
|
||||
)
|
||||
25
internal/version/version.go
Normal file
25
internal/version/version.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package version
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
// Build-time parameters set via -ldflags
|
||||
var Version = "unknown"
|
||||
|
||||
// A user may install pug using `go install github.com/leg100/pug@latest`
|
||||
// without -ldflags, in which case the version above is unset. As a workaround
|
||||
// we use the embedded build version that *is* set when using `go install` (and
|
||||
// is only set for `go install` and not for `go build`).
|
||||
func init() {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
// < go v1.18
|
||||
return
|
||||
}
|
||||
mainVersion := info.Main.Version
|
||||
if mainVersion == "" || mainVersion == "(devel)" {
|
||||
// bin not built using `go install`
|
||||
return
|
||||
}
|
||||
// bin built using `go install`
|
||||
Version = mainVersion
|
||||
}
|
||||
Reference in New Issue
Block a user