mirror of
https://github.com/Kyren223/eko.git
synced 2026-07-12 07:19:29 +00:00
feat: continued to improve the UI
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user