Generally "smooth" out style and layout

Christian Rocha created

Change summary

tui/bubble.go                   | 84 +++++++++++++++++++++++++---------
tui/bubbles/repo/bubble.go      | 46 +++++++++++++-----
tui/bubbles/selection/bubble.go | 14 ++++-
tui/bubbles/selection/style.go  | 11 ----
tui/commands.go                 | 20 +++++---
tui/help.go                     | 12 -----
tui/style.go                    | 77 ++++++++++++++++++++-----------
7 files changed, 166 insertions(+), 98 deletions(-)

Detailed changes

tui/bubble.go 🔗

@@ -2,7 +2,6 @@ package tui
 
 import (
 	"fmt"
-	"io"
 	"smoothie/git"
 	"smoothie/tui/bubbles/commits"
 	"smoothie/tui/bubbles/repo"
@@ -91,7 +90,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		case "q", "ctrl+c":
 			return b, tea.Quit
-		case "tab":
+		case "tab", "shift+tab":
 			b.activeBox = (b.activeBox + 1) % 2
 		case "h", "left":
 			if b.activeBox > 0 {
@@ -118,6 +117,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, cmd)
 			}
 		}
+		// XXX: maybe propagate size changes to child bubbles (particularly height)
 	case selection.SelectedMsg:
 		b.activeBox = 1
 		rb := b.repoMenu[msg.Index].bubble
@@ -138,48 +138,88 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return b, tea.Batch(cmds...)
 }
 
-func (b *Bubble) viewForBox(i int, width int, height int) string {
-	var ls lipgloss.Style
-	if i == b.activeBox {
-		ls = activeBoxStyle.Copy()
-	} else {
-		ls = inactiveBoxStyle.Copy()
-	}
-	ls.Width(width)
-	if height > 0 {
-		ls.Height(height).MarginBottom(3)
+func (b *Bubble) viewForBox(i int) string {
+	box := b.boxes[i]
+	isActive := i == b.activeBox
+	var s lipgloss.Style
+	var menuHeightFix int // TODO: figure out why we need this
+	switch box.(type) {
+	case *selection.Bubble:
+		menuHeightFix = 1
+		if isActive {
+			s = menuActiveStyle
+			break
+		}
+		s = menuStyle
+	case *repo.Bubble:
+		if isActive {
+			s = contentBoxActiveStyle
+		} else {
+			s = contentBoxStyle
+		}
+		const repoWidthFix = 1 // TODO: figure out why we need this
+		w := b.width -
+			lipgloss.Width(b.viewForBox(0)) -
+			appBoxStyle.GetHorizontalFrameSize() -
+			s.GetHorizontalFrameSize() + repoWidthFix
+		s = s.Copy().Width(w)
+	default:
+		panic(fmt.Sprintf("unknown box type %T", box))
 	}
-	return ls.Render(b.boxes[i].View())
+	h := b.height -
+		lipgloss.Height(b.headerView()) -
+		lipgloss.Height(b.footerView()) -
+		s.GetVerticalFrameSize() -
+		appBoxStyle.GetVerticalFrameSize() +
+		menuHeightFix
+	return s.Copy().Height(h).Render(box.View())
 }
 
-func (b Bubble) footerView(w io.Writer) {
+func (b Bubble) headerView() string {
+	w := b.width - appBoxStyle.GetHorizontalFrameSize()
+	return headerStyle.Copy().Width(w).Render(b.config.Name)
+}
+
+func (b Bubble) footerView() string {
+	w := &strings.Builder{}
 	h := []helpEntry{
 		{"tab", "section"},
 		{"↑/↓", "navigate"},
 		{"q", "quit"},
 	}
+	if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
+		h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
+	}
 	for i, v := range h {
 		fmt.Fprint(w, v)
 		if i != len(h)-1 {
 			fmt.Fprint(w, helpDivider)
 		}
 	}
+	return footerStyle.Render(w.String())
 }
 
-func (b *Bubble) View() string {
+func (b Bubble) View() string {
 	s := strings.Builder{}
-	w := b.width - 3
-	s.WriteString(headerStyle.Width(w - 2).Render(b.config.Name))
+	s.WriteString(b.headerView())
 	s.WriteRune('\n')
 	switch b.state {
 	case loadedState:
-		lb := b.viewForBox(0, boxLeftWidth, 0)
-		rb := b.viewForBox(1, b.width-boxLeftWidth-10, b.height-8)
+		lb := b.viewForBox(0)
+		rb := b.viewForBox(1)
 		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
 	case errorState:
 		s.WriteString(errorStyle.Render(fmt.Sprintf("Bummer: %s", b.error)))
 	}
-	s.WriteRune('\n')
-	b.footerView(&s)
-	return appBoxStyle.Width(w).Height(b.height).Render(s.String())
+	s.WriteString(b.footerView())
+	return appBoxStyle.Render(s.String())
+}
+
+type helpEntry struct {
+	key string
+	val string
+}
+
+func (h helpEntry) String() string {
+	return fmt.Sprintf("%s %s", helpKeyStyle.Render(h.key), helpValueStyle.Render(h.val))
 }

tui/bubbles/repo/bubble.go 🔗

@@ -8,8 +8,11 @@ import (
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/lipgloss"
 )
 
+const glamourMaxWidth = 120
+
 type ErrMsg struct {
 	Error error
 }
@@ -28,21 +31,18 @@ type Bubble struct {
 }
 
 func NewBubble(rs *git.RepoSource, name string, width, wm, height, hm int, tmp interface{}) *Bubble {
-	return &Bubble{
+	b := &Bubble{
 		templateObject: tmp,
 		repoSource:     rs,
 		name:           name,
-		height:         height,
-		width:          width,
 		heightMargin:   hm,
 		widthMargin:    wm,
 		readmeViewport: &ViewportBubble{
-			Viewport: &viewport.Model{
-				Width:  width - wm,
-				Height: height - hm,
-			},
+			Viewport: &viewport.Model{},
 		},
 	}
+	b.SetSize(width, height)
+	return b
 }
 
 func (b *Bubble) Init() tea.Cmd {
@@ -53,8 +53,10 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		b.readmeViewport.Viewport.Width = msg.Width - b.widthMargin
-		b.readmeViewport.Viewport.Height = msg.Height - b.heightMargin
+		b.SetSize(msg.Width, msg.Height)
+		// XXX: if we find that longer readmes take more than a few
+		// milliseconds to render we may need to move Glamour rendering into a
+		// command.
 		md, err := b.glamourize(b.readme)
 		if err != nil {
 			return b, nil
@@ -63,12 +65,17 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 	rv, cmd := b.readmeViewport.Update(msg)
 	b.readmeViewport = rv.(*ViewportBubble)
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
+	cmds = append(cmds, cmd)
 	return b, tea.Batch(cmds...)
 }
 
+func (b *Bubble) SetSize(w, h int) {
+	b.width = w
+	b.height = h
+	b.readmeViewport.Viewport.Width = w - b.widthMargin
+	b.readmeViewport.Viewport.Height = h - b.heightMargin
+}
+
 func (b *Bubble) GotoTop() {
 	b.readmeViewport.Viewport.GotoTop()
 }
@@ -116,9 +123,14 @@ func (b *Bubble) templatize(mdt string) (string, error) {
 }
 
 func (b *Bubble) glamourize(md string) (string, error) {
+	// TODO: read gaps in appropriate style to remove the magic number below.
+	w := b.width - b.widthMargin - 2
+	if w > glamourMaxWidth {
+		w = glamourMaxWidth
+	}
 	tr, err := glamour.NewTermRenderer(
 		glamour.WithStandardStyle("dark"),
-		glamour.WithWordWrap(b.width-b.widthMargin),
+		glamour.WithWordWrap(w),
 	)
 
 	if err != nil {
@@ -128,5 +140,13 @@ func (b *Bubble) glamourize(md string) (string, error) {
 	if err != nil {
 		return "", err
 	}
+	// Enforce a maximum width for cases when glamour lines run long.
+	//
+	// TODO: use Reflow's unconditional wrapping to force-wrap long lines. This
+	// should utlimately happen as a Glamour option.
+	//
+	// See:
+	// https://github.com/muesli/reflow#unconditional-wrapping
+	mdt = lipgloss.NewStyle().MaxWidth(w).Render(mdt)
 	return mdt, nil
 }

tui/bubbles/selection/bubble.go 🔗

@@ -18,14 +18,16 @@ type ActiveMsg struct {
 type Bubble struct {
 	NormalStyle   lipgloss.Style
 	SelectedStyle lipgloss.Style
+	Cursor        string
 	Items         []string
 	SelectedItem  int
 }
 
-func NewBubble(items []string) *Bubble {
+func NewBubble(items []string, normalStyle, selectedStyle lipgloss.Style, cursor string) *Bubble {
 	return &Bubble{
 		NormalStyle:   normalStyle,
 		SelectedStyle: selectedStyle,
+		Cursor:        cursor,
 		Items:         items,
 	}
 }
@@ -34,13 +36,17 @@ func (b *Bubble) Init() tea.Cmd {
 	return nil
 }
 
-func (b *Bubble) View() string {
+func (b Bubble) View() string {
 	s := ""
 	for i, item := range b.Items {
 		if i == b.SelectedItem {
-			s += b.SelectedStyle.Render(item) + "\n"
+			s += b.Cursor
+			s += b.SelectedStyle.Render(item)
 		} else {
-			s += b.NormalStyle.Render(item) + "\n"
+			s += b.NormalStyle.Render(item)
+		}
+		if i < len(b.Items)-1 {
+			s += "\n"
 		}
 	}
 	return s

tui/bubbles/selection/style.go 🔗

@@ -1,11 +0,0 @@
-package selection
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-var normalStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#707070"))
-
-var selectedStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#FFFFFF"))

tui/commands.go 🔗

@@ -2,7 +2,6 @@ package tui
 
 import (
 	"fmt"
-	"smoothie/tui/bubbles/commits"
 	"smoothie/tui/bubbles/repo"
 	"smoothie/tui/bubbles/selection"
 
@@ -49,7 +48,10 @@ func (b *Bubble) setupCmd() tea.Msg {
 		if me.Repo == "config" {
 			tmplConfig = b.config
 		}
-		rb := repo.NewBubble(b.repoSource, me.Repo, b.width, boxLeftWidth+12, b.height, 12, tmplConfig)
+		width := b.width
+		boxLeftWidth := menuStyle.GetWidth() + menuStyle.GetHorizontalFrameSize()
+		const heightMargin = 12 // TODO: figure out why this needs to be 12
+		rb := repo.NewBubble(b.repoSource, me.Repo, width, boxLeftWidth, b.height, heightMargin, tmplConfig)
 		initCmd := rb.Init()
 		msg := initCmd()
 		switch msg := msg.(type) {
@@ -60,13 +62,15 @@ func (b *Bubble) setupCmd() tea.Msg {
 		b.repoMenu = append(b.repoMenu, me)
 		rs = append(rs, me.Name)
 	}
-	b.repoSelect = selection.NewBubble(rs)
+	b.repoSelect = selection.NewBubble(rs, menuItemStyle, selectedMenuItemStyle, menuCursor.String())
 	b.boxes[0] = b.repoSelect
-	b.commitsLog = commits.NewBubble(
-		b.height-verticalPadding-2,
-		boxRightWidth-horizontalPadding-2,
-		b.repoSource.GetCommits(200),
-	)
+	/*
+		b.commitsLog = commits.NewBubble(
+			b.height-verticalPadding-2,
+			boxRightWidth-horizontalPadding-2,
+			b.repoSource.GetCommits(200),
+		)
+	*/
 	ir := -1
 	if b.initialRepo != "" {
 		for i, me := range b.repoMenu {

tui/help.go 🔗

@@ -1,12 +0,0 @@
-package tui
-
-import "fmt"
-
-type helpEntry struct {
-	key string
-	val string
-}
-
-func (h helpEntry) String() string {
-	return fmt.Sprintf("%s %s", helpKeyStyle.Render(h.key), helpValueStyle.Render(h.val))
-}

tui/style.go 🔗

@@ -4,42 +4,48 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
-const boxLeftWidth = 25
-const boxRightWidth = 85
-const headerHeight = 1
-const footerHeight = 2
-const appPadding = 1
-const boxPadding = 1
-const viewportHeightConstant = 7 // TODO figure out why this needs to be 7
-const horizontalPadding = appPadding * 2
-const verticalPadding = headerHeight + footerHeight + (appPadding * 2)
-
-var appBoxStyle = lipgloss.NewStyle().
-	PaddingLeft(appPadding).
-	PaddingRight(appPadding)
-
-var inactiveBoxStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#606060")).
+var activeBorderColor = lipgloss.Color("243")
+var inactiveBorderColor = lipgloss.Color("236")
+
+var hiddenBorder = lipgloss.Border{
+	TopLeft:     " ",
+	Top:         " ",
+	TopRight:    " ",
+	BottomLeft:  " ",
+	Bottom:      " ",
+	BottomRight: " ",
+}
+
+var appBoxStyle = lipgloss.NewStyle()
+
+var menuStyle = lipgloss.NewStyle().
+	BorderStyle(lipgloss.RoundedBorder()).
+	BorderForeground(inactiveBorderColor).
+	Padding(1, 2).
+	MarginRight(1).
+	Width(24)
+
+var menuActiveStyle = menuStyle.Copy().
+	BorderStyle(lipgloss.RoundedBorder()).
+	BorderForeground(activeBorderColor)
+
+var contentBoxStyle = lipgloss.NewStyle().
 	BorderStyle(lipgloss.RoundedBorder()).
-	BorderForeground(lipgloss.Color("#303030")).
-	Padding(boxPadding)
+	BorderForeground(inactiveBorderColor).
+	PaddingRight(1).
+	MarginBottom(1)
 
-var activeBoxStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#FFFFFF")).
+var contentBoxActiveStyle = contentBoxStyle.Copy().
 	BorderStyle(lipgloss.RoundedBorder()).
-	BorderForeground(lipgloss.Color("#714C7B")).
-	Padding(boxPadding)
+	BorderForeground(activeBorderColor)
 
 var headerStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#714C7B")).
+	Foreground(lipgloss.Color("61")).
 	Align(lipgloss.Right).
 	Bold(true)
 
-var normalStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#FFFFFF"))
-
-var errorStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("#FF00000"))
+var footerStyle = lipgloss.NewStyle().
+	MarginTop(1)
 
 var helpKeyStyle = lipgloss.NewStyle().
 	Foreground(lipgloss.Color("241"))
@@ -47,6 +53,21 @@ var helpKeyStyle = lipgloss.NewStyle().
 var helpValueStyle = lipgloss.NewStyle().
 	Foreground(lipgloss.Color("239"))
 
+var menuItemStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("252")).
+	PaddingLeft(2)
+
+var selectedMenuItemStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("207")).
+	PaddingLeft(1)
+
+var menuCursor = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("213")).
+	SetString(">")
+
+var errorStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#FF00000"))
+
 var helpDivider = lipgloss.NewStyle().
 	Foreground(lipgloss.Color("237")).
 	SetString(" • ")