ui.go

  1package ui
  2
  3import (
  4	"errors"
  5
  6	"github.com/charmbracelet/bubbles/key"
  7	"github.com/charmbracelet/bubbles/list"
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/lipgloss"
 10	"github.com/charmbracelet/soft-serve/server/backend"
 11	"github.com/charmbracelet/soft-serve/ui/common"
 12	"github.com/charmbracelet/soft-serve/ui/components/footer"
 13	"github.com/charmbracelet/soft-serve/ui/components/header"
 14	"github.com/charmbracelet/soft-serve/ui/components/selector"
 15	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 16	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 17)
 18
 19type page int
 20
 21const (
 22	selectionPage page = iota
 23	repoPage
 24)
 25
 26type sessionState int
 27
 28const (
 29	loadingState sessionState = iota
 30	errorState
 31	readyState
 32)
 33
 34// UI is the main UI model.
 35type UI struct {
 36	serverName  string
 37	initialRepo string
 38	common      common.Common
 39	pages       []common.Component
 40	activePage  page
 41	state       sessionState
 42	header      *header.Header
 43	footer      *footer.Footer
 44	showFooter  bool
 45	error       error
 46}
 47
 48// New returns a new UI model.
 49func New(c common.Common, initialRepo string) *UI {
 50	serverName := c.Config().Name
 51	h := header.New(c, serverName)
 52	ui := &UI{
 53		serverName:  serverName,
 54		common:      c,
 55		pages:       make([]common.Component, 2), // selection & repo
 56		activePage:  selectionPage,
 57		state:       loadingState,
 58		header:      h,
 59		initialRepo: initialRepo,
 60		showFooter:  true,
 61	}
 62	ui.footer = footer.New(c, ui)
 63	return ui
 64}
 65
 66func (ui *UI) getMargins() (wm, hm int) {
 67	style := ui.common.Styles.App.Copy()
 68	switch ui.activePage {
 69	case selectionPage:
 70		hm += ui.common.Styles.ServerName.GetHeight() +
 71			ui.common.Styles.ServerName.GetVerticalFrameSize()
 72	case repoPage:
 73	}
 74	wm += style.GetHorizontalFrameSize()
 75	hm += style.GetVerticalFrameSize()
 76	if ui.showFooter {
 77		// NOTE: we don't use the footer's style to determine the margins
 78		// because footer.Height() is the height of the footer after applying
 79		// the styles.
 80		hm += ui.footer.Height()
 81	}
 82	return
 83}
 84
 85// ShortHelp implements help.KeyMap.
 86func (ui *UI) ShortHelp() []key.Binding {
 87	b := make([]key.Binding, 0)
 88	switch ui.state {
 89	case errorState:
 90		b = append(b, ui.common.KeyMap.Back)
 91	case readyState:
 92		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 93	}
 94	if !ui.IsFiltering() {
 95		b = append(b, ui.common.KeyMap.Quit)
 96	}
 97	b = append(b, ui.common.KeyMap.Help)
 98	return b
 99}
100
101// FullHelp implements help.KeyMap.
102func (ui *UI) FullHelp() [][]key.Binding {
103	b := make([][]key.Binding, 0)
104	switch ui.state {
105	case errorState:
106		b = append(b, []key.Binding{ui.common.KeyMap.Back})
107	case readyState:
108		b = append(b, ui.pages[ui.activePage].FullHelp()...)
109	}
110	h := []key.Binding{
111		ui.common.KeyMap.Help,
112	}
113	if !ui.IsFiltering() {
114		h = append(h, ui.common.KeyMap.Quit)
115	}
116	b = append(b, h)
117	return b
118}
119
120// SetSize implements common.Component.
121func (ui *UI) SetSize(width, height int) {
122	ui.common.SetSize(width, height)
123	wm, hm := ui.getMargins()
124	ui.header.SetSize(width-wm, height-hm)
125	ui.footer.SetSize(width-wm, height-hm)
126	for _, p := range ui.pages {
127		if p != nil {
128			p.SetSize(width-wm, height-hm)
129		}
130	}
131}
132
133// Init implements tea.Model.
134func (ui *UI) Init() tea.Cmd {
135	ui.pages[selectionPage] = selection.New(ui.common)
136	ui.pages[repoPage] = repo.New(ui.common)
137	ui.SetSize(ui.common.Width, ui.common.Height)
138	cmds := make([]tea.Cmd, 0)
139	cmds = append(cmds,
140		ui.pages[selectionPage].Init(),
141		ui.pages[repoPage].Init(),
142	)
143	if ui.initialRepo != "" {
144		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
145	}
146	ui.state = readyState
147	ui.SetSize(ui.common.Width, ui.common.Height)
148	return tea.Batch(cmds...)
149}
150
151// IsFiltering returns true if the selection page is filtering.
152func (ui *UI) IsFiltering() bool {
153	if ui.activePage == selectionPage {
154		if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {
155			return true
156		}
157	}
158	return false
159}
160
161// Update implements tea.Model.
162func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163	ui.common.Logger.Debugf("msg received: %T", msg)
164	cmds := make([]tea.Cmd, 0)
165	switch msg := msg.(type) {
166	case tea.WindowSizeMsg:
167		ui.SetSize(msg.Width, msg.Height)
168		for i, p := range ui.pages {
169			m, cmd := p.Update(msg)
170			ui.pages[i] = m.(common.Component)
171			if cmd != nil {
172				cmds = append(cmds, cmd)
173			}
174		}
175	case tea.KeyMsg, tea.MouseMsg:
176		switch msg := msg.(type) {
177		case tea.KeyMsg:
178			switch {
179			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
180				ui.error = nil
181				ui.state = readyState
182				// Always show the footer on error.
183				ui.showFooter = ui.footer.ShowAll()
184			case key.Matches(msg, ui.common.KeyMap.Help):
185				cmds = append(cmds, footer.ToggleFooterCmd)
186			case key.Matches(msg, ui.common.KeyMap.Quit):
187				if !ui.IsFiltering() {
188					// Stop bubblezone background workers.
189					ui.common.Zone.Close()
190					return ui, tea.Quit
191				}
192			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
193				ui.activePage = selectionPage
194				// Always show the footer on selection page.
195				ui.showFooter = true
196			}
197		case tea.MouseMsg:
198			switch msg.Type {
199			case tea.MouseLeft:
200				switch {
201				case ui.common.Zone.Get("footer").InBounds(msg):
202					cmds = append(cmds, footer.ToggleFooterCmd)
203				}
204			}
205		}
206	case footer.ToggleFooterMsg:
207		ui.footer.SetShowAll(!ui.footer.ShowAll())
208		// Show the footer when on repo page and shot all help.
209		if ui.error == nil && ui.activePage == repoPage {
210			ui.showFooter = !ui.showFooter
211		}
212	case repo.RepoMsg:
213		ui.common.SetValue(common.RepoKey, msg)
214		ui.activePage = repoPage
215		// Show the footer on repo page if show all is set.
216		ui.showFooter = ui.footer.ShowAll()
217		cmds = append(cmds, repo.UpdateRefCmd(msg))
218	case common.ErrorMsg:
219		ui.error = msg
220		ui.state = errorState
221		ui.showFooter = true
222	case selector.SelectMsg:
223		switch msg.IdentifiableItem.(type) {
224		case selection.Item:
225			if ui.activePage == selectionPage {
226				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
227			}
228		}
229	}
230	h, cmd := ui.header.Update(msg)
231	ui.header = h.(*header.Header)
232	if cmd != nil {
233		cmds = append(cmds, cmd)
234	}
235	f, cmd := ui.footer.Update(msg)
236	ui.footer = f.(*footer.Footer)
237	if cmd != nil {
238		cmds = append(cmds, cmd)
239	}
240	if ui.state != loadingState {
241		m, cmd := ui.pages[ui.activePage].Update(msg)
242		ui.pages[ui.activePage] = m.(common.Component)
243		if cmd != nil {
244			cmds = append(cmds, cmd)
245		}
246	}
247	// This fixes determining the height margin of the footer.
248	ui.SetSize(ui.common.Width, ui.common.Height)
249	return ui, tea.Batch(cmds...)
250}
251
252// View implements tea.Model.
253func (ui *UI) View() string {
254	var view string
255	wm, hm := ui.getMargins()
256	switch ui.state {
257	case loadingState:
258		view = "Loading..."
259	case errorState:
260		err := ui.common.Styles.ErrorTitle.Render("Bummer")
261		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
262		view = ui.common.Styles.Error.Copy().
263			Width(ui.common.Width -
264				wm -
265				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
266			Height(ui.common.Height -
267				hm -
268				ui.common.Styles.Error.GetVerticalFrameSize()).
269			Render(err)
270	case readyState:
271		view = ui.pages[ui.activePage].View()
272	default:
273		view = "Unknown state :/ this is a bug!"
274	}
275	if ui.activePage == selectionPage {
276		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
277	}
278	if ui.showFooter {
279		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
280	}
281	return ui.common.Zone.Scan(
282		ui.common.Styles.App.Render(view),
283	)
284}
285
286func (ui *UI) openRepo(rn string) (backend.Repository, error) {
287	cfg := ui.common.Config()
288	if cfg == nil {
289		return nil, errors.New("config is nil")
290	}
291	repos, err := cfg.Backend.Repositories()
292	if err != nil {
293		ui.common.Logger.Debugf("ui: failed to list repos: %v", err)
294		return nil, err
295	}
296	for _, r := range repos {
297		if r.Name() == rn {
298			return r, nil
299		}
300	}
301	return nil, common.ErrMissingRepo
302}
303
304func (ui *UI) setRepoCmd(rn string) tea.Cmd {
305	return func() tea.Msg {
306		r, err := ui.openRepo(rn)
307		if err != nil {
308			return common.ErrorMsg(err)
309		}
310		return repo.RepoMsg(r)
311	}
312}
313
314func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
315	return func() tea.Msg {
316		r, err := ui.openRepo(rn)
317		if err != nil {
318			return nil
319		}
320		return repo.RepoMsg(r)
321	}
322}