diff --git a/internal/client/client.go b/internal/client/client.go index 10ca6f2..27301fe 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 } } diff --git a/internal/client/ui/auth/auth.go b/internal/client/ui/auth/auth.go new file mode 100644 index 0000000..647d238 --- /dev/null +++ b/internal/client/ui/auth/auth.go @@ -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...) +} diff --git a/internal/client/ui/auth/field/field.go b/internal/client/ui/auth/field/field.go new file mode 100644 index 0000000..afc55b1 --- /dev/null +++ b/internal/client/ui/auth/field/field.go @@ -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 +} diff --git a/internal/client/ui/chat/chat.go b/internal/client/ui/chat/chat.go new file mode 100644 index 0000000..1fd4537 --- /dev/null +++ b/internal/client/ui/chat/chat.go @@ -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 + } +} diff --git a/internal/client/ui/messagebox/messagebox.go b/internal/client/ui/messagebox/messagebox.go index 4b8d3d1..0728cb1 100644 --- a/internal/client/ui/messagebox/messagebox.go +++ b/internal/client/ui/messagebox/messagebox.go @@ -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) diff --git a/internal/client/ui/ui.go b/internal/client/ui/ui.go new file mode 100644 index 0000000..425e6b1 --- /dev/null +++ b/internal/client/ui/ui.go @@ -0,0 +1,7 @@ +package ui + +import tea "github.com/charmbracelet/bubbletea" + +type ModelTransition struct{ + Model tea.Model +}