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