implement simple tui with humans data
This commit is contained in:
+206
-58
@@ -1,28 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
pbhuman "github.com/flowy-live/admin-cli/genproto/helios/human"
|
||||
pbkeypad "github.com/flowy-live/admin-cli/genproto/helios/keypad"
|
||||
"github.com/mergestat/timediff"
|
||||
"github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
choices []string // items on the to-do list
|
||||
cursor int // which to-do list item our cursor is pointing at
|
||||
selected map[int]struct{} // which to-do items are selected
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#7D56F4")).
|
||||
Padding(0, 1).
|
||||
MarginBottom(1)
|
||||
|
||||
selectedItemStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#7D56F4")).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
normalItemStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FAFAFA")).
|
||||
Padding(0, 1)
|
||||
|
||||
dimItemStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
Padding(0, 1)
|
||||
|
||||
cursorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF75B5")).
|
||||
Bold(true)
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262")).
|
||||
Padding(1, 0, 0, 1)
|
||||
|
||||
statusStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#7D56F4")).
|
||||
Bold(true)
|
||||
|
||||
borderStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#3C3C3C"))
|
||||
|
||||
timestampStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#888888")).
|
||||
Italic(true)
|
||||
|
||||
selectedTimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#CCCCCC")).
|
||||
Italic(true)
|
||||
)
|
||||
|
||||
type HumanData struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
JoinedAt time.Time
|
||||
KeypadConfiguration string
|
||||
}
|
||||
|
||||
func initialModel() model {
|
||||
return model{
|
||||
// Our to-do list is a grocery list
|
||||
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
|
||||
type model struct {
|
||||
humans []*HumanData
|
||||
cursor int
|
||||
selectedHumanEmail string
|
||||
viewport viewport.Model
|
||||
ready bool
|
||||
showKeypad bool
|
||||
}
|
||||
|
||||
// A map which indicates which choices are selected. We're using
|
||||
// the map like a mathematical set. The keys refer to the indexes
|
||||
// of the `choices` slice, above.
|
||||
selected: make(map[int]struct{}),
|
||||
func initialModel(humans []*HumanData) model {
|
||||
return model{
|
||||
humans: humans,
|
||||
cursor: 0,
|
||||
showKeypad: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,81 +95,166 @@ func (m model) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
headerHeight := 3 // title + status + separator
|
||||
footerHeight := 2 // help text
|
||||
verticalMarginHeight := headerHeight + footerHeight
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
|
||||
m.viewport.YPosition = headerHeight
|
||||
m.viewport.SetContent(m.renderList())
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - verticalMarginHeight
|
||||
}
|
||||
|
||||
// Is it a key press?
|
||||
case tea.KeyMsg:
|
||||
|
||||
// Cool, what was the actual key pressed?
|
||||
switch msg.String() {
|
||||
|
||||
// These keys should exit the program.
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
// The "up" and "k" keys move the cursor up
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.viewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
// The "down" and "j" keys move the cursor down
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.choices)-1 {
|
||||
if m.cursor < len(m.humans)-1 {
|
||||
m.cursor++
|
||||
m.viewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
// The "enter" key and the spacebar (a literal space) toggle
|
||||
// the selected state for the item that the cursor is pointing at.
|
||||
case "enter", " ":
|
||||
_, ok := m.selected[m.cursor]
|
||||
if ok {
|
||||
delete(m.selected, m.cursor)
|
||||
} else {
|
||||
m.selected[m.cursor] = struct{}{}
|
||||
}
|
||||
case "t":
|
||||
m.showKeypad = !m.showKeypad
|
||||
m.viewport.SetContent(m.renderList())
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated model to the Bubble Tea runtime for processing.
|
||||
// Note that we're not returning a command.
|
||||
return m, nil
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) renderList() string {
|
||||
var b strings.Builder
|
||||
for i, human := range m.humans {
|
||||
email := human.Email
|
||||
|
||||
// Get relative time ago
|
||||
timeAgo := timediff.TimeDiff(human.JoinedAt)
|
||||
|
||||
if m.cursor == i {
|
||||
// Current selection - highlighted
|
||||
cursor := cursorStyle.Render("▸")
|
||||
item := selectedItemStyle.Render(email)
|
||||
timestamp := selectedTimestampStyle.Render(fmt.Sprintf("joined %s", timeAgo))
|
||||
b.WriteString(fmt.Sprintf("%s %s %s\n", cursor, item, timestamp))
|
||||
if m.showKeypad {
|
||||
b.WriteString(fmt.Sprintf("%s\n", selectedItemStyle.Render(human.KeypadConfiguration)))
|
||||
}
|
||||
} else {
|
||||
// Normal item
|
||||
cursor := dimItemStyle.Render(" ")
|
||||
item := normalItemStyle.Render(email)
|
||||
timestamp := timestampStyle.Render(fmt.Sprintf("joined %s", timeAgo))
|
||||
b.WriteString(fmt.Sprintf("%s %s %s\n", cursor, item, timestamp))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
// The header
|
||||
s := "What should we buy at the market?\n\n"
|
||||
|
||||
// Iterate over our choices
|
||||
for i, choice := range m.choices {
|
||||
|
||||
// Is the cursor pointing at this choice?
|
||||
cursor := " " // no cursor
|
||||
if m.cursor == i {
|
||||
cursor = ">" // cursor!
|
||||
}
|
||||
|
||||
// Is this choice selected?
|
||||
checked := " " // not selected
|
||||
if _, ok := m.selected[i]; ok {
|
||||
checked = "x" // selected!
|
||||
}
|
||||
|
||||
// Render the row
|
||||
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
|
||||
if !m.ready {
|
||||
return "\n Initializing..."
|
||||
}
|
||||
|
||||
// The footer
|
||||
s += "\nPress q to quit.\n"
|
||||
// Header with main data
|
||||
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
|
||||
pastSevenDaysSignupsCount := 0
|
||||
for _, human := range m.humans {
|
||||
if human.JoinedAt.After(sevenDaysAgo) {
|
||||
pastSevenDaysSignupsCount++
|
||||
}
|
||||
}
|
||||
header := titleStyle.Render(fmt.Sprintf("👥 Humans | %d total | %d in the past 7 days", len(m.humans), pastSevenDaysSignupsCount))
|
||||
|
||||
// Send the UI for rendering
|
||||
return s
|
||||
// Status bar showing current position
|
||||
statusBar := statusStyle.Render(fmt.Sprintf(" %d/%d", m.cursor+1, len(m.humans)))
|
||||
|
||||
// Help text
|
||||
help := helpStyle.Render("↑/k up • ↓/j down • t toggle keypads • q quit")
|
||||
|
||||
// Separator
|
||||
separator := borderStyle.Render(strings.Repeat("─", m.viewport.Width))
|
||||
|
||||
return fmt.Sprintf("%s%s\n%s\n%s\n%s", header, statusBar, separator, m.viewport.View(), help)
|
||||
}
|
||||
|
||||
func unaryInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
md := metadata.Pairs("authorization", "talksik")
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
return invoker(ctx, method, req, reply, cc, opts...)
|
||||
}
|
||||
|
||||
func main() {
|
||||
logrus.Infof("hello world")
|
||||
|
||||
p := tea.NewProgram(initialModel())
|
||||
heliosAddr := "helios.flowy.live:443"
|
||||
creds := credentials.NewTLS(&tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
heliosService, err := grpc.NewClient(heliosAddr, grpc.WithTransportCredentials(creds), grpc.WithUnaryInterceptor(unaryInterceptor))
|
||||
if err != nil {
|
||||
logrus.Fatalf("connection to aero server invalid: %v", err)
|
||||
}
|
||||
defer heliosService.Close()
|
||||
|
||||
humanService := pbhuman.NewHumanServiceClient(heliosService)
|
||||
keypadService := pbkeypad.NewKeypadServiceClient(heliosService)
|
||||
humansResponse, err := humanService.ListHumans(context.Background(), &pbhuman.ListHumansRequest{})
|
||||
if err != nil {
|
||||
logrus.Fatalf("unable to fetch humans: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
keypadsResponse, err := keypadService.ListKeypads(context.Background(), &pbkeypad.ListKeypadsRequest{})
|
||||
if err != nil {
|
||||
logrus.Fatalf("unable to fetch keypads: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
keypadsResponse.GetHumanKeypads()[0].GetHumanId()
|
||||
keypadsResponse.GetHumanKeypads()[0].GetYamlConfig()
|
||||
|
||||
logrus.Info("connected to helios")
|
||||
logrus.Infof("fetched %d humans", len(humansResponse.GetHumans()))
|
||||
|
||||
humansData := []*HumanData{}
|
||||
for _, human := range humansResponse.GetHumans() {
|
||||
keypadConfiguration := ""
|
||||
for _, keypad := range keypadsResponse.GetHumanKeypads() {
|
||||
if keypad.GetHumanId() == human.GetId() {
|
||||
keypadConfiguration = keypad.GetYamlConfig()
|
||||
}
|
||||
}
|
||||
humansData = append(humansData, &HumanData{
|
||||
Email: human.GetEmail(),
|
||||
DisplayName: human.GetDisplayName(),
|
||||
JoinedAt: human.GetJoinedAt().AsTime(),
|
||||
KeypadConfiguration: keypadConfiguration,
|
||||
})
|
||||
}
|
||||
|
||||
p := tea.NewProgram(initialModel(humansData))
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Alas, there's been an error: %v", err)
|
||||
os.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user