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}