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