This commit is contained in:
Kujtim Hoxha
2025-03-23 14:56:32 +01:00
parent 4b0ea68d7a
commit 7844cacb25
8 changed files with 322 additions and 25 deletions

View File

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

View 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),
}
}

View File

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

View File

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

View File

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

View File

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

View 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
}