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