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