mirror of
https://github.com/Kyren223/eko.git
synced 2026-07-12 15:20:25 +00:00
feat: working on the client UI
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
188
internal/client/ui/auth/auth.go
Normal file
188
internal/client/ui/auth/auth.go
Normal 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...)
|
||||
}
|
||||
67
internal/client/ui/auth/field/field.go
Normal file
67
internal/client/ui/auth/field/field.go
Normal 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
|
||||
}
|
||||
132
internal/client/ui/chat/chat.go
Normal file
132
internal/client/ui/chat/chat.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
7
internal/client/ui/ui.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package ui
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type ModelTransition struct{
|
||||
Model tea.Model
|
||||
}
|
||||
Reference in New Issue
Block a user