ui.go

  1package ui
  2
  3import (
  4	"log"
  5	"os"
  6
  7	"github.com/charmbracelet/bubbles/key"
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/lipgloss"
 10	"github.com/charmbracelet/soft-serve/ui/common"
 11	"github.com/charmbracelet/soft-serve/ui/components/footer"
 12	"github.com/charmbracelet/soft-serve/ui/components/header"
 13	"github.com/charmbracelet/soft-serve/ui/components/selector"
 14	"github.com/charmbracelet/soft-serve/ui/git"
 15	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 16	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 17	"github.com/charmbracelet/soft-serve/ui/session"
 18)
 19
 20type page int
 21
 22const (
 23	selectionPage page = iota
 24	repoPage
 25)
 26
 27type sessionState int
 28
 29const (
 30	startState sessionState = iota
 31	errorState
 32	loadedState
 33)
 34
 35// UI is the main UI model.
 36type UI struct {
 37	s           session.Session
 38	initialRepo string
 39	common      common.Common
 40	pages       []common.Page
 41	activePage  page
 42	state       sessionState
 43	header      *header.Header
 44	footer      *footer.Footer
 45	error       error
 46}
 47
 48// New returns a new UI model.
 49func New(s session.Session, c common.Common, initialRepo string) *UI {
 50	h := header.New(c, s.Config().Name)
 51	ui := &UI{
 52		s:           s,
 53		common:      c,
 54		pages:       make([]common.Page, 2), // selection & repo
 55		activePage:  selectionPage,
 56		state:       startState,
 57		header:      h,
 58		initialRepo: initialRepo,
 59	}
 60	ui.footer = footer.New(c, ui)
 61	return ui
 62}
 63
 64func (ui *UI) getMargins() (wm, hm int) {
 65	wm = ui.common.Styles.App.GetHorizontalFrameSize()
 66	hm = ui.common.Styles.App.GetVerticalFrameSize() +
 67		ui.common.Styles.Footer.GetVerticalFrameSize() +
 68		ui.footer.Height()
 69	switch ui.activePage {
 70	case selectionPage:
 71		hm += ui.common.Styles.Header.GetHeight() +
 72			ui.common.Styles.Header.GetVerticalFrameSize()
 73	case repoPage:
 74	}
 75	return
 76}
 77
 78// ShortHelp implements help.KeyMap.
 79func (ui *UI) ShortHelp() []key.Binding {
 80	b := make([]key.Binding, 0)
 81	switch ui.state {
 82	case errorState:
 83		b = append(b, ui.common.KeyMap.Back)
 84	case loadedState:
 85		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 86	}
 87	b = append(b,
 88		ui.common.KeyMap.Quit,
 89		ui.common.KeyMap.Help,
 90	)
 91	return b
 92}
 93
 94// FullHelp implements help.KeyMap.
 95func (ui *UI) FullHelp() [][]key.Binding {
 96	b := make([][]key.Binding, 0)
 97	switch ui.state {
 98	case errorState:
 99		b = append(b, []key.Binding{ui.common.KeyMap.Back})
100	case loadedState:
101		b = append(b, ui.pages[ui.activePage].FullHelp()...)
102	}
103	b = append(b, []key.Binding{
104		ui.common.KeyMap.Quit,
105		ui.common.KeyMap.Help,
106	})
107	return b
108}
109
110// SetSize implements common.Component.
111func (ui *UI) SetSize(width, height int) {
112	ui.common.SetSize(width, height)
113	wm, hm := ui.getMargins()
114	ui.header.SetSize(width-wm, height-hm)
115	ui.footer.SetSize(width-wm, height-hm)
116	for _, p := range ui.pages {
117		if p != nil {
118			p.SetSize(width-wm, height-hm)
119		}
120	}
121}
122
123// Init implements tea.Model.
124func (ui *UI) Init() tea.Cmd {
125	ui.pages[selectionPage] = selection.New(ui.s, ui.common)
126	ui.pages[repoPage] = repo.New(ui.s, ui.common)
127	ui.SetSize(ui.common.Width, ui.common.Height)
128	cmds := make([]tea.Cmd, 0)
129	cmds = append(cmds,
130		ui.pages[selectionPage].Init(),
131		ui.pages[repoPage].Init(),
132	)
133	if ui.initialRepo != "" {
134		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
135	}
136	ui.state = loadedState
137	ui.SetSize(ui.common.Width, ui.common.Height)
138	return tea.Batch(cmds...)
139}
140
141// Update implements tea.Model.
142func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
143	if os.Getenv("DEBUG") == "true" {
144		log.Printf("ui msg: %T", msg)
145	}
146	cmds := make([]tea.Cmd, 0)
147	switch msg := msg.(type) {
148	case tea.WindowSizeMsg:
149		ui.SetSize(msg.Width, msg.Height)
150		for i, p := range ui.pages {
151			m, cmd := p.Update(msg)
152			ui.pages[i] = m.(common.Page)
153			if cmd != nil {
154				cmds = append(cmds, cmd)
155			}
156		}
157	case tea.KeyMsg, tea.MouseMsg:
158		switch msg := msg.(type) {
159		case tea.KeyMsg:
160			switch {
161			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
162				ui.error = nil
163				ui.state = loadedState
164			case key.Matches(msg, ui.common.KeyMap.Help):
165				ui.footer.SetShowAll(!ui.footer.ShowAll())
166			case key.Matches(msg, ui.common.KeyMap.Quit):
167				return ui, tea.Quit
168			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
169				ui.activePage = selectionPage
170			}
171		}
172	case common.ErrorMsg:
173		ui.error = msg
174		ui.state = errorState
175		return ui, nil
176	case selector.SelectMsg:
177		switch msg.IdentifiableItem.(type) {
178		case selection.Item:
179			if ui.activePage == selectionPage {
180				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
181			}
182		}
183	}
184	h, cmd := ui.header.Update(msg)
185	ui.header = h.(*header.Header)
186	if cmd != nil {
187		cmds = append(cmds, cmd)
188	}
189	f, cmd := ui.footer.Update(msg)
190	ui.footer = f.(*footer.Footer)
191	if cmd != nil {
192		cmds = append(cmds, cmd)
193	}
194	if ui.state == loadedState {
195		m, cmd := ui.pages[ui.activePage].Update(msg)
196		ui.pages[ui.activePage] = m.(common.Page)
197		if cmd != nil {
198			cmds = append(cmds, cmd)
199		}
200	}
201	// This fixes determining the height margin of the footer.
202	ui.SetSize(ui.common.Width, ui.common.Height)
203	return ui, tea.Batch(cmds...)
204}
205
206// View implements tea.Model.
207func (ui *UI) View() string {
208	var view string
209	wm, hm := ui.getMargins()
210	footer := ui.footer.View()
211	style := ui.common.Styles.App.Copy()
212	switch ui.state {
213	case startState:
214		view = "Loading..."
215	case errorState:
216		err := ui.common.Styles.ErrorTitle.Render("Bummer")
217		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
218		view = ui.common.Styles.Error.Copy().
219			Width(ui.common.Width -
220				wm -
221				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
222			Height(ui.common.Height -
223				hm -
224				ui.common.Styles.Error.GetVerticalFrameSize()).
225			Render(err)
226	case loadedState:
227		view = ui.pages[ui.activePage].View()
228	default:
229		view = "Unknown state :/ this is a bug!"
230	}
231	switch ui.activePage {
232	case selectionPage:
233		view = lipgloss.JoinVertical(lipgloss.Bottom,
234			ui.header.View(),
235			view,
236		)
237	case repoPage:
238	}
239	return style.Render(
240		lipgloss.JoinVertical(lipgloss.Bottom,
241			view,
242			footer,
243		),
244	)
245}
246
247func (ui *UI) setRepoCmd(rn string) tea.Cmd {
248	rs := ui.s.Source()
249	return func() tea.Msg {
250		for _, r := range rs.AllRepos() {
251			if r.Repo() == rn {
252				ui.activePage = repoPage
253				return repo.RepoMsg(r)
254			}
255		}
256		return common.ErrorMsg(git.ErrMissingRepo)
257	}
258}
259
260func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
261	rs := ui.s.Source()
262	return func() tea.Msg {
263		for _, r := range rs.AllRepos() {
264			if r.Repo() == rn {
265				ui.activePage = repoPage
266				return repo.RepoMsg(r)
267			}
268		}
269		return nil
270	}
271}