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