ui.go

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