on main help sign in
add help
7844cacKujtim Hoxha
cmd/termai/main.go−3
@@ -48,11 +48,8 @@ func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
48 wg.Done()
48 wg.Done()
49 }()
49 }()
50 }
50 }
51− // cleanup function to be invoked when program is terminated.
52 return ch, func() {
51 return ch, func() {
53 cancel()
52 cancel()
54− // Wait for relays to finish before closing channel, to avoid sends
55− // to a closed channel, which would result in a panic.
56 wg.Wait()
53 wg.Wait()
57 close(ch)
54 close(ch)
58 }
55 }
internal/tui/components/core/help.gonew file+119
@@ -0,0 +1,119 @@
1+package core
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
10+)
11+
12+type HelpCmp interface {
13+ tea.Model
14+ SetBindings(bindings []key.Binding)
15+ Height() int
16+}
17+
18+const (
19+ helpWidgetHeight = 12
20+)
21+
22+type helpCmp struct {
23+ width int
24+ bindings []key.Binding
25+}
26+
27+func (m *helpCmp) Init() tea.Cmd {
28+ return nil
29+}
30+
31+func (m *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32+ switch msg := msg.(type) {
33+ case tea.WindowSizeMsg:
34+ m.width = msg.Width
35+ }
36+ return m, nil
37+}
38+
39+func (m *helpCmp) View() string {
40+ helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
41+ helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
42+ // Compile list of bindings to render
43+ bindings := removeDuplicateBindings(m.bindings)
44+ // Enumerate through each group of bindings, populating a series of
45+ // pairs of columns, one for keys, one for descriptions
46+ var (
47+ pairs []string
48+ width int
49+ rows = helpWidgetHeight - 2
50+ )
51+ for i := 0; i < len(bindings); i += rows {
52+ var (
53+ keys []string
54+ descs []string
55+ )
56+ for j := i; j < min(i+rows, len(bindings)); j++ {
57+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
58+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
59+ }
60+ // Render pair of columns; beyond the first pair, render a three space
61+ // left margin, in order to visually separate the pairs.
62+ var cols []string
63+ if len(pairs) > 0 {
64+ cols = []string{" "}
65+ }
66+ cols = append(cols,
67+ strings.Join(keys, "\n"),
68+ strings.Join(descs, "\n"),
69+ )
70+
71+ pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
72+ // check whether it exceeds the maximum width avail (the width of the
73+ // terminal, subtracting 2 for the borders).
74+ width += lipgloss.Width(pair)
75+ if width > m.width-2 {
76+ break
77+ }
78+ pairs = append(pairs, pair)
79+ }
80+
81+ // Join pairs of columns and enclose in a border
82+ content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
83+ return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(m.width - 2).Render(content)
84+}
85+
86+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
87+ seen := make(map[string]struct{})
88+ result := make([]key.Binding, 0, len(bindings))
89+
90+ // Process bindings in reverse order
91+ for i := len(bindings) - 1; i >= 0; i-- {
92+ b := bindings[i]
93+ k := strings.Join(b.Keys(), " ")
94+ if _, ok := seen[k]; ok {
95+ // duplicate, skip
96+ continue
97+ }
98+ seen[k] = struct{}{}
99+ // Add to the beginning of result to maintain original order
100+ result = append([]key.Binding{b}, result...)
101+ }
102+
103+ return result
104+}
105+
106+func (m *helpCmp) SetBindings(bindings []key.Binding) {
107+ m.bindings = bindings
108+}
109+
110+func (m helpCmp) Height() int {
111+ return helpWidgetHeight
112+}
113+
114+func NewHelpCmp() HelpCmp {
115+ return &helpCmp{
116+ width: 0,
117+ bindings: make([]key.Binding, 0),
118+ }
119+}
internal/tui/components/core/status.go+72
@@ -0,0 +1,72 @@
1+package core
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea"
5+ "github.com/charmbracelet/lipgloss"
6+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
7+ "github.com/kujtimiihoxha/termai/internal/tui/util"
8+ "github.com/kujtimiihoxha/termai/internal/version"
9+)
10+
11+type statusCmp struct {
12+ err error
13+ info string
14+ width int
15+}
16+
17+func (m statusCmp) Init() tea.Cmd {
18+ return nil
19+}
20+
21+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
22+ switch msg := msg.(type) {
23+ case tea.WindowSizeMsg:
24+ m.width = msg.Width
25+ case util.ErrorMsg:
26+ m.err = msg
27+ case util.InfoMsg:
28+ m.info = string(msg)
29+ }
30+ return m, nil
31+}
32+
33+var (
34+ versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
35+ helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
36+)
37+
38+func (m statusCmp) View() string {
39+ status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
40+
41+ if m.err != nil {
42+ status += styles.Regular.Padding(0, 1).
43+ Background(styles.Red).
44+ Foreground(styles.Text).
45+ Width(m.availableFooterMsgWidth()).
46+ Render(m.err.Error())
47+ } else if m.info != "" {
48+ status += styles.Padded.
49+ Foreground(styles.Base).
50+ Background(styles.Green).
51+ Width(m.availableFooterMsgWidth()).
52+ Render(m.info)
53+ } else {
54+ status += styles.Padded.
55+ Foreground(styles.Base).
56+ Background(styles.LightGrey).
57+ Width(m.availableFooterMsgWidth()).
58+ Render(m.info)
59+ }
60+
61+ status += versionWidget
62+ return status
63+}
64+
65+func (m statusCmp) availableFooterMsgWidth() int {
66+ // -2 to accommodate padding
67+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget))
68+}
69+
70+func NewStatusCmp() tea.Model {
71+ return &statusCmp{}
72+}
internal/tui/layout/bento.go+5−5
@@ -72,11 +72,11 @@ func (b *bentoLayout) GetSize() (int, int) {
72 return b.width, b.height
72 return b.width, b.height
73}
73}
74
74
75−func (b bentoLayout) Init() tea.Cmd {
75+func (b *bentoLayout) Init() tea.Cmd {
76 return nil
76 return nil
77}
77}
78
78
79−func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
79+func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 switch msg := msg.(type) {
80 switch msg := msg.(type) {
81 case tea.WindowSizeMsg:
81 case tea.WindowSizeMsg:
82 b.SetSize(msg.Width, msg.Height)
82 b.SetSize(msg.Width, msg.Height)
@@ -106,7 +106,7 @@ func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106 return b, nil
106 return b, nil
107}
107}
108
108
109−func (b bentoLayout) View() string {
109+func (b *bentoLayout) View() string {
110 if b.width <= 0 || b.height <= 0 {
110 if b.width <= 0 || b.height <= 0 {
111 return ""
111 return ""
112 }
112 }
@@ -310,14 +310,14 @@ func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
310 p := make(map[paneID]SinglePaneLayout, len(panes))
310 p := make(map[paneID]SinglePaneLayout, len(panes))
311 for id, pane := range panes {
311 for id, pane := range panes {
312 // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
312 // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
313− if _, ok := pane.(SinglePaneLayout); !ok {
313+ if sp, ok := pane.(SinglePaneLayout); !ok {
314 p[id] = NewSinglePane(
314 p[id] = NewSinglePane(
315 pane,
315 pane,
316 WithSinglePaneFocusable(true),
316 WithSinglePaneFocusable(true),
317 WithSinglePaneBordered(true),
317 WithSinglePaneBordered(true),
318 )
318 )
319 } else {
319 } else {
320− p[id] = pane.(SinglePaneLayout)
320+ p[id] = sp
321 }
321 }
322 }
322 }
323 if len(p) == 0 {
323 if len(p) == 0 {
internal/tui/layout/single.go+3−3
@@ -30,11 +30,11 @@ type singlePaneLayout struct {
30
30
31type SinglePaneOption func(*singlePaneLayout)
31type SinglePaneOption func(*singlePaneLayout)
32
32
33−func (s singlePaneLayout) Init() tea.Cmd {
33+func (s *singlePaneLayout) Init() tea.Cmd {
34 return s.content.Init()
34 return s.content.Init()
35}
35}
36
36
37−func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37+func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38 switch msg := msg.(type) {
38 switch msg := msg.(type) {
39 case tea.WindowSizeMsg:
39 case tea.WindowSizeMsg:
40 s.SetSize(msg.Width, msg.Height)
40 s.SetSize(msg.Width, msg.Height)
@@ -45,7 +45,7 @@ func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45 return s, cmd
45 return s, cmd
46}
46}
47
47
48−func (s singlePaneLayout) View() string {
48+func (s *singlePaneLayout) View() string {
49 style := lipgloss.NewStyle().Width(s.width).Height(s.height)
49 style := lipgloss.NewStyle().Width(s.width).Height(s.height)
50 if s.bordered {
50 if s.bordered {
51 style = style.Width(s.width).Height(s.height)
51 style = style.Width(s.width).Height(s.height)
internal/tui/tui.go+80−14
@@ -3,14 +3,19 @@ package tui
3import (
3import (
4 "github.com/charmbracelet/bubbles/key"
4 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
5 tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/tui/components/core"
6 "github.com/kujtimiihoxha/termai/internal/tui/layout"
8 "github.com/kujtimiihoxha/termai/internal/tui/layout"
7 "github.com/kujtimiihoxha/termai/internal/tui/page"
9 "github.com/kujtimiihoxha/termai/internal/tui/page"
10+ "github.com/kujtimiihoxha/termai/internal/tui/util"
8)
11)
9
12
10type keyMap struct {
13type keyMap struct {
14+ Logs key.Binding
11− Logs key.Binding
15+ Return key.Binding
12− Back key.Binding
16+ Back key.Binding
17+ Quit key.Binding
13− Quit key.Binding
18+ Help key.Binding
14}
19}
15
20
16var keys = keyMap{
21var keys = keyMap{
@@ -18,14 +23,22 @@ var keys = keyMap{
18 key.WithKeys("L"),
23 key.WithKeys("L"),
19 key.WithHelp("L", "logs"),
24 key.WithHelp("L", "logs"),
20 ),
25 ),
21− Back: key.NewBinding(
26+ Return: key.NewBinding(
22 key.WithKeys("esc"),
27 key.WithKeys("esc"),
23− key.WithHelp("esc", "back"),
28+ key.WithHelp("esc", "close"),
29+ ),
30+ Back: key.NewBinding(
31+ key.WithKeys("backspace"),
32+ key.WithHelp("backspace", "back"),
24 ),
33 ),
25 Quit: key.NewBinding(
34 Quit: key.NewBinding(
26 key.WithKeys("ctrl+c", "q"),
35 key.WithKeys("ctrl+c", "q"),
27 key.WithHelp("ctrl+c/q", "quit"),
36 key.WithHelp("ctrl+c/q", "quit"),
28 ),
37 ),
38+ Help: key.NewBinding(
39+ key.WithKeys("?"),
40+ key.WithHelp("?", "toggle help"),
41+ ),
29}
42}
30
43
31type appModel struct {
44type appModel struct {
@@ -34,6 +47,9 @@ type appModel struct {
34 previousPage page.PageID
47 previousPage page.PageID
35 pages map[page.PageID]tea.Model
48 pages map[page.PageID]tea.Model
36 loadedPages map[page.PageID]bool
49 loadedPages map[page.PageID]bool
50+ status tea.Model
51+ help core.HelpCmp
52+ showHelp bool
37}
53}
38
54
39func (a appModel) Init() tea.Cmd {
55func (a appModel) Init() tea.Cmd {
@@ -45,28 +61,61 @@ func (a appModel) Init() tea.Cmd {
45func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46 switch msg := msg.(type) {
62 switch msg := msg.(type) {
47 case tea.WindowSizeMsg:
63 case tea.WindowSizeMsg:
64+ msg.Height -= 1 // Make space for the status bar
48 a.width, a.height = msg.Width, msg.Height
65 a.width, a.height = msg.Width, msg.Height
66+
67+ a.status, _ = a.status.Update(msg)
68+
69+ uh, _ := a.help.Update(msg)
70+ a.help = uh.(core.HelpCmp)
71+
72+ p, cmd := a.pages[a.currentPage].Update(msg)
73+ a.pages[a.currentPage] = p
74+ return a, cmd
75+ case util.InfoMsg:
76+ a.status, _ = a.status.Update(msg)
77+ case util.ErrorMsg:
78+ a.status, _ = a.status.Update(msg)
49 case tea.KeyMsg:
79 case tea.KeyMsg:
80+ switch {
50− if key.Matches(msg, keys.Quit) {
81+ case key.Matches(msg, keys.Quit):
51 return a, tea.Quit
82 return a, tea.Quit
52− }
53− if key.Matches(msg, keys.Back) {
83+ case key.Matches(msg, keys.Back):
54 if a.previousPage != "" {
84 if a.previousPage != "" {
55 return a, a.moveToPage(a.previousPage)
85 return a, a.moveToPage(a.previousPage)
56 }
86 }
87+ case key.Matches(msg, keys.Return):
88+ if a.showHelp {
89+ a.ToggleHelp()
90+ return a, nil
91+ }
57 return a, nil
92 return a, nil
58− }
59− if key.Matches(msg, keys.Logs) {
93+ case key.Matches(msg, keys.Logs):
60 return a, a.moveToPage(page.LogsPage)
94 return a, a.moveToPage(page.LogsPage)
95+ case key.Matches(msg, keys.Help):
96+ a.ToggleHelp()
97+ return a, nil
61 }
98 }
62 }
99 }
63 p, cmd := a.pages[a.currentPage].Update(msg)
100 p, cmd := a.pages[a.currentPage].Update(msg)
64− if p != nil {
65− a.pages[a.currentPage] = p
101+ a.pages[a.currentPage] = p
66− }
67 return a, cmd
102 return a, cmd
68}
103}
69
104
105+func (a *appModel) ToggleHelp() {
106+ if a.showHelp {
107+ a.showHelp = false
108+ a.height += a.help.Height()
109+ } else {
110+ a.showHelp = true
111+ a.height -= a.help.Height()
112+ }
113+
114+ if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
115+ sizable.SetSize(a.width, a.height)
116+ }
117+}
118+
70func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
119func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
71 var cmd tea.Cmd
120 var cmd tea.Cmd
72 if _, ok := a.loadedPages[pageID]; !ok {
121 if _, ok := a.loadedPages[pageID]; !ok {
@@ -83,13 +132,30 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
83}
132}
84
133
85func (a appModel) View() string {
134func (a appModel) View() string {
86− return a.pages[a.currentPage].View()
135+ components := []string{
136+ a.pages[a.currentPage].View(),
137+ }
138+
139+ if a.showHelp {
140+ bindings := layout.KeyMapToSlice(keys)
141+ if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
142+ bindings = append(bindings, p.BindingKeys()...)
143+ }
144+ a.help.SetBindings(bindings)
145+ components = append(components, a.help.View())
146+ }
147+
148+ components = append(components, a.status.View())
149+
150+ return lipgloss.JoinVertical(lipgloss.Top, components...)
87}
151}
88
152
89func New() tea.Model {
153func New() tea.Model {
90 return &appModel{
154 return &appModel{
91 currentPage: page.ReplPage,
155 currentPage: page.ReplPage,
92 loadedPages: make(map[page.PageID]bool),
156 loadedPages: make(map[page.PageID]bool),
157+ status: core.NewStatusCmp(),
158+ help: core.NewHelpCmp(),
93 pages: map[page.PageID]tea.Model{
159 pages: map[page.PageID]tea.Model{
94 page.LogsPage: page.NewLogsPage(),
160 page.LogsPage: page.NewLogsPage(),
95 page.InitPage: page.NewInitPage(),
161 page.InitPage: page.NewInitPage(),
internal/tui/util/util.gonew file+18
@@ -0,0 +1,18 @@
1+package util
2+
3+import tea "github.com/charmbracelet/bubbletea"
4+
5+func CmdHandler(msg tea.Msg) tea.Cmd {
6+ return func() tea.Msg {
7+ return msg
8+ }
9+}
10+
11+func ReportError(err error) tea.Cmd {
12+ return CmdHandler(ErrorMsg(err))
13+}
14+
15+type (
16+ InfoMsg string
17+ ErrorMsg error
18+)
internal/version/version.gonew file+25
@@ -0,0 +1,25 @@
1+package version
2+
3+import "runtime/debug"
4+
5+// Build-time parameters set via -ldflags
6+var Version = "unknown"
7+
8+// A user may install pug using `go install github.com/leg100/pug@latest`
9+// without -ldflags, in which case the version above is unset. As a workaround
10+// we use the embedded build version that *is* set when using `go install` (and
11+// is only set for `go install` and not for `go build`).
12+func init() {
13+ info, ok := debug.ReadBuildInfo()
14+ if !ok {
15+ // < go v1.18
16+ return
17+ }
18+ mainVersion := info.Main.Version
19+ if mainVersion == "" || mainVersion == "(devel)" {
20+ // bin not built using `go install`
21+ return
22+ }
23+ // bin built using `go install`
24+ Version = mainVersion
25+}