2021-04-04 17:06:17 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/muesli/reflow/wordwrap"
|
2021-04-04 17:06:17 +00:00
|
|
|
)
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
const viewportMargin = 6
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
var (
|
|
|
|
appStyle = lipgloss.NewStyle().Padding(1, 2)
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
titleStyle = func() lipgloss.Style {
|
|
|
|
b := lipgloss.RoundedBorder()
|
|
|
|
b.Right = "├"
|
|
|
|
return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
|
|
|
|
}().Render
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render
|
|
|
|
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Render
|
|
|
|
senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render
|
|
|
|
)
|
|
|
|
|
|
|
|
type errMsg error
|
|
|
|
|
|
|
|
type sending bool
|
|
|
|
|
|
|
|
type quit bool
|
|
|
|
|
|
|
|
type MessageType int
|
|
|
|
|
|
|
|
const (
|
|
|
|
ChatMessageType MessageType = iota
|
|
|
|
InfoMessageType
|
|
|
|
ErrorMessageType
|
|
|
|
)
|
|
|
|
|
|
|
|
type message struct {
|
|
|
|
mType MessageType
|
|
|
|
err error
|
|
|
|
author string
|
|
|
|
clock time.Time
|
|
|
|
content string
|
|
|
|
}
|
|
|
|
|
|
|
|
type UI struct {
|
|
|
|
ready bool
|
|
|
|
err error
|
|
|
|
|
|
|
|
quitChan chan struct{}
|
|
|
|
readyChan chan<- struct{}
|
|
|
|
inputChan chan<- string
|
|
|
|
|
|
|
|
messageChan chan message
|
|
|
|
messages []message
|
|
|
|
|
|
|
|
isSendingChan chan sending
|
|
|
|
isSending bool
|
|
|
|
|
|
|
|
width int
|
|
|
|
height int
|
|
|
|
|
|
|
|
viewport viewport.Model
|
|
|
|
textarea textarea.Model
|
|
|
|
|
|
|
|
spinner spinner.Model
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewUIModel(readyChan chan<- struct{}, inputChan chan<- string) UI {
|
|
|
|
width, height := GetTerminalDimensions()
|
|
|
|
|
|
|
|
ta := textarea.New()
|
|
|
|
ta.Placeholder = "Send a message..."
|
|
|
|
ta.Focus()
|
|
|
|
|
|
|
|
ta.Prompt = "┃ "
|
|
|
|
ta.CharLimit = 2000
|
|
|
|
|
|
|
|
// Remove cursor line styling
|
|
|
|
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
|
|
ta.SetHeight(3)
|
|
|
|
ta.SetWidth(width)
|
|
|
|
ta.ShowLineNumbers = false
|
|
|
|
|
|
|
|
ta.KeyMap.InsertNewline.SetEnabled(false)
|
|
|
|
|
|
|
|
s := spinner.New()
|
|
|
|
s.Spinner = spinner.Jump
|
|
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
|
|
|
|
|
|
m := UI{
|
|
|
|
messageChan: make(chan message, 100),
|
|
|
|
isSendingChan: make(chan sending, 100),
|
|
|
|
quitChan: make(chan struct{}),
|
|
|
|
readyChan: readyChan,
|
|
|
|
inputChan: inputChan,
|
|
|
|
width: width,
|
|
|
|
height: height,
|
|
|
|
textarea: ta,
|
|
|
|
spinner: s,
|
|
|
|
err: nil,
|
|
|
|
}
|
|
|
|
|
|
|
|
return m
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m UI) Init() tea.Cmd {
|
|
|
|
return tea.Batch(
|
|
|
|
recvQuitSignal(m.quitChan),
|
|
|
|
recvMessages(m.messageChan),
|
|
|
|
recvSendingState(m.isSendingChan),
|
|
|
|
textarea.Blink,
|
|
|
|
spinner.Tick,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
var (
|
|
|
|
tiCmd tea.Cmd
|
|
|
|
vpCmd tea.Cmd
|
|
|
|
)
|
|
|
|
|
|
|
|
m.textarea, tiCmd = m.textarea.Update(msg)
|
|
|
|
m.viewport, vpCmd = m.viewport.Update(msg)
|
|
|
|
|
|
|
|
var cmdToReturn []tea.Cmd = []tea.Cmd{tiCmd, vpCmd}
|
|
|
|
|
|
|
|
headerHeight := lipgloss.Height(m.headerView())
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
printMessages := false
|
|
|
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
|
|
|
case tea.WindowSizeMsg:
|
|
|
|
m.width, m.height = msg.Width, msg.Height
|
|
|
|
|
|
|
|
if !m.ready {
|
|
|
|
// Since this program is using the full size of the viewport we
|
|
|
|
// need to wait until we've received the window dimensions before
|
|
|
|
// we can initialize the viewport. The initial dimensions come in
|
|
|
|
// quickly, though asynchronously, which is why we wait for them
|
|
|
|
// here.
|
|
|
|
m.viewport = viewport.New(msg.Width, msg.Height-headerHeight-viewportMargin)
|
|
|
|
m.viewport.SetContent("")
|
|
|
|
m.viewport.YPosition = headerHeight + 1
|
|
|
|
m.viewport.KeyMap = DefaultKeyMap()
|
|
|
|
m.ready = true
|
|
|
|
|
|
|
|
close(m.readyChan)
|
|
|
|
} else {
|
|
|
|
m.viewport.Width = msg.Width
|
|
|
|
m.viewport.Height = msg.Height - headerHeight - viewportMargin
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
printMessages = true
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
switch msg.Type {
|
|
|
|
case tea.KeyCtrlC, tea.KeyEsc:
|
|
|
|
return m, tea.Quit
|
|
|
|
case tea.KeyEnter:
|
|
|
|
line := m.textarea.Value()
|
|
|
|
if len(line) != 0 {
|
|
|
|
m.inputChan <- line
|
|
|
|
m.textarea.Reset()
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
// We handle errors just like any other message
|
|
|
|
case errMsg:
|
|
|
|
m.err = msg
|
|
|
|
return m, nil
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
case message:
|
|
|
|
m.messages = append(m.messages, msg)
|
|
|
|
printMessages = true
|
|
|
|
cmdToReturn = append(cmdToReturn, recvMessages(m.messageChan))
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
case quit:
|
2022-08-15 23:29:19 +00:00
|
|
|
fmt.Println("Bye!")
|
2022-08-15 18:29:59 +00:00
|
|
|
return m, tea.Quit
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
case sending:
|
|
|
|
m.isSending = bool(msg)
|
|
|
|
cmdToReturn = append(cmdToReturn, recvSendingState(m.isSendingChan))
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
case spinner.TickMsg:
|
|
|
|
var cmd tea.Cmd
|
|
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
|
|
return m, cmd
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
if printMessages {
|
|
|
|
var sb strings.Builder
|
|
|
|
for i, msg := range m.messages {
|
|
|
|
line := ""
|
|
|
|
|
|
|
|
switch msg.mType {
|
|
|
|
case ChatMessageType:
|
|
|
|
line += m.breaklineIfNeeded(i, ChatMessageType)
|
|
|
|
msgLine := "[" + msg.clock.Format("Jan 02 15:04") + " " + senderStyle(msg.author) + "] "
|
|
|
|
msgLine += msg.content
|
|
|
|
line += wordwrap.String(line+msgLine, m.width-10)
|
|
|
|
case ErrorMessageType:
|
|
|
|
line += m.breaklineIfNeeded(i, ErrorMessageType)
|
|
|
|
line += wordwrap.String(errorStyle("ERROR:")+" "+msg.err.Error(), m.width-10)
|
|
|
|
case InfoMessageType:
|
|
|
|
line += m.breaklineIfNeeded(i, InfoMessageType)
|
|
|
|
line += wordwrap.String(infoStyle("INFO:")+" "+msg.content, m.width-10)
|
|
|
|
}
|
|
|
|
|
|
|
|
sb.WriteString(line + "\n")
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
m.viewport.SetContent(sb.String())
|
|
|
|
m.viewport.GotoBottom()
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
return m, tea.Batch(cmdToReturn...)
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) breaklineIfNeeded(i int, mt MessageType) string {
|
|
|
|
result := ""
|
|
|
|
if i > 0 {
|
|
|
|
if (mt == ChatMessageType && m.messages[i-1].mType != ChatMessageType) || (mt != ChatMessageType && m.messages[i-1].mType == ChatMessageType) {
|
|
|
|
result += "\n"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
2022-08-04 21:39:12 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) headerView() string {
|
|
|
|
title := titleStyle("Chat2 •")
|
|
|
|
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)-4))
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
|
|
|
|
}
|
2022-08-04 21:39:12 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func max(a, b int) int {
|
|
|
|
if a > b {
|
|
|
|
return a
|
|
|
|
}
|
|
|
|
return b
|
|
|
|
}
|
2022-08-04 21:39:12 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) View() string {
|
|
|
|
spinnerStr := ""
|
|
|
|
inputStr := ""
|
|
|
|
if m.isSending {
|
|
|
|
spinnerStr = m.spinner.View() + " Sending message..."
|
|
|
|
} else {
|
|
|
|
inputStr = m.textarea.View()
|
2022-08-04 21:39:12 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
return appStyle.Render(fmt.Sprintf(
|
|
|
|
"%s\n%s\n%s%s\n",
|
|
|
|
m.headerView(),
|
|
|
|
m.viewport.View(),
|
|
|
|
inputStr,
|
|
|
|
spinnerStr,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func recvMessages(sub chan message) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
return <-sub
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func recvSendingState(sub chan sending) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
return <-sub
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func recvQuitSignal(q chan struct{}) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
<-q
|
|
|
|
return quit(true)
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) Quit() {
|
|
|
|
m.quitChan <- struct{}{}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) SetSending(isSending bool) {
|
|
|
|
m.isSendingChan <- sending(isSending)
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) ErrorMessage(err error) {
|
|
|
|
m.messageChan <- message{mType: ErrorMessageType, err: err}
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) InfoMessage(text string) {
|
|
|
|
m.messageChan <- message{mType: InfoMessageType, content: text}
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
func (m UI) ChatMessage(clock int64, author string, text string) {
|
|
|
|
m.messageChan <- message{mType: ChatMessageType, author: author, content: text, clock: time.Unix(clock, 0)}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 18:29:59 +00:00
|
|
|
// DefaultKeyMap returns a set of pager-like default keybindings.
|
|
|
|
func DefaultKeyMap() viewport.KeyMap {
|
|
|
|
return viewport.KeyMap{
|
|
|
|
PageDown: key.NewBinding(
|
|
|
|
key.WithKeys("pgdown"),
|
|
|
|
key.WithHelp("pgdn", "page down"),
|
|
|
|
),
|
|
|
|
PageUp: key.NewBinding(
|
|
|
|
key.WithKeys("pgup"),
|
|
|
|
key.WithHelp("pgup", "page up"),
|
|
|
|
),
|
|
|
|
HalfPageUp: key.NewBinding(
|
|
|
|
key.WithKeys("ctrl+u"),
|
|
|
|
key.WithHelp("ctrl+u", "½ page up"),
|
|
|
|
),
|
|
|
|
HalfPageDown: key.NewBinding(
|
|
|
|
key.WithKeys("ctrl+d"),
|
|
|
|
key.WithHelp("ctrl+d", "½ page down"),
|
|
|
|
),
|
|
|
|
Up: key.NewBinding(
|
|
|
|
key.WithKeys("up"),
|
|
|
|
key.WithHelp("↑", "up"),
|
|
|
|
),
|
|
|
|
Down: key.NewBinding(
|
|
|
|
key.WithKeys("down"),
|
|
|
|
key.WithHelp("↓", "down"),
|
|
|
|
),
|
|
|
|
}
|
2021-04-04 17:06:17 +00:00
|
|
|
}
|