diff --git a/cmd/termai/main.go b/cmd/termai/main.go index 5b71728d8468a0d14303b1084306d9416f8fec33..e1e506b86e2981754f54444fb885fddc496038c0 100644 --- a/cmd/termai/main.go +++ b/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) } diff --git a/internal/tui/components/core/help.go b/internal/tui/components/core/help.go new file mode 100644 index 0000000000000000000000000000000000000000..9402d936256d5971d5601ac83c77990cf48b52de --- /dev/null +++ b/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), + } +} diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..8c9b3f60b7caf4b41d4126875ed69f0f44676f40 100644 --- a/internal/tui/components/core/status.go +++ b/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{} +} diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go index 7d4c070df371649d863d5e90697cf00bb9513ca2..8d1f1d10f15e005391bdf6a954339e2875defa8b 100644 --- a/internal/tui/layout/bento.go +++ b/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 { diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go index 230e454586f792688ca805c061f34990c1dd02a3..b8168225f1727f779333435ab04c1d47bdc982dc 100644 --- a/internal/tui/layout/single.go +++ b/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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 5045e59523d8e78d53c7c4381ed6aa58486f735d..424f576c88fa2a1c41dac0c9dd5077947d44b596 100644 --- a/internal/tui/tui.go +++ b/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(), diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go new file mode 100644 index 0000000000000000000000000000000000000000..fd1c4e8183f73095652fd2d570034eec4a36cf02 --- /dev/null +++ b/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 +) diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000000000000000000000000000000000000..54c576f6c2605f8c678212d776d22f3a103c5656 --- /dev/null +++ b/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 +}