repos / pico

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

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