6
1
mirror of https://git.mills.io/saltyim/saltyim.git synced 2024-06-25 00:08:26 +00:00
prologic-saltyim/internal/app/app.go
2023-02-28 08:37:50 +10:00

301 lines
6.8 KiB
Go

// Package app implements a terminal user interface (tui)
package app
import (
"bytes"
"context"
"fmt"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/encoding"
"github.com/posener/formatter"
"github.com/rivo/tview"
sync "github.com/sasha-s/go-deadlock"
log "github.com/sirupsen/logrus"
"go.yarn.social/lextwt"
"go.salty.im/saltyim"
)
const (
bufferStatus = "Status"
)
// App is a terminal user inerface (TUI) using tview/tcell to provide an interface
// for a saltyim.Client (cli) client and provides a multi-chat experience allowing
// the user to switch between active chats.
type App struct {
sync.RWMutex
*tview.Application
cli *saltyim.Client
user string
addr saltyim.Addr
layout *tview.Flex
views *tview.Pages
chats *tview.List
buffers *tview.Pages
bufferMap map[string]*tview.TextView
input *tview.InputField
// I/O channels
in chan string
out chan string
// Configurations.
palette map[string]string
}
// NewApp initializes a new tui chat app
func NewApp(cli *saltyim.Client) (*App, error) {
app := &App{
Application: tview.NewApplication(),
cli: cli,
bufferMap: make(map[string]*tview.TextView),
in: make(chan string),
out: make(chan string),
palette: map[string]string{
"background": "000000",
"border": "4C5C68",
"title": "46494C",
"date": "E76F51",
"text": "577399",
},
}
app.doLayout()
app.setKeyBindings()
return app, nil
}
func (app *App) doLayout() {
inputTitle := fmt.Sprintf("Connected to %s as %s", app.cli.Me().Endpoint(), app.cli.Me())
app.input = NewChatInput(app.palette, inputTitle, app.messageHandler())
app.chats = tview.NewList()
app.chats.SetBorder(true).SetTitle("Chats")
app.chats.ShowSecondaryText(false)
app.chats.SetSelectedFunc(func(idx int, mainText string, secondaryText string, shortcut rune) {
app.buffers.SwitchToPage(mainText)
app.SetFocus(app.input)
app.user = mainText
})
app.bufferMap[bufferStatus] = NewChatBox(app.palette, bufferStatus)
app.buffers = tview.NewPages().AddPage(bufferStatus, app.bufferMap[bufferStatus], true, true).SwitchToPage(bufferStatus)
app.chats.AddItem(bufferStatus, "", 0, nil)
// Layout the widgets in flex view.
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(app.buffers, 0, 1, false).
AddItem(app.chats, 40, 1, false),
0, 2, false).
AddItem(app.input, 3, 0, true)
app.layout = layout
app.views = tview.NewPages().AddPage("main", layout, true, true).SwitchToPage("main")
app.SetRoot(app.views, true)
app.SetFocus(app.input)
app.EnableMouse(true)
}
func (app *App) setKeyBindings() {
app.layout.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch key := event.Key(); key {
case tcell.KeyEsc:
app.Stop()
case tcell.KeyCtrlN:
form := NewChatForm(
app.palette,
"Start new chat with",
func(address string) {
app.views.RemovePage("newchat")
app.views.SwitchToPage("main")
if err := app.ChatWith(address); err != nil {
dialog := NewMessageDialog(ErrorDialog)
dialog.SetTitle("Error")
dialog.SetMessage(fmt.Sprintf("error occurred adding new chat %s: %s", address, err))
dialog.SetDoneFunc(func() {
app.views.RemovePage("error")
app.views.SwitchToPage("main")
})
app.views.AddAndSwitchToPage("error", dialog, true)
}
}, func() {
app.views.RemovePage("newchat")
app.views.SwitchToPage("main")
},
)
app.views.AddPage("newchat", floatingModal(form, 40, 7), true, true).SwitchToPage("newchat")
}
return event
})
}
func (app *App) messageHandler() InputHandler {
return func(message string) {
app.out <- message
}
}
func (app *App) inputLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case in := <-app.in:
s, err := lextwt.ParseSalty(in)
if err != nil {
continue
}
switch s := s.(type) {
case *lextwt.SaltyEvent:
// Ignored for now
case *lextwt.SaltyText:
buffer := app.getOrSetBuffer(s.User.String())
buf := &bytes.Buffer{}
_, _, width, _ := buffer.GetInnerRect()
f := formatter.Formatter{
Writer: buf,
Indent: []byte("> "),
Width: width - 1,
}
if _, err := f.Write([]byte(s.LiteralText())); err != nil {
log.WithError(err).Error("error formatting message")
continue
}
app.RLock()
app.QueueUpdateDraw(func() {
fmt.Fprintf(buffer,
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
app.palette["date"],
s.Timestamp.DateTime().Local().Format(saltyim.DateTimeFormat),
getUserColor(s.User.String()).Hex(),
s.User.String(),
app.palette["text"],
buf.String(),
)
})
app.RUnlock()
}
}
}
}
func (app *App) getOrSetBuffer(name string) *tview.TextView {
buffer, ok := app.bufferMap[name]
if !ok {
buffer = NewChatBox(app.palette, name)
app.bufferMap[name] = buffer
app.chats.AddItem(name, "", 0, nil)
app.chats.SetCurrentItem(app.chats.GetItemCount())
app.buffers.AddPage(name, buffer, true, true)
app.user = name
}
return buffer
}
func (app *App) outputLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case msg := <-app.out:
if strings.TrimSpace(msg) == "" {
continue
}
if err := app.cli.Send(app.user, msg); err != nil {
// TODO: Handle errors more gracefully
log.WithError(err).Fatal("error sending message")
}
buffer := app.getOrSetBuffer(app.user)
buf := &bytes.Buffer{}
_, _, width, _ := buffer.GetInnerRect()
f := formatter.Formatter{
Writer: buf,
Indent: []byte("> "),
Width: width - 1,
}
if _, err := f.Write([]byte(msg)); err != nil {
log.WithError(err).Error("error formatting message")
continue
}
app.RLock()
app.QueueUpdateDraw(func() {
fmt.Fprintf(buffer,
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
app.palette["date"],
time.Now().Format(saltyim.DateTimeFormat),
getUserColor(app.cli.Me().String()).Hex(),
app.cli.Me().String(),
app.palette["text"],
buf.String(),
)
})
app.RUnlock()
//app.in <- string(saltyim.PackMessage(app.cli.Me(), msg))
}
}
}
func (app *App) readLoop(ctx context.Context) {
ch := app.cli.Subscribe(ctx)
for {
select {
case <-ctx.Done():
return
case msg := <-ch:
app.in <- msg.Text
}
}
}
// ChatWith sets the current chat with a specified user
func (app *App) ChatWith(user string) error {
addr, err := saltyim.LookupAddr(user)
if err != nil {
return fmt.Errorf("error looking up addr %q: %w", user, err)
}
app.addr = addr
app.user = user
app.getOrSetBuffer(user)
return nil
}
// Run runs the main chat loop
func (app *App) Run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
encoding.Register()
go app.readLoop(ctx)
go app.inputLoop(ctx)
go app.outputLoop(ctx)
return app.Application.Run()
}