Eric Bower
·
2025-03-28
ui.go
1package tui
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "strings"
8
9 "git.sr.ht/~rockorager/vaxis"
10 "git.sr.ht/~rockorager/vaxis/vxfw"
11 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
12 "github.com/picosh/pico/pkg/db"
13 "github.com/picosh/pico/pkg/pssh"
14 "github.com/picosh/pico/pkg/shared"
15 "github.com/picosh/utils"
16)
17
18var HOME = "dash"
19
20type SharedModel struct {
21 Logger *slog.Logger
22 Session *pssh.SSHServerConnSession
23 Cfg *shared.ConfigSite
24 Dbpool db.DB
25 User *db.User
26 PlusFeatureFlag *db.FeatureFlag
27 BouncerFeatureFlag *db.FeatureFlag
28 Impersonator string
29 App *vxfw.App
30}
31
32type Navigate struct{ To string }
33type PageIn struct{}
34type PageOut struct{}
35
36var fuschia = vaxis.HexColor(0xEE6FF8)
37var cream = vaxis.HexColor(0xFFFDF5)
38var green = vaxis.HexColor(0x04B575)
39var grey = vaxis.HexColor(0x5C5C5C)
40var red = vaxis.HexColor(0xED567A)
41
42// var white = vaxis.HexColor(0xFFFFFF).
43var oj = vaxis.HexColor(0xFFCA80)
44var purp = vaxis.HexColor(0xBD93F9)
45var black = vaxis.HexColor(0x282A36)
46
47func createDrawCtx(ctx vxfw.DrawContext, h uint16) vxfw.DrawContext {
48 return vxfw.DrawContext{
49 Characters: ctx.Characters,
50 Max: vxfw.Size{
51 Width: ctx.Max.Width,
52 Height: h,
53 },
54 }
55}
56
57type App struct {
58 shared *SharedModel
59 pages map[string]vxfw.Widget
60 page string
61}
62
63func (app *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
64 switch msg := ev.(type) {
65 case vaxis.Key:
66 if msg.Matches('c', vaxis.ModCtrl) {
67 return vxfw.QuitCmd{}, nil
68 }
69 if msg.Matches(vaxis.KeyEsc) {
70 if app.page == "signup" || app.page == HOME {
71 return nil, nil
72 }
73 app.shared.App.PostEvent(Navigate{To: HOME})
74 }
75 }
76 return nil, nil
77}
78
79type WidgetFooter interface {
80 Footer() []Shortcut
81}
82
83func (app *App) GetCurPage() vxfw.Widget {
84 return app.pages[app.page]
85}
86
87func (app *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
88 switch msg := ev.(type) {
89 case vxfw.Init:
90 page := HOME
91 // no user? kick them to the create account page
92 if app.shared.User == nil {
93 page = "signup"
94 }
95 app.shared.App.PostEvent(Navigate{To: page})
96 return nil, nil
97 case Navigate:
98 cmds := []vxfw.Command{}
99 cur := app.GetCurPage()
100 if cur != nil {
101 // send event to page notifying that we are leaving
102 cmd, _ := cur.HandleEvent(PageOut{}, vxfw.TargetPhase)
103 if cmd != nil {
104 cmds = append(cmds, cmd)
105 }
106 }
107
108 // switch the page
109 app.page = msg.To
110
111 cur = app.GetCurPage()
112 if cur != nil {
113 // send event to page notifying that we are entering
114 cmd, _ := app.GetCurPage().HandleEvent(PageIn{}, vxfw.TargetPhase)
115 if cmd != nil {
116 cmds = append(cmds, cmd)
117 }
118 }
119
120 cmds = append(
121 cmds,
122 vxfw.RedrawCmd{},
123 )
124 return vxfw.BatchCmd(cmds), nil
125 }
126 return nil, nil
127}
128
129func (app *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
130 w := ctx.Max.Width
131 h := ctx.Max.Height
132 root := vxfw.NewSurface(w, ctx.Min.Height, app)
133
134 ah := 1
135 header := NewHeaderWdt(app.shared, app.page)
136 headerSurf, _ := header.Draw(vxfw.DrawContext{
137 Max: vxfw.Size{Width: w, Height: 2},
138 Characters: ctx.Characters,
139 })
140 root.AddChild(1, ah, headerSurf)
141 ah += int(headerSurf.Size.Height)
142
143 cur := app.GetCurPage()
144 if cur != nil {
145 pageCtx := vxfw.DrawContext{
146 Characters: ctx.Characters,
147 Max: vxfw.Size{Width: ctx.Max.Width - 1, Height: h - 2 - uint16(ah)},
148 }
149 surface, _ := app.GetCurPage().Draw(pageCtx)
150 root.AddChild(1, ah, surface)
151 }
152
153 wdgt, ok := cur.(WidgetFooter)
154 segs := []Shortcut{
155 {Shortcut: "^c", Text: "quit"},
156 {Shortcut: "esc", Text: "prev page"},
157 }
158 if ok {
159 segs = append(segs, wdgt.Footer()...)
160 }
161 footer := NewFooterWdt(app.shared, segs)
162 footerSurf, _ := footer.Draw(vxfw.DrawContext{
163 Max: vxfw.Size{Width: w, Height: 2},
164 Characters: ctx.Characters,
165 })
166 root.AddChild(1, int(ctx.Max.Height)-2, footerSurf)
167
168 return root, nil
169}
170
171type HeaderWdgt struct {
172 shared *SharedModel
173
174 page string
175}
176
177func NewHeaderWdt(shrd *SharedModel, page string) *HeaderWdgt {
178 return &HeaderWdgt{
179 shared: shrd,
180 page: page,
181 }
182}
183
184func (m *HeaderWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
185 return nil, nil
186}
187
188func (m *HeaderWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
189 logoTxt := "pico.sh"
190 ff := m.shared.PlusFeatureFlag
191 if ff != nil && ff.IsValid() {
192 logoTxt = "pico+"
193 }
194
195 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
196 // header
197 wdgt := richtext.New([]vaxis.Segment{
198 {Text: " " + logoTxt + " ", Style: vaxis.Style{Background: purp, Foreground: black}},
199 {Text: " • " + m.page, Style: vaxis.Style{Foreground: green}},
200 })
201 surf, _ := wdgt.Draw(ctx)
202 root.AddChild(0, 0, surf)
203
204 if m.shared.User != nil {
205 user := richtext.New([]vaxis.Segment{
206 {Text: "~" + m.shared.User.Name, Style: vaxis.Style{Foreground: cream}},
207 })
208 surf, _ = user.Draw(ctx)
209 root.AddChild(int(ctx.Max.Width)-int(surf.Size.Width)-1, 0, surf)
210 }
211
212 return root, nil
213}
214
215type Shortcut struct {
216 Text string
217 Shortcut string
218}
219
220type FooterWdgt struct {
221 shared *SharedModel
222
223 cmds []Shortcut
224}
225
226func NewFooterWdt(shrd *SharedModel, cmds []Shortcut) *FooterWdgt {
227 return &FooterWdgt{
228 shared: shrd,
229 cmds: cmds,
230 }
231}
232
233func (m *FooterWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
234 return nil, nil
235}
236
237func (m *FooterWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
238 segs := []vaxis.Segment{}
239 for idx, shortcut := range m.cmds {
240 segs = append(
241 segs,
242 vaxis.Segment{Text: shortcut.Shortcut, Style: vaxis.Style{Foreground: fuschia}},
243 vaxis.Segment{Text: " " + shortcut.Text},
244 )
245 if idx < len(m.cmds)-1 {
246 segs = append(segs, vaxis.Segment{Text: " • "})
247 }
248 }
249 wdgt := richtext.New(segs)
250 return wdgt.Draw(ctx)
251}
252
253func initData(shrd *SharedModel) error {
254 user, err := FindUser(shrd)
255 if err != nil {
256 return err
257 }
258 shrd.User = user
259
260 ff, _ := FindFeatureFlag(shrd, "plus")
261 shrd.PlusFeatureFlag = ff
262
263 bff, _ := FindFeatureFlag(shrd, "bouncer")
264 shrd.BouncerFeatureFlag = bff
265 return nil
266}
267
268func FindUser(shrd *SharedModel) (*db.User, error) {
269 logger := shrd.Cfg.Logger
270 var user *db.User
271 usr := shrd.Session.User()
272
273 if shrd.Session.PublicKey() == nil {
274 return nil, fmt.Errorf("unable to find public key")
275 }
276
277 key := utils.KeyForKeyText(shrd.Session.PublicKey())
278
279 user, err := shrd.Dbpool.FindUserForKey(usr, key)
280 if err != nil {
281 logger.Error("no user found for public key", "err", err.Error())
282 // we only want to throw an error for specific cases
283 if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
284 return nil, err
285 }
286 // no user and not error indicates we need to create an account
287 return nil, nil
288 }
289 origUserName := user.Name
290
291 // impersonation
292 adminPrefix := "admin__"
293 if strings.HasPrefix(usr, adminPrefix) {
294 hasFeature := shrd.Dbpool.HasFeatureForUser(user.ID, "admin")
295 if !hasFeature {
296 return nil, fmt.Errorf("only admins can impersonate a user")
297 }
298 impersonate := strings.Replace(usr, adminPrefix, "", 1)
299 user, err = shrd.Dbpool.FindUserByName(impersonate)
300 if err != nil {
301 return nil, err
302 }
303 shrd.Impersonator = origUserName
304 }
305
306 return user, nil
307}
308
309func FindFeatureFlag(shrd *SharedModel, name string) (*db.FeatureFlag, error) {
310 if shrd.User == nil {
311 return nil, nil
312 }
313
314 ff, err := shrd.Dbpool.FindFeature(shrd.User.ID, name)
315 if err != nil {
316 return nil, err
317 }
318
319 return ff, nil
320}
321
322func NewTui(opts vaxis.Options, shrd *SharedModel) error {
323 err := initData(shrd)
324 if err != nil {
325 return err
326 }
327
328 app, err := vxfw.NewApp(opts)
329 if err != nil {
330 return err
331 }
332
333 shrd.App = app
334 pages := map[string]vxfw.Widget{
335 HOME: NewMenuPage(shrd),
336 "pubkeys": NewPubkeysPage(shrd),
337 "add-pubkey": NewAddPubkeyPage(shrd),
338 "tokens": NewTokensPage(shrd),
339 "add-token": NewAddTokenPage(shrd),
340 "signup": NewSignupPage(shrd),
341 "pico+": NewPlusPage(shrd),
342 "logs": NewLogsPage(shrd),
343 "analytics": NewAnalyticsPage(shrd),
344 "chat": NewChatPage(shrd),
345 "tuns": NewTunsPage(shrd),
346 }
347 root := &App{
348 shared: shrd,
349 pages: pages,
350 }
351
352 return app.Run(root)
353}