add help

Kujtim Hoxha created

Change summary

cmd/termai/main.go                     |   3 
internal/tui/components/core/help.go   | 119 ++++++++++++++++++++++++++++
internal/tui/components/core/status.go |  72 ++++++++++++++++
internal/tui/layout/bento.go           |  10 +-
internal/tui/layout/single.go          |   6 
internal/tui/tui.go                    |  94 ++++++++++++++++++---
internal/tui/util/util.go              |  18 ++++
internal/version/version.go            |  25 +++++
8 files changed, 322 insertions(+), 25 deletions(-)

Detailed changes

cmd/termai/main.go 🔗

@@ -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)
 	}

internal/tui/components/core/help.go 🔗

@@ -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),
+	}
+}

internal/tui/components/core/status.go 🔗

@@ -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{}
+}

internal/tui/layout/bento.go 🔗

@@ -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 {

internal/tui/layout/single.go 🔗

@@ -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)

internal/tui/tui.go 🔗

@@ -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(),

internal/tui/util/util.go 🔗

@@ -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
+)

internal/version/version.go 🔗

@@ -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
+}