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