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