1package selection
2
3import (
4 "fmt"
5 "sort"
6
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/list"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/soft-serve/ui/common"
13 "github.com/charmbracelet/soft-serve/ui/components/code"
14 "github.com/charmbracelet/soft-serve/ui/components/selector"
15 "github.com/charmbracelet/soft-serve/ui/components/tabs"
16)
17
18var (
19 logger = log.WithPrefix("ui.selection")
20)
21
22type pane int
23
24const (
25 selectorPane pane = iota
26 readmePane
27 lastPane
28)
29
30func (p pane) String() string {
31 return []string{
32 "Repositories",
33 "About",
34 }[p]
35}
36
37// Selection is the model for the selection screen/page.
38type Selection struct {
39 common common.Common
40 readme *code.Code
41 readmeHeight int
42 selector *selector.Selector
43 activePane pane
44 tabs *tabs.Tabs
45}
46
47// New creates a new selection model.
48func New(c common.Common) *Selection {
49 ts := make([]string, lastPane)
50 for i, b := range []pane{selectorPane, readmePane} {
51 ts[i] = b.String()
52 }
53 t := tabs.New(c, ts)
54 t.TabSeparator = lipgloss.NewStyle()
55 t.TabInactive = c.Styles.TopLevelNormalTab.Copy()
56 t.TabActive = c.Styles.TopLevelActiveTab.Copy()
57 t.TabDot = c.Styles.TopLevelActiveTabDot.Copy()
58 t.UseDot = true
59 sel := &Selection{
60 common: c,
61 activePane: selectorPane, // start with the selector focused
62 tabs: t,
63 }
64 readme := code.New(c, "", "")
65 readme.NoContentStyle = c.Styles.NoContent.Copy().SetString("No readme found.")
66 selector := selector.New(c,
67 []selector.IdentifiableItem{},
68 ItemDelegate{&c, &sel.activePane})
69 selector.SetShowTitle(false)
70 selector.SetShowHelp(false)
71 selector.SetShowStatusBar(false)
72 selector.DisableQuitKeybindings()
73 sel.selector = selector
74 sel.readme = readme
75 return sel
76}
77
78func (s *Selection) getMargins() (wm, hm int) {
79 wm = 0
80 hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
81 s.common.Styles.Tabs.GetHeight()
82 if s.activePane == selectorPane && s.IsFiltering() {
83 // hide tabs when filtering
84 hm = 0
85 }
86 return
87}
88
89// FilterState returns the current filter state.
90func (s *Selection) FilterState() list.FilterState {
91 return s.selector.FilterState()
92}
93
94// SetSize implements common.Component.
95func (s *Selection) SetSize(width, height int) {
96 s.common.SetSize(width, height)
97 wm, hm := s.getMargins()
98 s.tabs.SetSize(width, height-hm)
99 s.selector.SetSize(width-wm, height-hm)
100 s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line
101}
102
103// IsFiltering returns true if the selector is currently filtering.
104func (s *Selection) IsFiltering() bool {
105 return s.FilterState() == list.Filtering
106}
107
108// ShortHelp implements help.KeyMap.
109func (s *Selection) ShortHelp() []key.Binding {
110 k := s.selector.KeyMap
111 kb := make([]key.Binding, 0)
112 kb = append(kb,
113 s.common.KeyMap.UpDown,
114 s.common.KeyMap.Section,
115 )
116 if s.activePane == selectorPane {
117 copyKey := s.common.KeyMap.Copy
118 copyKey.SetHelp("c", "copy command")
119 kb = append(kb,
120 s.common.KeyMap.Select,
121 k.Filter,
122 k.ClearFilter,
123 copyKey,
124 )
125 }
126 return kb
127}
128
129// FullHelp implements help.KeyMap.
130func (s *Selection) FullHelp() [][]key.Binding {
131 b := [][]key.Binding{
132 {
133 s.common.KeyMap.Section,
134 },
135 }
136 switch s.activePane {
137 case readmePane:
138 k := s.readme.KeyMap
139 b = append(b, []key.Binding{
140 k.PageDown,
141 k.PageUp,
142 })
143 b = append(b, []key.Binding{
144 k.HalfPageDown,
145 k.HalfPageUp,
146 })
147 b = append(b, []key.Binding{
148 k.Down,
149 k.Up,
150 })
151 case selectorPane:
152 copyKey := s.common.KeyMap.Copy
153 copyKey.SetHelp("c", "copy command")
154 k := s.selector.KeyMap
155 if !s.IsFiltering() {
156 b[0] = append(b[0],
157 s.common.KeyMap.Select,
158 copyKey,
159 )
160 }
161 b = append(b, []key.Binding{
162 k.CursorUp,
163 k.CursorDown,
164 })
165 b = append(b, []key.Binding{
166 k.NextPage,
167 k.PrevPage,
168 k.GoToStart,
169 k.GoToEnd,
170 })
171 b = append(b, []key.Binding{
172 k.Filter,
173 k.ClearFilter,
174 k.CancelWhileFiltering,
175 k.AcceptWhileFiltering,
176 })
177 }
178 return b
179}
180
181// Init implements tea.Model.
182func (s *Selection) Init() tea.Cmd {
183 var readmeCmd tea.Cmd
184 cfg := s.common.Config()
185 pk := s.common.PublicKey()
186 if cfg == nil || pk == nil {
187 return nil
188 }
189 repos, err := cfg.Backend.Repositories()
190 if err != nil {
191 return common.ErrorCmd(err)
192 }
193 sortedItems := make(Items, 0)
194 // Put configured repos first
195 for _, r := range repos {
196 item, err := NewItem(r, cfg)
197 if err != nil {
198 logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err)
199 continue
200 }
201 sortedItems = append(sortedItems, item)
202 }
203 sort.Sort(sortedItems)
204 items := make([]selector.IdentifiableItem, len(sortedItems))
205 for i, it := range sortedItems {
206 items[i] = it
207 }
208 return tea.Batch(
209 s.selector.Init(),
210 s.selector.SetItems(items),
211 readmeCmd,
212 )
213}
214
215// Update implements tea.Model.
216func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217 cmds := make([]tea.Cmd, 0)
218 switch msg := msg.(type) {
219 case tea.WindowSizeMsg:
220 r, cmd := s.readme.Update(msg)
221 s.readme = r.(*code.Code)
222 if cmd != nil {
223 cmds = append(cmds, cmd)
224 }
225 m, cmd := s.selector.Update(msg)
226 s.selector = m.(*selector.Selector)
227 if cmd != nil {
228 cmds = append(cmds, cmd)
229 }
230 case tea.KeyMsg, tea.MouseMsg:
231 switch msg := msg.(type) {
232 case tea.KeyMsg:
233 switch {
234 case key.Matches(msg, s.common.KeyMap.Back):
235 cmds = append(cmds, s.selector.Init())
236 }
237 }
238 t, cmd := s.tabs.Update(msg)
239 s.tabs = t.(*tabs.Tabs)
240 if cmd != nil {
241 cmds = append(cmds, cmd)
242 }
243 case tabs.ActiveTabMsg:
244 s.activePane = pane(msg)
245 }
246 switch s.activePane {
247 case readmePane:
248 r, cmd := s.readme.Update(msg)
249 s.readme = r.(*code.Code)
250 if cmd != nil {
251 cmds = append(cmds, cmd)
252 }
253 case selectorPane:
254 m, cmd := s.selector.Update(msg)
255 s.selector = m.(*selector.Selector)
256 if cmd != nil {
257 cmds = append(cmds, cmd)
258 }
259 }
260 return s, tea.Batch(cmds...)
261}
262
263// View implements tea.Model.
264func (s *Selection) View() string {
265 var view string
266 wm, hm := s.getMargins()
267 switch s.activePane {
268 case selectorPane:
269 ss := lipgloss.NewStyle().
270 Width(s.common.Width - wm).
271 Height(s.common.Height - hm)
272 view = ss.Render(s.selector.View())
273 case readmePane:
274 rs := lipgloss.NewStyle().
275 Height(s.common.Height - hm)
276 status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
277 readmeStatus := lipgloss.NewStyle().
278 Align(lipgloss.Right).
279 Width(s.common.Width - wm).
280 Foreground(s.common.Styles.InactiveBorderColor).
281 Render(status)
282 view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
283 s.readme.View(),
284 readmeStatus,
285 ))
286 }
287 if s.activePane != selectorPane || s.FilterState() != list.Filtering {
288 tabs := s.common.Styles.Tabs.Render(s.tabs.View())
289 view = lipgloss.JoinVertical(lipgloss.Left,
290 tabs,
291 view,
292 )
293 }
294 return lipgloss.JoinVertical(
295 lipgloss.Left,
296 view,
297 )
298}