wip: add readme

Ayman Bagabas created

Change summary

ui/common/common.go                      |   2 
ui/common/component.go                   |   2 
ui/common/error.go                       |   2 
ui/components/code/code.go               |  30 +++++-
ui/components/footer/footer.go           |   6 +
ui/components/header/header.go           |   6 +
ui/components/selector/selector.go       |  29 +++++-
ui/components/viewport/viewport_patch.go |   5 +
ui/keymap/keymap.go                      |  12 ++
ui/pages/selection/item.go               |  39 ++++++---
ui/pages/selection/selection.go          | 105 +++++++++++++++++++++----
ui/styles/styles.go                      |  32 +++++-
ui/ui.go                                 |   8 +
13 files changed, 224 insertions(+), 54 deletions(-)

Detailed changes

ui/common/common.go 🔗

@@ -12,7 +12,7 @@ type Common struct {
 	Height int
 }
 
-func (c Common) SetSize(width, height int) {
+func (c *Common) SetSize(width, height int) {
 	c.Width = width
 	c.Height = height
 }

ui/common/component.go 🔗

@@ -5,11 +5,13 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 )
 
+// Component represents a Bubble Tea model that implements a SetSize function.
 type Component interface {
 	tea.Model
 	SetSize(width, height int)
 }
 
+// Page represents a component that implements help.KeyMap.
 type Page interface {
 	Component
 	help.KeyMap

ui/common/error.go 🔗

@@ -2,8 +2,10 @@ package common
 
 import tea "github.com/charmbracelet/bubbletea"
 
+// ErrorMsg is a Bubble Tea message that represents an error.
 type ErrorMsg error
 
+// ErrorCmd returns an ErrorMsg from error.
 func ErrorCmd(err error) tea.Cmd {
 	return func() tea.Msg {
 		return ErrorMsg(err)

ui/components/code/code.go 🔗

@@ -8,19 +8,23 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/glamour"
 	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
 	"github.com/muesli/reflow/wrap"
 	"github.com/muesli/termenv"
 )
 
+// Code is a code snippet.
 type Code struct {
-	common    common.Common
-	content   string
-	extension string
-	viewport  *vp.ViewportBubble
+	common         common.Common
+	content        string
+	extension      string
+	viewport       *vp.ViewportBubble
+	NoContentStyle lipgloss.Style
 }
 
+// New returns a new Code.
 func New(c common.Common, content, extension string) *Code {
 	r := &Code{
 		common:    c,
@@ -31,28 +35,36 @@ func New(c common.Common, content, extension string) *Code {
 				MouseWheelEnabled: true,
 			},
 		},
+		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 	}
+	r.SetSize(c.Width, c.Height)
 	return r
 }
 
+// SetSize implements common.Component.
 func (r *Code) SetSize(width, height int) {
-	r.common.Width = width
-	r.common.Height = height
+	r.common.SetSize(width, height)
 	r.viewport.SetSize(width, height)
 }
 
+// SetContent sets the content of the Code.
 func (r *Code) SetContent(c, ext string) tea.Cmd {
 	r.content = c
 	r.extension = ext
 	return r.Init()
 }
 
+// GotoTop reset the viewport to the top.
+func (r *Code) GotoTop() {
+	r.viewport.Viewport.GotoTop()
+}
+
+// Init implements tea.Model.
 func (r *Code) Init() tea.Cmd {
 	w := r.common.Width
-	s := r.common.Styles
 	c := r.content
 	if c == "" {
-		c = s.AboutNoReadme.Render("File is empty.")
+		c = r.NoContentStyle.String()
 	}
 	f, err := renderFile(r.extension, c, w)
 	if err != nil {
@@ -63,12 +75,14 @@ func (r *Code) Init() tea.Cmd {
 	return nil
 }
 
+// Update implements tea.Model.
 func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	v, cmd := r.viewport.Update(msg)
 	r.viewport = v.(*vp.ViewportBubble)
 	return r, cmd
 }
 
+// View implements tea.View.
 func (r *Code) View() string {
 	return r.viewport.View()
 }

ui/components/footer/footer.go 🔗

@@ -6,12 +6,14 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
+// Footer is a Bubble Tea model that displays help and other info.
 type Footer struct {
 	common common.Common
 	help   help.Model
 	keymap help.KeyMap
 }
 
+// New creates a new Footer.
 func New(c common.Common, keymap help.KeyMap) *Footer {
 	h := help.New()
 	h.Styles.ShortKey = c.Styles.HelpKey
@@ -24,19 +26,23 @@ func New(c common.Common, keymap help.KeyMap) *Footer {
 	return f
 }
 
+// SetSize implements common.Component.
 func (f *Footer) SetSize(width, height int) {
 	f.common.Width = width
 	f.common.Height = height
 }
 
+// Init implements tea.Model.
 func (f *Footer) Init() tea.Cmd {
 	return nil
 }
 
+// Update implements tea.Model.
 func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return f, nil
 }
 
+// View implements tea.Model.
 func (f *Footer) View() string {
 	if f.keymap == nil {
 		return ""

ui/components/header/header.go 🔗

@@ -7,11 +7,13 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
+// Header represents a header component.
 type Header struct {
 	common common.Common
 	text   string
 }
 
+// New creates a new header component.
 func New(c common.Common, text string) *Header {
 	h := &Header{
 		common: c,
@@ -20,19 +22,23 @@ func New(c common.Common, text string) *Header {
 	return h
 }
 
+// SetSize implements common.Component.
 func (h *Header) SetSize(width, height int) {
 	h.common.Width = width
 	h.common.Height = height
 }
 
+// Init implements tea.Model.
 func (h *Header) Init() tea.Cmd {
 	return nil
 }
 
+// Update implements tea.Model.
 func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return h, nil
 }
 
+// View implements tea.Model.
 func (h *Header) View() string {
 	s := h.common.Styles.Header.Copy().Width(h.common.Width)
 	return s.Render(strings.TrimSpace(h.text))

ui/components/selector/selector.go 🔗

@@ -7,18 +7,28 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
+// Selector is a list of items that can be selected.
 type Selector struct {
 	list   list.Model
 	common common.Common
 	active int
 }
 
+// IdentifiableItem is an item that can be identified by a string and extends list.Item.
+type IdentifiableItem interface {
+	list.Item
+	ID() string
+}
+
+// SelectMsg is a message that is sent when an item is selected.
 type SelectMsg string
 
+// ActiveMsg is a message that is sent when an item is active but not selected.
 type ActiveMsg string
 
-func New(common common.Common, items []list.Item) *Selector {
-	l := list.New(items, ItemDelegate{common.Styles}, common.Width, common.Height)
+// New creates a new selector.
+func New(common common.Common, items []list.Item, delegate list.ItemDelegate) *Selector {
+	l := list.New(items, delegate, common.Width, common.Height)
 	l.SetShowTitle(false)
 	l.SetShowHelp(false)
 	l.SetShowStatusBar(false)
@@ -31,27 +41,33 @@ func New(common common.Common, items []list.Item) *Selector {
 	return s
 }
 
+// KeyMap returns the underlying list's keymap.
 func (s *Selector) KeyMap() list.KeyMap {
 	return s.list.KeyMap
 }
 
+// SetSize implements common.Component.
 func (s *Selector) SetSize(width, height int) {
 	s.common.SetSize(width, height)
 	s.list.SetSize(width, height)
 }
 
+// SetItems sets the items in the selector.
 func (s *Selector) SetItems(items []list.Item) tea.Cmd {
 	return s.list.SetItems(items)
 }
 
+// Index returns the index of the selected item.
 func (s *Selector) Index() int {
 	return s.list.Index()
 }
 
+// Init implements tea.Model.
 func (s *Selector) Init() tea.Cmd {
 	return s.activeCmd
 }
 
+// Update implements tea.Model.
 func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
@@ -74,18 +90,19 @@ func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, tea.Batch(cmds...)
 }
 
+// View implements tea.Model.
 func (s *Selector) View() string {
 	return s.list.View()
 }
 
 func (s *Selector) selectCmd() tea.Msg {
 	item := s.list.SelectedItem()
-	i := item.(Item)
-	return SelectMsg(i.Name)
+	i := item.(IdentifiableItem)
+	return SelectMsg(i.ID())
 }
 
 func (s *Selector) activeCmd() tea.Msg {
 	item := s.list.SelectedItem()
-	i := item.(Item)
-	return ActiveMsg(i.Name)
+	i := item.(IdentifiableItem)
+	return ActiveMsg(i.ID())
 }

ui/components/viewport/viewport_patch.go 🔗

@@ -5,25 +5,30 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 )
 
+// ViewportBubble represents a viewport component.
 type ViewportBubble struct {
 	Viewport *viewport.Model
 }
 
+// SetSize implements common.Component.
 func (v *ViewportBubble) SetSize(width, height int) {
 	v.Viewport.Width = width
 	v.Viewport.Height = height
 }
 
+// Init implements tea.Model.
 func (v *ViewportBubble) Init() tea.Cmd {
 	return nil
 }
 
+// Update implements tea.Model.
 func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	vp, cmd := v.Viewport.Update(msg)
 	v.Viewport = &vp
 	return v, cmd
 }
 
+// View implements tea.Model.
 func (v *ViewportBubble) View() string {
 	return v.Viewport.View()
 }

ui/keymap/keymap.go 🔗

@@ -11,6 +11,7 @@ type KeyMap struct {
 	LeftRight key.Binding
 	Arrows    key.Binding
 	Select    key.Binding
+	Section   key.Binding
 }
 
 // DefaultKeyMap returns the default key map.
@@ -103,5 +104,16 @@ func DefaultKeyMap() *KeyMap {
 		),
 	)
 
+	km.Section = key.NewBinding(
+		key.WithKeys(
+			"tab",
+			"shift+tab",
+		),
+		key.WithHelp(
+			"tab",
+			"section",
+		),
+	)
+
 	return km
 }

ui/components/selector/item.go → ui/pages/selection/item.go 🔗

@@ -1,4 +1,4 @@
-package selector
+package selection
 
 import (
 	"fmt"
@@ -14,6 +14,7 @@ import (
 	"github.com/dustin/go-humanize"
 )
 
+// Item represents a single item in the selector.
 type Item struct {
 	Title       string
 	Name        string
@@ -22,35 +23,41 @@ type Item struct {
 	URL         *yankable.Yankable
 }
 
-func (i *Item) Init() tea.Cmd {
-	return nil
-}
-
-func (i *Item) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	return i, nil
-}
-
-func (i *Item) View() string {
-	return ""
+// ID implements selector.IdentifiableItem.
+func (i Item) ID() string {
+	return i.Name
 }
 
+// FilterValue implements list.Item.
 func (i Item) FilterValue() string { return i.Title }
 
+// ItemDelegate is the delegate for the item.
 type ItemDelegate struct {
-	styles *styles.Styles
+	styles    *styles.Styles
+	activeBox *box
 }
 
+// Width returns the item width.
 func (d ItemDelegate) Width() int {
 	width := d.styles.MenuItem.GetHorizontalFrameSize() + d.styles.MenuItem.GetWidth()
 	return width
 }
+
+// Height returns the item height. Implements list.ItemDelegate.
 func (d ItemDelegate) Height() int {
 	height := d.styles.MenuItem.GetVerticalFrameSize() + d.styles.MenuItem.GetHeight()
 	return height
 }
+
+// Spacing returns the spacing between items. Implements list.ItemDelegate.
 func (d ItemDelegate) Spacing() int { return 1 }
+
+// Update implements list.ItemDelegate.
 func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	cmds := make([]tea.Cmd, 0)
+	if d.activeBox == nil || *d.activeBox != selectorBox {
+		return nil
+	}
 	for i, item := range m.VisibleItems() {
 		itm, ok := item.(Item)
 		if !ok {
@@ -78,12 +85,18 @@ func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	}
 	return tea.Batch(cmds...)
 }
+
+// Render implements list.ItemDelegate.
 func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 	i := listItem.(Item)
 	s := strings.Builder{}
 	style := d.styles.MenuItem.Copy()
 	if index == m.Index() {
-		style = d.styles.SelectedMenuItem.Copy()
+		style = style.BorderForeground(d.styles.ActiveBorderColor)
+		if d.activeBox != nil && *d.activeBox == readmeBox {
+			// TODO make this into its own color
+			style = style.BorderForeground(lipgloss.Color("15"))
+		}
 	}
 	titleStr := i.Title
 	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.LastUpdate))

ui/pages/selection/selection.go 🔗

@@ -16,39 +16,67 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/session"
 )
 
+type box int
+
+const (
+	readmeBox box = iota
+	selectorBox
+)
+
+// Selection is the model for the selection screen/page.
 type Selection struct {
-	s        session.Session
-	common   common.Common
-	readme   *code.Code
-	selector *selector.Selector
+	s         session.Session
+	common    common.Common
+	readme    *code.Code
+	selector  *selector.Selector
+	activeBox box
 }
 
+// New creates a new selection model.
 func New(s session.Session, common common.Common) *Selection {
 	sel := &Selection{
-		s:        s,
-		common:   common,
-		readme:   code.New(common, "", ""),
-		selector: selector.New(common, []list.Item{}),
+		s:         s,
+		common:    common,
+		activeBox: 1,
 	}
+	readme := code.New(common, "", "")
+	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
+	sel.readme = readme
+	sel.selector = selector.New(common, []list.Item{}, ItemDelegate{common.Styles, &sel.activeBox})
 	return sel
 }
 
+// SetSize implements common.Component.
 func (s *Selection) SetSize(width, height int) {
 	s.common.SetSize(width, height)
-	s.readme.SetSize(width, height)
-	s.selector.SetSize(width, height)
+	sw := s.common.Styles.SelectorBox.GetWidth()
+	wm := sw +
+		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
+		s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
+	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
+	s.readme.SetSize(width-wm, height-hm)
+	s.selector.SetSize(sw, height)
 }
 
+// ShortHelp implements help.KeyMap.
 func (s *Selection) ShortHelp() []key.Binding {
 	k := s.selector.KeyMap()
-	return []key.Binding{
+	kb := make([]key.Binding, 0)
+	kb = append(kb,
 		s.common.Keymap.UpDown,
 		s.common.Keymap.Select,
-		k.Filter,
-		k.ClearFilter,
+	)
+	if s.activeBox == selectorBox {
+		kb = append(kb,
+			k.Filter,
+			k.ClearFilter,
+		)
 	}
+	return kb
 }
 
+// FullHelp implements help.KeyMap.
+// TODO implement full help on ?
 func (s *Selection) FullHelp() [][]key.Binding {
 	k := s.selector.KeyMap()
 	return [][]key.Binding{
@@ -74,9 +102,11 @@ func (s *Selection) FullHelp() [][]key.Binding {
 	}
 }
 
+// Init implements tea.Model.
 func (s *Selection) Init() tea.Cmd {
 	items := make([]list.Item, 0)
 	cfg := s.s.Config()
+	// TODO fix yankable component
 	yank := func(text string) *yankable.Yankable {
 		return yankable.New(
 			lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
@@ -86,7 +116,7 @@ func (s *Selection) Init() tea.Cmd {
 	}
 	// Put configured repos first
 	for _, r := range cfg.Repos {
-		items = append(items, selector.Item{
+		items = append(items, Item{
 			Title:       r.Name,
 			Name:        r.Repo,
 			Description: r.Note,
@@ -97,14 +127,14 @@ func (s *Selection) Init() tea.Cmd {
 	for _, r := range cfg.Source.AllRepos() {
 		exists := false
 		for _, item := range items {
-			item := item.(selector.Item)
+			item := item.(Item)
 			if item.Name == r.Name() {
 				exists = true
 				break
 			}
 		}
 		if !exists {
-			items = append(items, selector.Item{
+			items = append(items, Item{
 				Title:       r.Name(),
 				Name:        r.Name(),
 				Description: "",
@@ -119,12 +149,39 @@ func (s *Selection) Init() tea.Cmd {
 	)
 }
 
+// Update implements tea.Model.
 func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		r, cmd := s.readme.Update(msg)
+		s.readme = r.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		m, cmd := s.selector.Update(msg)
+		s.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case selector.ActiveMsg:
 		cmds = append(cmds, s.changeActive(msg))
-	default:
+		// reset readme position
+		s.readme.GotoTop()
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, s.common.Keymap.Section):
+			s.activeBox = (s.activeBox + 1) % 2
+		}
+	}
+	switch s.activeBox {
+	case readmeBox:
+		r, cmd := s.readme.Update(msg)
+		s.readme = r.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case selectorBox:
 		m, cmd := s.selector.Update(msg)
 		s.selector = m.(*selector.Selector)
 		if cmd != nil {
@@ -134,10 +191,22 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, tea.Batch(cmds...)
 }
 
+// View implements tea.Model.
 func (s *Selection) View() string {
+	wm := s.common.Styles.SelectorBox.GetWidth() +
+		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
+		s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
+	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
+	rs := s.common.Styles.ReadmeBox.Copy().
+		Width(s.common.Width - wm).
+		Height(s.common.Height - hm)
+	if s.activeBox == readmeBox {
+		rs.BorderForeground(s.common.Styles.ActiveBorderColor)
+	}
+	readme := rs.Render(s.readme.View())
 	return lipgloss.JoinHorizontal(
 		lipgloss.Top,
-		s.readme.View(),
+		readme,
 		s.selector.View(),
 	)
 }

ui/styles/styles.go 🔗

@@ -15,11 +15,14 @@ type Styles struct {
 	App    lipgloss.Style
 	Header lipgloss.Style
 
-	Menu             lipgloss.Style
-	MenuCursor       lipgloss.Style
-	MenuItem         lipgloss.Style
-	MenuLastUpdate   lipgloss.Style
-	SelectedMenuItem lipgloss.Style
+	Menu           lipgloss.Style
+	MenuCursor     lipgloss.Style
+	MenuItem       lipgloss.Style
+	MenuLastUpdate lipgloss.Style
+
+	// Selection page styles
+	SelectorBox lipgloss.Style
+	ReadmeBox   lipgloss.Style
 
 	RepoTitleBorder lipgloss.Border
 	RepoNoteBorder  lipgloss.Border
@@ -74,6 +77,8 @@ type Styles struct {
 	TreeNoItems      lipgloss.Style
 
 	Spinner lipgloss.Style
+
+	CodeNoContent lipgloss.Style
 }
 
 // DefaultStyles returns default styles for the UI.
@@ -81,7 +86,7 @@ func DefaultStyles() *Styles {
 	s := new(Styles)
 
 	s.ActiveBorderColor = lipgloss.Color("62")
-	s.InactiveBorderColor = lipgloss.Color("236")
+	s.InactiveBorderColor = lipgloss.Color("241")
 
 	s.App = lipgloss.NewStyle().
 		Margin(1, 2)
@@ -113,8 +118,13 @@ func DefaultStyles() *Styles {
 		Foreground(lipgloss.Color("241")).
 		Align(lipgloss.Right)
 
-	s.SelectedMenuItem = s.MenuItem.Copy().
-		BorderForeground(s.ActiveBorderColor)
+	s.SelectorBox = lipgloss.NewStyle().
+		Width(64)
+
+	s.ReadmeBox = lipgloss.NewStyle().
+		BorderForeground(s.InactiveBorderColor).
+		Padding(1).
+		MarginRight(1)
 
 	s.RepoTitleBorder = lipgloss.Border{
 		Top:         "─",
@@ -288,5 +298,11 @@ func DefaultStyles() *Styles {
 		MarginLeft(2).
 		Foreground(lipgloss.Color("205"))
 
+	s.CodeNoContent = lipgloss.NewStyle().
+		SetString("No Content.").
+		MarginTop(1).
+		MarginLeft(2).
+		Foreground(lipgloss.Color("#626262"))
+
 	return s
 }

ui/ui.go 🔗

@@ -21,6 +21,7 @@ const (
 	loadedState
 )
 
+// UI is the main UI model.
 type UI struct {
 	s          session.Session
 	common     common.Common
@@ -32,6 +33,7 @@ type UI struct {
 	error      error
 }
 
+// New returns a new UI model.
 func New(s session.Session, c common.Common, initialRepo string) *UI {
 	h := header.New(c, s.Config().Name)
 	ui := &UI{
@@ -55,6 +57,7 @@ func (ui *UI) getMargins() (wm, hm int) {
 	return
 }
 
+// ShortHelp implements help.KeyMap.
 func (ui *UI) ShortHelp() []key.Binding {
 	b := make([]key.Binding, 0)
 	b = append(b, ui.pages[ui.activePage].ShortHelp()...)
@@ -62,6 +65,7 @@ func (ui *UI) ShortHelp() []key.Binding {
 	return b
 }
 
+// FullHelp implements help.KeyMap.
 func (ui *UI) FullHelp() [][]key.Binding {
 	b := make([][]key.Binding, 0)
 	b = append(b, ui.pages[ui.activePage].FullHelp()...)
@@ -69,6 +73,7 @@ func (ui *UI) FullHelp() [][]key.Binding {
 	return b
 }
 
+// SetSize implements common.Component.
 func (ui *UI) SetSize(width, height int) {
 	ui.common.SetSize(width, height)
 	wm, hm := ui.getMargins()
@@ -81,6 +86,7 @@ func (ui *UI) SetSize(width, height int) {
 	}
 }
 
+// Init implements tea.Model.
 func (ui *UI) Init() tea.Cmd {
 	ui.pages[0] = selection.New(ui.s, ui.common)
 	ui.pages[1] = selection.New(ui.s, ui.common)
@@ -89,6 +95,7 @@ func (ui *UI) Init() tea.Cmd {
 	return ui.pages[ui.activePage].Init()
 }
 
+// Update implements tea.Model.
 // TODO update help when page change.
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
@@ -130,6 +137,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return ui, tea.Batch(cmds...)
 }
 
+// View implements tea.Model.
 func (ui *UI) View() string {
 	s := strings.Builder{}
 	switch ui.state {