summaryrefslogtreecommitdiff
path: root/internal/client/ui
diff options
context:
space:
mode:
authorKyren223 <Kyren223@proton.me>2024-11-05 13:59:35 +0200
committerKyren223 <Kyren223@proton.me>2024-11-05 13:59:35 +0200
commitbdf7d2c2d2ee578edc3b6ecacbb9b0236e591b57 (patch)
tree5b0c649d1dfd2a6dc42c88cf881d50bbd177b35c /internal/client/ui
parent547acbb554e1f0e75b4200c1e5e6e404feebc58d (diff)
feat: working on the client UI
Diffstat (limited to 'internal/client/ui')
-rw-r--r--internal/client/ui/auth/auth.go188
-rw-r--r--internal/client/ui/auth/field/field.go67
-rw-r--r--internal/client/ui/chat/chat.go132
-rw-r--r--internal/client/ui/messagebox/messagebox.go23
-rw-r--r--internal/client/ui/ui.go7
5 files changed, 405 insertions, 12 deletions
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
+}