feat: continued to improve the UI

This commit is contained in:
2024-11-07 18:00:35 +02:00
parent dd3fd8c0a7
commit e29b7ade2f
2 changed files with 313 additions and 126 deletions

View File

@@ -3,17 +3,26 @@ package auth
import (
"errors"
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
authfield "github.com/kyren223/eko/internal/client/ui/auth/field"
"github.com/kyren223/eko/pkg/assert"
)
const (
usernameField = iota
privateKeyField
passphraseField
passphraseConfirmField
)
var (
grayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198"))
noStyle = lipgloss.NewStyle()
@@ -23,13 +32,28 @@ var (
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#007E8A"))
focusedButton = focusedStyle.Render("[ sign-up ]")
blurredButton = fmt.Sprintf("[ %s ]", lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render("sign-up"))
focusedSignupButton = focusedStyle.Render("[ sign-up ]")
focusedSigninButton = focusedStyle.Render("[ sign-in ]")
blurredSignupButton = fmt.Sprintf("[ %s ]", grayStyle.Render("sign-up"))
blurredSigninButton = fmt.Sprintf("[ %s ]", grayStyle.Render("sign-in"))
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#54D7A9"))
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5874FF"))
signupTitle = titleStyle.Render(`
____ _ ____ _ _ _ _ ___
[__ | | __ |\ | __ | | |__]
___] | |__] | \| |__| |
`)
signinTitle = titleStyle.Render(`
____ _ ____ _ _ _ _ _
[__ | | __ |\ | __ | |\ |
___] | |__] | \| | | \|
`)
revealIcon = lipgloss.NewStyle().PaddingLeft(1).Render("󰈈 ")
concealIcon = lipgloss.NewStyle().PaddingLeft(1).Render("󰈉 ")
)
type Model struct {
@@ -38,16 +62,17 @@ type Model struct {
focusIndex int
fields []authfield.Model
signup bool
}
func New() Model {
m := Model{
fields: make([]authfield.Model, 3),
fields: make([]authfield.Model, 4),
}
var field authfield.Model
for i := range m.fields {
field = authfield.New(32)
field := authfield.New(48)
field.Input.Cursor.Style = cursorStyle
field.Style = fieldStyle
field.ErrorStyle = errorStyle
@@ -55,60 +80,52 @@ func New() Model {
field.BlurredStyle = noStyle
switch i {
case 0:
field.Header = "Username"
case usernameField:
field.Header = headerStyle.Render("Username")
field.Input.Placeholder = "Username"
field.Focus()
field.Input.Validate = func(username string) error {
if len(username) == 0 {
return errors.New("* required field")
return errors.New("Required")
}
return nil
}
case 1:
field.Header = "Email (Optional)"
field.Input.Placeholder = "Email"
field.Input.CharLimit = 64
field.Input.Validate = func(email string) error {
if len(email) == 0 {
return errors.New("* required field")
}
var errs []string
if !strings.ContainsRune(email, '@') {
errs = append(errs, "- missing @")
}
if !strings.ContainsRune(email, '.') {
errs = append(errs, "- missing .")
}
proton := strings.Contains(email, "proton.me") || strings.Contains(email, "protonmail.com")
gmail := strings.Contains(email, "gmail.com")
outlook := strings.Contains(email, "hotmail.com")
if !(proton || gmail || outlook) {
errs = append(errs, "- unknown email provider")
}
if errs != nil {
return errors.New(strings.Join(errs, "\n"))
case privateKeyField:
field.Header = headerStyle.Render("Private Key")
field.Input.Placeholder = "Path to Private Key"
// TODO: Consider if I need a custom validate function or not
// I most likely need it for prompts
// So if signup is on and private key file exists suggest to either:
// 1. Overwrite it 2. Switch to sign-in 3. Cancel
// And if it's off and the file doesn't exist, suggest:
// 1. Switch to sign-up 2. Cancel
field.Input.Validate = func(privKey string) error {
if len(privKey) == 0 {
return errors.New("Required")
}
return nil
}
case 2:
field.Header = "Password"
field.Input.Placeholder = "Password"
field.Input.EchoMode = textinput.EchoPassword
case passphraseField:
field.Header = headerStyle.Render("Passphrase (Optional)")
field.Input.Placeholder = "Passphrase"
field.SetRevealIcon(revealIcon)
field.SetConcealIcon(concealIcon)
field.SetRevealed(false)
field.Input.EchoCharacter = '*'
case passphraseConfirmField:
field.Header = headerStyle.Render("Passphrase Confirm")
field.Input.Placeholder = "Repeated Passphrase"
field.SetRevealIcon(revealIcon)
field.SetConcealIcon(concealIcon)
field.SetRevealed(false)
field.Input.EchoCharacter = '*'
// field.Input.EchoCharacter = '•'
field.Input.Validate = func(password string) error {
if len(password) == 0 {
return errors.New("* required field")
}
return nil
}
}
field.Header = headerStyle.Render(field.Header)
m.fields[i] = field
}
m.SetSignup(false)
return m
}
@@ -120,38 +137,51 @@ func (m Model) View() string {
var builder strings.Builder
builder.WriteRune('\n')
for i := range m.fields {
builder.WriteString(m.fields[i].View())
for i, field := range m.fields {
if field.Visisble {
builder.WriteString(m.fields[i].View())
} else {
builder.WriteString("\n\n")
}
if i < len(m.fields)-1 {
builder.WriteRune('\n')
}
}
button := &blurredButton
if m.focusIndex == len(m.fields) {
button = &focusedButton
var button *string
if m.signup {
if m.focusIndex == len(m.fields) {
button = &focusedSignupButton
} else {
button = &blurredSignupButton
}
} else {
if m.focusIndex == len(m.fields) {
button = &focusedSigninButton
} else {
button = &blurredSigninButton
}
}
submit := lipgloss.NewStyle().
Render(*button)
fmt.Fprintf(&builder, "\n\n%s", submit)
fmt.Fprintf(&builder, "\n\n%s", *button)
title := titleStyle.Render(`
____ _ ____ _ _ _ _ ___
[__ | | __ |\ | __ | | |__]
___] | |__] | \| |__| |
`)
var title string
if m.signup {
title = signupTitle
} else {
title = signinTitle
}
// Tiny offset so odd numbers will have the extra char on the right, ie: 12 <thing> 13
content := lipgloss.JoinVertical(lipgloss.Center-0.01, title, builder.String())
width := lipgloss.Width(content)
vp := viewport.New(50, 25)
vp := viewport.New(64, 23)
vp.SetContent(content)
vp.Style = vp.Style.
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#007E8A")).
// NOTE: Without -1 it wraps/truncates, not sure why
Padding(0, (vp.Width-width)/2-1)
Padding(0, (vp.Width-width)/2-1, 1)
return lipgloss.Place(
m.width, m.height,
@@ -172,66 +202,151 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyTab, tea.KeyShiftTab, tea.KeyEnter, tea.KeyUp, tea.KeyDown:
// User pressed submit
if key == tea.KeyEnter && m.focusIndex == len(m.fields) {
var cmds []tea.Cmd
for i, field := range m.fields {
if field.Input.Validate == nil {
continue
}
field.Input.Err = field.Input.Validate(field.Input.Value())
if field.Input.Err != nil {
var cmd tea.Cmd
m.fields[i], cmd = field.Update(msg)
cmds = append(cmds, cmd)
}
}
if len(cmds) == 0 {
return m, tea.Quit
}
return m, tea.Batch(cmds...)
case tea.KeyCtrlS:
return m, m.SetSignup(!m.signup)
case tea.KeyCtrlT:
if m.focusIndex == passphraseField || m.focusIndex == passphraseConfirmField {
m.fields[m.focusIndex].SetRevealed(!m.fields[m.focusIndex].Revealed())
}
return m, nil
case tea.KeyEnter:
pressedButton := key == tea.KeyEnter && m.focusIndex == len(m.fields)
if pressedButton {
return m, m.ButtonPressed(msg)
}
// Cycle indexes
if key == tea.KeyUp || key == tea.KeyShiftTab {
m.focusIndex--
} else {
m.focusIndex++
}
case tea.KeyShiftTab, tea.KeyUp:
m.CycleBack()
return m, m.updateFocus()
if m.focusIndex > len(m.fields) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.fields)
}
cmds := make([]tea.Cmd, len(m.fields))
for i := 0; i <= len(m.fields)-1; i++ {
if i == m.focusIndex {
cmds[i] = m.fields[i].Focus()
} else {
m.fields[i].Blur()
}
}
return m, tea.Batch(cmds...)
case tea.KeyDown, tea.KeyTab:
m.CycleForward()
return m, m.updateFocus()
}
}
cmd := m.updateInputs(msg)
cmd := func() tea.Cmd {
cmds := make([]tea.Cmd, len((&m).fields))
for i := range (&m).fields {
(&m).fields[i], cmds[i] = (&m).fields[i].Update(msg)
}
return tea.Batch(cmds...)
}()
return m, cmd
}
func (m *Model) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.fields))
func (m *Model) CycleBack() {
for i := 0; ; i++ {
m.focusIndex--
// Only fields with Focus() set will respond, so it's safe to simply
// update all of them here without any further logic.
for i := range m.fields {
m.fields[i], cmds[i] = m.fields[i].Update(msg)
if m.focusIndex < 0 {
m.focusIndex = len(m.fields)
}
if m.focusIndex == len(m.fields) || m.fields[m.focusIndex].Visisble {
break
}
assert.Assert(i < 2*len(m.fields), "CycleBack infinite loop", "i", i)
}
}
func (m *Model) CycleForward() {
for i := 0; ; i++ {
m.focusIndex++
if m.focusIndex > len(m.fields) {
m.focusIndex = 0
}
if m.focusIndex == len(m.fields) || m.fields[m.focusIndex].Visisble {
break
}
assert.Assert(i < 2*len(m.fields), "CycleForward infinite loop", "i", i)
}
}
func (m *Model) updateFocus() tea.Cmd {
cmds := make([]tea.Cmd, len(m.fields))
for i := 0; i < len(m.fields); i++ {
if i == m.focusIndex {
cmds[i] = m.fields[i].Focus()
} else {
m.fields[i].Blur()
}
}
return tea.Batch(cmds...)
}
func (m *Model) SetSignup(signup bool) tea.Cmd {
m.signup = signup
for i, field := range m.fields {
visible := m.signup || (i == privateKeyField || i == passphraseField)
field.Visisble = visible
field.Input.Err = nil
m.fields[i] = field
}
m.focusIndex = -1
m.CycleForward()
return m.updateFocus()
}
func (m *Model) ButtonPressed(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
for i, field := range m.fields {
if !field.Visisble || field.Input.Validate == nil {
continue
}
field.Input.Err = field.Input.Validate(field.Input.Value())
if field.Input.Err != nil {
var cmd tea.Cmd
m.fields[i], cmd = field.Update(msg)
cmds = append(cmds, cmd)
}
}
if len(cmds) != 0 {
return tea.Batch(cmds...)
}
if m.signup {
return m.Signup()
} else {
return m.signin()
}
}
func (m *Model) Signup() tea.Cmd {
return tea.Quit
}
func (m *Model) signin() tea.Cmd {
privateKeyFilepath := m.fields[privateKeyField].Input.Value()
_, err := os.ReadFile(privateKeyFilepath)
if errors.Is(err, os.ErrNotExist) {
// TODO: add suggestion to move to signup, a popup like:
// File <file> doesn't exist.
// Do you want to go to sign-up?
// Sign-up Cancel
return nil
}
if err != nil {
m.fields[privateKeyField].Input.Err = errors.Unwrap(err)
assert.NotNil(errors.Unwrap(err), "there should always be an error to unwrap", "err", err)
return nil
}
return tea.Quit
}
func test() {
// pubKey, privKey, err := ed25519.GenerateKey(nil)
// sshPrivKey, err := ssh.NewSignerFromSigner(privKey)
// ssh.MarshalPrivateKey()
// ssh.MarshalPrivateKey()
// ssh.ParseRawPrivateKey()
// ssh.ParseRawPrivateKeyWithPassphrase()
}

View File

@@ -9,25 +9,36 @@ import (
)
type Model struct {
Input textinput.Model
Input textinput.Model
Header string
Visisble bool
width int
reveal bool
revealIcon string
concealIcon string
icon string
Style lipgloss.Style
FocusedStyle lipgloss.Style
BlurredStyle lipgloss.Style
ErrorStyle lipgloss.Style
Header string
HeaderStyle lipgloss.Style
}
func New(width int) Model {
t := textinput.New()
t.Width = width
t.CharLimit = width
t.Prompt = ""
input := textinput.New()
input.Prompt = ""
input.Width = width
m := Model{
Input: t,
width: width,
Visisble: true,
Input: input,
}
m.Blur()
m.SetRevealed(true)
return m
}
@@ -37,21 +48,29 @@ func (m Model) Init() tea.Cmd {
}
func (m Model) View() string {
style := m.Style
error := ""
if m.Input.Err != nil {
error = m.Input.Err.Error()
style = style.BorderForeground(m.ErrorStyle.GetForeground())
if !m.Visisble {
return ""
}
field := ui.AddBorderHeader(m.Header, 1, style, m.Input.View())
error = m.ErrorStyle.MaxWidth(lipgloss.Width(field)).Render(error)
style := m.Style
header := m.HeaderStyle.Render(m.Header)
if m.Input.Err != nil {
error := m.Input.Err.Error()
style = style.BorderForeground(m.ErrorStyle.GetForeground())
header = lipgloss.NewStyle().
MaxWidth(m.width).
Render(header + m.ErrorStyle.Render(" - "+error))
}
// return lipgloss.JoinVertical(lipgloss.Left, borderTop, field, error)
return lipgloss.JoinVertical(lipgloss.Left, field, error)
field := ui.AddBorderHeader(header, 1, style, m.Input.View()+m.icon)
return field
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.Visisble {
return m, nil
}
var cmd tea.Cmd
m.Input, cmd = m.Input.Update(msg)
return m, cmd
@@ -68,3 +87,56 @@ func (m *Model) Blur() {
m.Input.PromptStyle = m.BlurredStyle
m.Input.TextStyle = m.BlurredStyle
}
func (m *Model) SetWidth(width int) {
m.width = width
m.recalculateWidth()
}
func (m Model) Width() int {
return m.width
}
func (m *Model) SetRevealed(revealed bool) {
m.reveal = revealed
if m.reveal {
m.Input.EchoMode = textinput.EchoNormal
m.icon = m.revealIcon
} else {
m.Input.EchoMode = textinput.EchoPassword
m.icon = m.concealIcon
}
m.recalculateWidth()
}
func (m Model) Revealed() bool {
return m.reveal
}
func (m *Model) SetRevealIcon(icon string) {
m.revealIcon = icon
if m.reveal {
m.icon = icon
}
m.recalculateWidth()
}
func (m Model) RevealIcon() string {
return m.revealIcon
}
func (m *Model) SetConcealIcon(icon string) {
m.concealIcon = icon
if !m.reveal {
m.icon = icon
}
m.recalculateWidth()
}
func (m Model) ConcealIcon() string {
return m.concealIcon
}
func (m *Model) recalculateWidth() {
m.Input.Width = m.width - lipgloss.Width(m.icon)
}