Eric Bower
·
29 Nov 24
tokens.go
1package tokens
2
3import (
4 pager "github.com/charmbracelet/bubbles/paginator"
5 tea "github.com/charmbracelet/bubbletea"
6 "github.com/picosh/pico/db"
7 "github.com/picosh/pico/tui/common"
8 "github.com/picosh/pico/tui/pages"
9)
10
11const keysPerPage = 4
12
13type state int
14
15const (
16 stateLoading state = iota
17 stateNormal
18 stateDeletingKey
19)
20
21type keyState int
22
23const (
24 keyNormal keyState = iota
25 keySelected
26 keyDeleting
27)
28
29type errMsg struct {
30 err error
31}
32
33func (e errMsg) Error() string { return e.err.Error() }
34
35type (
36 keysLoadedMsg []*db.Token
37 unlinkedKeyMsg int
38)
39
40// Model is the Tea state model for this user interface.
41type Model struct {
42 shared *common.SharedModel
43
44 state state
45 err error
46 activeKeyIndex int // index of the key in the below slice which is currently in use
47 tokens []*db.Token // keys linked to user's account
48 index int // index of selected key in relation to the current page
49
50 pager pager.Model
51}
52
53// getSelectedIndex returns the index of the cursor in relation to the total
54// number of items.
55func (m *Model) getSelectedIndex() int {
56 return m.index + m.pager.Page*m.pager.PerPage
57}
58
59// UpdatePaging runs an update against the underlying pagination model as well
60// as performing some related tasks on this model.
61func (m *Model) UpdatePaging(msg tea.Msg) {
62 // Handle paging
63 m.pager.SetTotalPages(len(m.tokens))
64 m.pager, _ = m.pager.Update(msg)
65
66 // If selected item is out of bounds, put it in bounds
67 numItems := m.pager.ItemsOnPage(len(m.tokens))
68 m.index = min(m.index, numItems-1)
69}
70
71// NewModel creates a new model with defaults.
72func NewModel(shared *common.SharedModel) Model {
73 p := pager.New()
74 p.PerPage = keysPerPage
75 p.Type = pager.Dots
76 p.InactiveDot = shared.Styles.InactivePagination.Render("•")
77
78 return Model{
79 shared: shared,
80
81 state: stateLoading,
82 err: nil,
83 activeKeyIndex: -1,
84 tokens: []*db.Token{},
85 index: 0,
86
87 pager: p,
88 }
89}
90
91// Init is the Tea initialization function.
92func (m Model) Init() tea.Cmd {
93 return FetchTokens(m.shared)
94}
95
96// Update is the tea update function which handles incoming messages.
97func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 var (
99 cmds []tea.Cmd
100 )
101
102 switch msg := msg.(type) {
103 case tea.KeyMsg:
104 switch msg.String() {
105 case "q", "esc":
106 return m, pages.Navigate(pages.MenuPage)
107 case "up", "k":
108 m.index--
109 if m.index < 0 && m.pager.Page > 0 {
110 m.index = m.pager.PerPage - 1
111 m.pager.PrevPage()
112 }
113 m.index = max(0, m.index)
114 case "down", "j":
115 itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
116 m.index++
117 if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
118 m.index = 0
119 m.pager.NextPage()
120 }
121 m.index = min(itemsOnPage-1, m.index)
122
123 case "n":
124 return m, pages.Navigate(pages.CreateTokenPage)
125
126 // Delete
127 case "x":
128 m.state = stateDeletingKey
129 m.UpdatePaging(msg)
130 return m, nil
131
132 // Confirm Delete
133 case "y":
134 switch m.state {
135 case stateDeletingKey:
136 m.state = stateNormal
137 return m, m.unlinkKey()
138 }
139 }
140
141 case errMsg:
142 m.err = msg.err
143 return m, nil
144
145 case keysLoadedMsg:
146 m.state = stateNormal
147 m.index = 0
148 m.tokens = msg
149
150 case unlinkedKeyMsg:
151 i := m.getSelectedIndex()
152
153 // Remove key from array
154 m.tokens = append(m.tokens[:i], m.tokens[i+1:]...)
155
156 // Update pagination
157 m.pager.SetTotalPages(len(m.tokens))
158 m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
159
160 // Update cursor
161 m.index = min(m.index, m.pager.ItemsOnPage(len(m.tokens)-1))
162
163 return m, nil
164
165 // leaving page so reset model
166 case pages.NavigateMsg:
167 next := NewModel(m.shared)
168 return next, next.Init()
169 }
170
171 switch m.state {
172 case stateDeletingKey:
173 // If an item is being confirmed for delete, any key (other than the key
174 // used for confirmation above) cancels the deletion
175 k, ok := msg.(tea.KeyMsg)
176 if ok && k.String() != "y" {
177 m.state = stateNormal
178 }
179 }
180
181 m.UpdatePaging(msg)
182 return m, tea.Batch(cmds...)
183}
184
185// View renders the current UI into a string.
186func (m Model) View() string {
187 if m.err != nil {
188 return m.err.Error()
189 }
190
191 var s string
192
193 switch m.state {
194 case stateLoading:
195 s = "Loading...\n\n"
196 default:
197 s = "Here are the tokens linked to your account. An API token can be used for connecting to our IRC bouncer or your pico RSS feed.\n\n"
198
199 // Keys
200 s += keysView(m)
201 if m.pager.TotalPages > 1 {
202 s += m.pager.View()
203 }
204
205 // Footer
206 switch m.state {
207 case stateDeletingKey:
208 s += m.promptView("Delete this key?")
209 default:
210 s += "\n\n" + m.helpView()
211 }
212 }
213
214 return s
215}
216
217func keysView(m Model) string {
218 var (
219 s string
220 state keyState
221 start, end = m.pager.GetSliceBounds(len(m.tokens))
222 slice = m.tokens[start:end]
223 )
224
225 destructiveState := m.state == stateDeletingKey
226
227 // Render key info
228 for i, key := range slice {
229 if destructiveState && m.index == i {
230 state = keyDeleting
231 } else if m.index == i {
232 state = keySelected
233 } else {
234 state = keyNormal
235 }
236 s += newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
237 }
238
239 // If there aren't enough keys to fill the view, fill the missing parts
240 // with whitespace
241 if len(slice) < m.pager.PerPage {
242 for i := len(slice); i < m.pager.PerPage; i++ {
243 s += "\n\n\n"
244 }
245 }
246
247 return s
248}
249
250func (m *Model) helpView() string {
251 var items []string
252 if len(m.tokens) > 1 {
253 items = append(items, "j/k, ↑/↓: choose")
254 }
255 if m.pager.TotalPages > 1 {
256 items = append(items, "h/l, ←/→: page")
257 }
258 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
259 return common.HelpView(m.shared.Styles, items...)
260}
261
262func (m *Model) promptView(prompt string) string {
263 st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
264 return st.Render(prompt) +
265 m.shared.Styles.Delete.Render("(y/N)")
266}
267
268func FetchTokens(shrd *common.SharedModel) tea.Cmd {
269 return func() tea.Msg {
270 ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
271 if err != nil {
272 return errMsg{err}
273 }
274 return keysLoadedMsg(ak)
275 }
276}
277
278// unlinkKey deletes the selected key.
279func (m *Model) unlinkKey() tea.Cmd {
280 return func() tea.Msg {
281 id := m.tokens[m.getSelectedIndex()].ID
282 err := m.shared.Dbpool.RemoveToken(id)
283 if err != nil {
284 return errMsg{err}
285 }
286 return unlinkedKeyMsg(m.index)
287 }
288}
289
290// Utils
291
292func min(a, b int) int {
293 if a < b {
294 return a
295 }
296 return b
297}
298
299func max(a, b int) int {
300 if a > b {
301 return a
302 }
303 return b
304}