repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / tui
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}