feat: working on the client UI

This commit is contained in:
2024-11-05 13:59:35 +02:00
parent 547acbb554
commit bdf7d2c2d2
6 changed files with 423 additions and 118 deletions

View File

@@ -3,20 +3,13 @@ package client
import (
"context"
"crypto/ed25519"
"fmt"
"log"
"strings"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kyren223/eko/internal/client/api"
"github.com/kyren223/eko/internal/client/gateway"
"github.com/kyren223/eko/internal/client/ui/messagebox"
"github.com/kyren223/eko/internal/data"
"github.com/kyren223/eko/internal/packet"
"github.com/kyren223/eko/internal/client/ui"
"github.com/kyren223/eko/internal/client/ui/auth"
"github.com/kyren223/eko/pkg/assert"
)
@@ -33,6 +26,12 @@ func Run() {
log.Println("client started")
program := tea.NewProgram(initialModel(), tea.WithAltScreen())
assert.AddFlush(BubbleTeaCloser{program})
defer func() {
if r := recover(); r != nil {
program.Kill()
log.Println("recovered from panic:", r)
}
}()
_, privKey, err := ed25519.GenerateKey(nil)
assert.NoError(err, "private key gen should not error")
@@ -44,116 +43,29 @@ func Run() {
}
type model struct {
messagebox messagebox.Model
textarea textarea.Model
err error
model tea.Model
}
func initialModel() model {
ta := textarea.New()
ta.Placeholder = "Send a message..."
ta.Focus()
ta.Prompt = "┃ "
ta.CharLimit = 280
ta.SetWidth(30)
ta.SetHeight(3)
// Remove cursor line styling
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
ta.ShowLineNumbers = false
ta.KeyMap.InsertNewline.SetEnabled(false)
return model{
textarea: ta,
messagebox: messagebox.New(30, 20),
err: nil,
}
return model{auth.New()}
}
func (m model) Init() tea.Cmd {
return tea.Batch(textarea.Blink, api.GetMessages)
return m.model.Init()
}
func (m model) View() string {
return fmt.Sprintf(
"%s\n%s",
m.messagebox.View(),
m.textarea.View(),
) + ""
return m.model.View()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.messagebox.Viewport.Width = msg.Width
m.messagebox.Viewport.Height = msg.Height - m.textarea.Height()
m.messagebox.Viewport.GotoBottom()
m.textarea.SetWidth(msg.Width)
log.Println("resized to:", msg.Width, "x", msg.Height)
var mbCmd, taCmd tea.Cmd
m.messagebox, mbCmd = m.messagebox.Update(msg)
m.textarea, taCmd = m.textarea.Update(msg)
return m, tea.Batch(mbCmd, taCmd)
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEnter:
value := m.textarea.Value()
content := strings.TrimSpace(value)
if content == "" {
return m, nil
}
m.textarea.Reset()
return m, api.SendMessage(content)
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
case packet.Payload:
switch msg := msg.(type) {
case *packet.Messages:
m.messagebox.SetMessages(msg.Messages)
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
default:
return m, nil
}
case api.AppendMessage:
m.messagebox.AppendMessage(data.Message(msg))
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
case api.UserProfileUpdate:
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
case cursor.BlinkMsg:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
default:
case ui.ModelTransition:
m.model = msg.Model
return m, nil
default:
var cmd tea.Cmd
m.model, cmd = m.model.Update(msg)
return m, cmd
}
}

View File

@@ -0,0 +1,188 @@
package auth
import (
"errors"
"fmt"
"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"
)
var (
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198"))
noStyle = lipgloss.NewStyle()
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F16265"))
fieldStyle = lipgloss.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#007E8A"))
focusedButton = focusedStyle.Render("[ Submit ]")
blurredButton = fmt.Sprintf("[ %s ]", lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render("Submit"))
)
type Model struct {
width int
height int
focusIndex int
fields []authfield.Model
}
func New() Model {
m := Model{
fields: make([]authfield.Model, 3),
}
var field authfield.Model
for i := range m.fields {
field = authfield.New(32)
field.Input.Cursor.Style = cursorStyle
field.Style = fieldStyle
field.ErrorStyle = errorStyle
field.FocusedStyle = focusedStyle
field.BlurredStyle = noStyle
switch i {
case 0:
field.Input.Placeholder = "Username"
field.Focus()
case 1:
field.Input.Placeholder = "Email"
field.Input.CharLimit = 64
field.Input.Validate = func(email string) error {
if !strings.ContainsRune(email, '@') {
return errors.New("missing @")
}
if !strings.ContainsRune(email, '.') {
return errors.New("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) {
return errors.New("unknown email provider")
}
return nil
}
case 2:
field.Input.Placeholder = "Password"
field.Input.EchoMode = textinput.EchoPassword
field.Input.EchoCharacter = '*'
// field.Input.EchoCharacter = '•'
}
m.fields[i] = field
}
return m
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) View() string {
var builder strings.Builder
for i := range m.fields {
builder.WriteString(m.fields[i].View())
if i < len(m.fields)-1 {
builder.WriteRune('\n')
}
}
button := &blurredButton
if m.focusIndex == len(m.fields) {
button = &focusedButton
}
submit := lipgloss.NewStyle().
PaddingLeft((lipgloss.Width(builder.String()) - lipgloss.Width(*button)) / 2).
Render(*button)
fmt.Fprintf(&builder, "\n\n%s", submit)
width, height := lipgloss.Width(builder.String()), lipgloss.Height(builder.String())
vp := viewport.New(50, 25)
vp.SetContent(builder.String())
vp.Style = vp.Style.
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#0000FF")).
// NOTE: Without -1 it wraps/truncates, not sure why
Padding((vp.Height-height)/2-1, (vp.Width-width)/2-1)
return lipgloss.Place(
m.width, m.height,
lipgloss.Center, lipgloss.Center,
vp.View(),
)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
key := msg.Type
switch key {
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) {
return m, tea.Quit
}
// Cycle indexes
if key == tea.KeyUp || key == tea.KeyShiftTab {
m.focusIndex--
} else {
m.focusIndex++
}
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...)
}
}
cmd := m.updateInputs(msg)
return m, cmd
}
func (m *Model) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.fields))
// 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)
}
return tea.Batch(cmds...)
}

View File

@@ -0,0 +1,67 @@
package field
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
Input textinput.Model
Style lipgloss.Style
FocusedStyle lipgloss.Style
BlurredStyle lipgloss.Style
ErrorStyle lipgloss.Style
}
func New(width int) Model {
t := textinput.New()
t.Width = width
t.CharLimit = width
t.Prompt = ""
m := Model{
Input: t,
}
m.Blur()
return m
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) View() string {
var builder strings.Builder
builder.WriteString(m.Style.Render(m.Input.View()))
builder.WriteRune('\n')
if m.Input.Err != nil {
builder.WriteString(m.ErrorStyle.Render(m.Input.Err.Error()))
}
return builder.String()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m.Input, cmd = m.Input.Update(msg)
return m, cmd
}
func (m *Model) Focus() tea.Cmd {
m.Input.PromptStyle = m.FocusedStyle
m.Input.TextStyle = m.FocusedStyle
return m.Input.Focus()
}
func (m *Model) Blur() {
m.Input.Blur()
m.Input.PromptStyle = m.BlurredStyle
m.Input.TextStyle = m.BlurredStyle
}

View File

@@ -0,0 +1,132 @@
package chat
import (
"fmt"
"log"
"strings"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kyren223/eko/internal/client/api"
"github.com/kyren223/eko/internal/client/ui/messagebox"
"github.com/kyren223/eko/internal/data"
"github.com/kyren223/eko/internal/packet"
)
type Model struct {
messagebox messagebox.Model
textarea textarea.Model
err error
}
func New() Model {
ta := textarea.New()
ta.Placeholder = "Send a message..."
ta.Focus()
ta.Prompt = "┃ "
ta.CharLimit = 280
ta.SetWidth(30)
ta.SetHeight(3)
// Remove cursor line styling
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
ta.ShowLineNumbers = false
ta.KeyMap.InsertNewline.SetEnabled(false)
return Model{
textarea: ta,
messagebox: messagebox.New(30, 20),
err: nil,
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(textarea.Blink, api.GetMessages)
}
func (m Model) View() string {
return fmt.Sprintf(
"%s\n%s",
m.messagebox.View(),
m.textarea.View(),
) + ""
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.messagebox.Viewport.Width = msg.Width
m.messagebox.Viewport.Height = msg.Height - m.textarea.Height()
m.messagebox.Viewport.GotoBottom()
m.textarea.SetWidth(msg.Width)
log.Println("resized to:", msg.Width, "x", msg.Height)
var mbCmd, taCmd tea.Cmd
m.messagebox, mbCmd = m.messagebox.Update(msg)
m.textarea, taCmd = m.textarea.Update(msg)
return m, tea.Batch(mbCmd, taCmd)
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEnter:
value := m.textarea.Value()
content := strings.TrimSpace(value)
if content == "" {
return m, nil
}
m.textarea.Reset()
return m, api.SendMessage(content)
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
case packet.Payload:
switch msg := msg.(type) {
case *packet.Messages:
m.messagebox.SetMessages(msg.Messages)
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
default:
return m, nil
}
case api.AppendMessage:
m.messagebox.AppendMessage(data.Message(msg))
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
case api.UserProfileUpdate:
var cmd tea.Cmd
m.messagebox, cmd = m.messagebox.Update(msg)
return m, cmd
case cursor.BlinkMsg:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
default:
return m, nil
}
}

View File

@@ -14,6 +14,11 @@ import (
"github.com/kyren223/eko/pkg/snowflake"
)
var (
senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true)
timestampStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#585c62")).Italic(true)
)
type user struct {
name string
isFetching bool
@@ -22,20 +27,14 @@ type user struct {
type Model struct {
Viewport viewport.Model
messages []data.Message
senderStyle lipgloss.Style
timestampStyle lipgloss.Style
users map[snowflake.ID]user
users map[snowflake.ID]user
}
func New(width, height int) Model {
return Model{
Viewport: viewport.New(width, height),
messages: nil,
senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true),
timestampStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#585c62")).Italic(true),
users: make(map[snowflake.ID]user),
Viewport: viewport.New(width, height),
messages: nil,
users: make(map[snowflake.ID]user),
}
}
@@ -112,11 +111,11 @@ func (m *Model) updateContent() {
for _, message := range m.messages {
sender := m.users[message.SenderID].name
builder.WriteString(m.senderStyle.Render(sender))
builder.WriteString(senderStyle.Render(sender))
builder.WriteByte(' ')
timestamp := timestampFromID(message.ID)
builder.WriteString(m.timestampStyle.Render(timestamp))
builder.WriteString(timestampStyle.Render(timestamp))
builder.WriteByte('\n')
builder.WriteString(message.Content)

7
internal/client/ui/ui.go Normal file
View File

@@ -0,0 +1,7 @@
package ui
import tea "github.com/charmbracelet/bubbletea"
type ModelTransition struct{
Model tea.Model
}