repos / pico

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

pico / pkg / tui
Eric Bower  ·  2025-03-28

ui.go

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