feat: selection tabs redesign

Ayman Bagabas created

Change summary

ui/components/code/code.go      |   6 +
ui/components/tabs/tabs.go      |  25 +++++--
ui/git.go                       |  25 ++++++++
ui/pages/selection/selection.go | 106 ++++++++++++++++++++++------------
ui/styles/styles.go             |   6 -
5 files changed, 117 insertions(+), 51 deletions(-)

Detailed changes

ui/components/code/code.go 🔗

@@ -161,6 +161,10 @@ func (r *Code) ScrollPercent() float64 {
 func (r *Code) glamourize(w int, md string) (string, error) {
 	r.renderMutex.Lock()
 	defer r.renderMutex.Unlock()
+	// This fixes a bug with markdown text wrapping being off by one.
+	if w > 0 {
+		w--
+	}
 	tr, err := glamour.NewTermRenderer(
 		glamour.WithStyles(r.styleConfig),
 		glamour.WithWordWrap(w),
@@ -202,7 +206,7 @@ func (r *Code) renderFile(path, content string, width int) (string, error) {
 	rc := r.renderContext
 	if r.showLineNumber {
 		st := common.StyleConfig()
-		var m uint = 0
+		m := uint(0)
 		st.CodeBlock.Margin = &m
 		rc = gansi.NewRenderContext(gansi.Options{
 			ColorProfile: termenv.TrueColor,

ui/components/tabs/tabs.go 🔗

@@ -4,6 +4,7 @@ import (
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
@@ -15,17 +16,23 @@ type ActiveTabMsg int
 
 // Tabs is bubbletea component that displays a list of tabs.
 type Tabs struct {
-	common    common.Common
-	tabs      []string
-	activeTab int
+	common       common.Common
+	tabs         []string
+	activeTab    int
+	TabSeparator lipgloss.Style
+	TabInactive  lipgloss.Style
+	TabActive    lipgloss.Style
 }
 
 // New creates a new Tabs component.
 func New(c common.Common, tabs []string) *Tabs {
 	r := &Tabs{
-		common:    c,
-		tabs:      tabs,
-		activeTab: 0,
+		common:       c,
+		tabs:         tabs,
+		activeTab:    0,
+		TabSeparator: c.Styles.TabSeparator,
+		TabInactive:  c.Styles.TabInactive,
+		TabActive:    c.Styles.TabActive,
 	}
 	return r
 }
@@ -66,11 +73,11 @@ func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // View implements tea.Model.
 func (t *Tabs) View() string {
 	s := strings.Builder{}
-	sep := t.common.Styles.TabSeparator
+	sep := t.TabSeparator
 	for i, tab := range t.tabs {
-		style := t.common.Styles.TabInactive.Copy()
+		style := t.TabInactive.Copy()
 		if i == t.activeTab {
-			style = t.common.Styles.TabActive.Copy()
+			style = t.TabActive.Copy()
 		}
 		s.WriteString(style.Render(tab))
 		if i != len(t.tabs)-1 {

ui/git.go 🔗

@@ -0,0 +1,25 @@
+package ui
+
+import (
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
+type source struct {
+	*config.RepoSource
+}
+
+// GetRepo implements git.GitRepoSource.
+func (s *source) GetRepo(name string) (git.GitRepo, error) {
+	return s.RepoSource.GetRepo(name)
+}
+
+// AllRepos implements git.GitRepoSource.
+func (s *source) AllRepos() []git.GitRepo {
+	rs := make([]git.GitRepo, 0)
+	for _, r := range s.RepoSource.AllRepos() {
+		rs = append(rs, r)
+	}
+	return rs
+}

ui/pages/selection/selection.go 🔗

@@ -1,6 +1,7 @@
 package selection
 
 import (
+	"fmt"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/key"
@@ -10,6 +11,7 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/code"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 	"github.com/charmbracelet/soft-serve/ui/git"
 	wgit "github.com/charmbracelet/wish/git"
 	"github.com/gliderlabs/ssh"
@@ -31,23 +33,34 @@ type Selection struct {
 	readmeHeight int
 	selector     *selector.Selector
 	activeBox    box
+	tabs         *tabs.Tabs
 }
 
 // New creates a new selection model.
 func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
+	t := tabs.New(common, []string{"About", "Repositories"})
+	t.TabSeparator = lipgloss.NewStyle()
+	t.TabInactive = lipgloss.NewStyle().
+		Bold(true).
+		UnsetBackground().
+		Foreground(common.Styles.InactiveBorderColor).
+		Padding(0, 1)
+	t.TabActive = t.TabInactive.Copy().
+		Background(lipgloss.Color("62")).
+		Foreground(lipgloss.Color("230"))
 	sel := &Selection{
 		cfg:       cfg,
 		pk:        pk,
 		common:    common,
-		activeBox: selectorBox, // start with the selector focused
+		activeBox: readmeBox, // start with the selector focused
+		tabs:      t,
 	}
 	readme := code.New(common, "", "")
 	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
 	selector := selector.New(common,
 		[]selector.IdentifiableItem{},
 		ItemDelegate{&common, &sel.activeBox})
-	selector.SetShowTitle(true)
-	selector.Title = "Repositories"
+	selector.SetShowTitle(false)
 	selector.SetShowHelp(false)
 	selector.SetShowStatusBar(false)
 	selector.DisableQuitKeybindings()
@@ -56,21 +69,19 @@ func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection
 	return sel
 }
 
-func (s *Selection) getReadmeHeight() int {
-	rh := s.readmeHeight
-	if rh > s.common.Height/3 {
-		rh = s.common.Height / 3
-	}
-	return rh
-}
-
 func (s *Selection) getMargins() (wm, hm int) {
 	wm = 0
-	hm = s.common.Styles.SelectorBox.GetVerticalFrameSize() +
-		s.common.Styles.SelectorBox.GetHeight()
-	if rh := s.getReadmeHeight(); rh > 0 {
+	hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
+		s.common.Styles.Tabs.GetHeight() +
+		2 // tabs margin see View()
+	switch s.activeBox {
+	case selectorBox:
+		hm += s.common.Styles.SelectorBox.GetVerticalFrameSize() +
+			s.common.Styles.SelectorBox.GetHeight()
+	case readmeBox:
 		hm += s.common.Styles.ReadmeBox.GetVerticalFrameSize() +
-			rh
+			s.common.Styles.ReadmeBox.GetHeight() +
+			1 // readme statusbar
 	}
 	return
 }
@@ -79,8 +90,9 @@ func (s *Selection) getMargins() (wm, hm int) {
 func (s *Selection) SetSize(width, height int) {
 	s.common.SetSize(width, height)
 	wm, hm := s.getMargins()
-	s.readme.SetSize(width-wm, s.getReadmeHeight())
+	s.tabs.SetSize(width, height-hm)
 	s.selector.SetSize(width-wm, height-hm)
+	s.readme.SetSize(width-wm, height-hm)
 }
 
 // ShortHelp implements help.KeyMap.
@@ -232,13 +244,21 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, s.common.KeyMap.Section):
-			s.activeBox = (s.activeBox + 1) % 2
-		case key.Matches(msg, s.common.KeyMap.Back):
-			cmds = append(cmds, s.selector.Init())
+	case tea.KeyMsg, tea.MouseMsg:
+		switch msg := msg.(type) {
+		case tea.KeyMsg:
+			switch {
+			case key.Matches(msg, s.common.KeyMap.Back):
+				cmds = append(cmds, s.selector.Init())
+			}
 		}
+		t, cmd := s.tabs.Update(msg)
+		s.tabs = t.(*tabs.Tabs)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case tabs.ActiveTabMsg:
+		s.activeBox = box(msg)
 	}
 	switch s.activeBox {
 	case readmeBox:
@@ -259,20 +279,32 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements tea.Model.
 func (s *Selection) View() string {
-	rh := s.getReadmeHeight()
-	rs := s.common.Styles.ReadmeBox.Copy().
-		Width(s.common.Width).
-		Height(rh)
-	if s.activeBox == readmeBox {
-		rs.BorderForeground(s.common.Styles.ActiveBorderColor)
-	}
-	view := s.selector.View()
-	if rh > 0 {
-		readme := rs.Render(s.readme.View())
-		view = lipgloss.JoinVertical(lipgloss.Top,
-			readme,
-			view,
-		)
+	var view string
+	wm, hm := s.getMargins()
+	hm++ // tabs margin
+	switch s.activeBox {
+	case selectorBox:
+		ss := s.common.Styles.SelectorBox.Copy().
+			Height(s.common.Height - hm)
+		view = ss.Render(s.selector.View())
+	case readmeBox:
+		rs := s.common.Styles.ReadmeBox.Copy().
+			Height(s.common.Height - hm)
+		status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
+		readmeStatus := lipgloss.NewStyle().
+			Align(lipgloss.Right).
+			Width(s.common.Width - wm).
+			Foreground(s.common.Styles.InactiveBorderColor).
+			Render(status)
+		view = rs.Render(lipgloss.JoinVertical(lipgloss.Top,
+			s.readme.View(),
+			readmeStatus,
+		))
 	}
-	return view
+	ts := s.common.Styles.Tabs.Copy().
+		MarginBottom(1)
+	return lipgloss.JoinVertical(lipgloss.Top,
+		ts.Render(s.tabs.View()),
+		view,
+	)
 }

ui/styles/styles.go 🔗

@@ -136,11 +136,9 @@ func DefaultStyles() *Styles {
 		Foreground(lipgloss.Color("241")).
 		Align(lipgloss.Right)
 
-	s.SelectorBox = lipgloss.NewStyle().
-		Width(64)
+	s.SelectorBox = lipgloss.NewStyle()
 
-	s.ReadmeBox = lipgloss.NewStyle().
-		Margin(1, 1, 1, 0)
+	s.ReadmeBox = lipgloss.NewStyle()
 
 	s.RepoTitleBorder = lipgloss.Border{
 		Top:         "─",