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