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