summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorcitrons <citrons@mondecitronne.com>2025-05-30 17:47:35 -0500
committercitrons <citrons@mondecitronne.com>2025-05-30 17:47:35 -0500
commitb3a01b28cbfe43ef22168a2f81803dccd4e56864 (patch)
tree042a79f70fe1b6f08bc3ba6b0ea2dde127b1a1be /client
parent194c63381a8b19f6a9198ff30a307b65acc2af76 (diff)
simple client
Diffstat (limited to 'client')
-rw-r--r--client/application.go127
-rw-r--r--client/buffer/buffer.go94
-rw-r--r--client/client/client.go135
-rw-r--r--client/cmd_buffer.go77
-rw-r--r--client/main.go31
-rw-r--r--client/object/object.go95
6 files changed, 559 insertions, 0 deletions
diff --git a/client/application.go b/client/application.go
new file mode 100644
index 0000000..fdb0216
--- /dev/null
+++ b/client/application.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "citrons.xyz/talk/client/client"
+ "citrons.xyz/talk/client/object"
+ "citrons.xyz/talk/client/buffer"
+ "citrons.xyz/talk/proto"
+ "citrons.xyz/talk/tui"
+ "zgo.at/termfo/keys"
+)
+
+type application struct {
+ client.Client
+ reconnecting bool
+ authenticated bool
+ uid string
+ cache object.ObjCache
+ currentBuffer *buffer.Buffer
+ cmdBuffer cmdBuffer
+}
+
+func newApplication(serverAddress string) *application {
+ var app application
+ app.Client = client.New(serverAddress)
+ app.cache = object.NewCache(&app)
+ app.cmdBuffer.Buffer = buffer.New("buffer")
+ app.currentBuffer = &app.cmdBuffer.Buffer
+
+ app.cmdBuffer.info("connecting to %s", app.Client.Address)
+ return &app
+}
+
+func (a *application) OnConnect() {
+ a.reconnecting = false
+ a.cmdBuffer.info("connected to %s", a.Client.Address)
+
+ a.auth("test user")
+}
+
+func (a *application) OnDisconnect(err error) {
+ a.authenticated = false
+ a.uid = ""
+ if !a.reconnecting {
+ a.cmdBuffer.err(
+ "disconnected from %s: %s\nreconnecting...", a.Client.Address, err,
+ )
+ a.reconnecting = true
+ }
+}
+
+func (a *application) OnEvent(cmd proto.Command) {
+ switch cmd.Kind {
+ case "update":
+ if len(cmd.Args) > 0 {
+ a.cache.Update(cmd.Target, cmd.Args[0])
+ }
+ case "delete":
+ a.cache.Gone(cmd.Target)
+ }
+}
+
+func (a *application) OnResponse(requestId string, cmd proto.Command) {
+ switch cmd.Kind {
+ case "you-are":
+ if len(cmd.Args) > 0 {
+ a.cmdBuffer.info("your name is: %s", cmd.Args[0].Fields[""])
+ }
+ }
+}
+
+func (a *application) Sub(id string) {
+ a.Request(proto.NewCmd("s", id), func(cmd proto.Command) {
+ if cmd.Kind == "i" && len(cmd.Args) > 0 {
+ a.cache.Update(id, cmd.Args[0])
+ }
+ })
+}
+
+func (a *application) Unsub(id string) {
+ a.Request(proto.NewCmd("u", id), nil)
+}
+
+func (a *application) GetInfo(id string, callback func(proto.Command)) {
+ a.Request(proto.NewCmd("i", id), callback)
+}
+
+func (a *application) auth(name string) {
+ callback := func(response proto.Command) {
+ switch response.Kind {
+ case "you-are":
+ if len(response.Args) == 0 {
+ break
+ }
+ me := response.Args[0]
+ a.authenticated = true
+ a.uid = me.Id
+ a.cache.Watch(a.uid)
+ case "fail":
+ // todo
+ }
+ }
+ a.Request(proto.NewCmd("auth", "", proto.Object {
+ "anonymous", "", map[string]string {"": name},
+ }), callback)
+}
+
+func (a *application) onInput(ev tui.Event) {
+ a.currentBuffer.Scroll(-ev.Mouse.Scroll * 2)
+ switch ev.Key {
+ case keys.Up:
+ a.currentBuffer.Scroll(1)
+ case keys.Down:
+ a.currentBuffer.Scroll(-1)
+ }
+}
+
+func (a *application) show() {
+ tui.Clear()
+ s := tui.Size()
+ tui.Push("", tui.Box {
+ Width: tui.BoxSize(s.Width), Height: tui.BoxSize(s.Height),
+ })
+ a.currentBuffer.Show()
+ tui.Pop()
+ tui.DrawLayout()
+ tui.Present()
+}
diff --git a/client/buffer/buffer.go b/client/buffer/buffer.go
new file mode 100644
index 0000000..bb782fb
--- /dev/null
+++ b/client/buffer/buffer.go
@@ -0,0 +1,94 @@
+package buffer
+
+import (
+ "citrons.xyz/talk/tui"
+)
+
+type Buffer struct {
+ id string
+ top *bufList
+ bottom *bufList
+ scroll tui.ScrollState
+ Closed bool
+}
+
+type Message interface {
+ Id() string
+ Show(odd bool)
+}
+
+type bufList struct {
+ msg Message
+ odd bool
+ prev *bufList
+ next *bufList
+}
+
+func New(id string) Buffer {
+ return Buffer {id: id}
+}
+
+func (b *Buffer) Add(msg Message) {
+ l := &bufList {msg: msg}
+ if b.bottom != nil {
+ b.bottom.next = l
+ l.prev = b.bottom
+ l.odd = !b.bottom.odd
+ }
+ b.bottom = l
+ if b.top == nil {
+ b.top = b.bottom
+ }
+}
+
+func (b *Buffer) AddTop(msg Message) {
+ l := bufList {msg: msg}
+ if b.top != nil {
+ b.top.prev = &l
+ l.next = b.top
+ l.odd = !b.bottom.odd
+ }
+ b.top = &l
+ if b.bottom == nil {
+ b.bottom = b.top
+ }
+}
+
+func (b *Buffer) Scroll(amnt int) {
+ b.scroll.Scroll(amnt)
+}
+
+func (b *Buffer) ScrollBottom() {
+ b.scroll.ToStart()
+}
+
+func (b *Buffer) AtBottom() bool {
+ return b.scroll.AtFirst()
+}
+
+func (b *Buffer) AtTop() bool {
+ return b.scroll.AtLast()
+}
+
+func (b *Buffer) Show() (atTop bool) {
+ tui.Push(b.id, tui.Box {
+ Width: tui.Fill, Height: tui.Fill, Dir: tui.Up, Overflow: true,
+ Scroll: &b.scroll,
+ })
+ defer tui.Pop()
+
+ for m := b.bottom; m != nil; m = m.prev {
+ var bg int32 = tui.Black
+ if m.odd {
+ bg = 236
+ }
+ tui.Push(m.msg.Id(), tui.Box {
+ Width: tui.Fill, Height: tui.Children,
+ Style: &tui.Style {Fg: tui.White, Bg: bg},
+ })
+ m.msg.Show(m.odd)
+ tui.Pop()
+ }
+
+ return atTop
+}
diff --git a/client/client/client.go b/client/client/client.go
new file mode 100644
index 0000000..afbafbe
--- /dev/null
+++ b/client/client/client.go
@@ -0,0 +1,135 @@
+package client
+
+import (
+ "citrons.xyz/talk/proto"
+ "net"
+ "time"
+ "bufio"
+)
+
+type MessageHandler interface {
+ OnConnect()
+ OnDisconnect(err error)
+ OnEvent(cmd proto.Command)
+ OnResponse(requestId string, cmd proto.Command)
+}
+
+type Message struct {
+ action func(MessageHandler)
+}
+
+type Client struct {
+ Address string
+ stop chan struct{}
+ message chan Message
+ send chan proto.Line
+ activeRequests map[string]func(proto.Command)
+}
+
+func New(address string) Client {
+ return Client {
+ Address: address,
+ stop: make(chan struct{}),
+ message: make(chan Message),
+ activeRequests: make(map[string]func(proto.Command)),
+ }
+}
+
+func (c *Client) RunClient() {
+ sleep := time.Second / 4
+ for {
+ conn, err := net.Dial("tcp", c.Address)
+ if err != nil {
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnDisconnect(err)
+ }}
+ time.Sleep(sleep)
+ sleep = min(sleep * 2, 30 * time.Second)
+ continue
+ }
+ defer conn.Close()
+ sleep = time.Second
+
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnConnect()
+ }}
+
+ recv, recvErr := proto.ReadLines(bufio.NewReader(conn))
+ send := make(chan proto.Line, 1)
+ defer close(send)
+ sendErr := proto.WriteLines(bufio.NewWriter(conn), send)
+
+ c.send = make(chan proto.Line)
+ go func() {
+ buf := make([]proto.Line, 0, 8)
+ for {
+ line, ok := <-c.send
+ if !ok {
+ return
+ }
+ buf = append(buf, line)
+ for len(buf) > 0 {
+ select {
+ case line, ok := <-c.send:
+ if !ok {
+ return
+ }
+ buf = append(buf, line)
+ case send <- buf[0]:
+ buf = buf[1:]
+ }
+ }
+ }
+ }()
+ defer close(c.send)
+
+ run: for {
+ select {
+ case line := <-recv:
+ switch line.Kind {
+ case '*':
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnEvent(line.Cmd)
+ }}
+ case '!':
+ cb := c.activeRequests[line.RequestId]
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnResponse(line.RequestId, line.Cmd)
+ if cb != nil {
+ cb(line.Cmd)
+ }
+ }}
+ }
+ case err = <-recvErr:
+ break run
+ case err = <-sendErr:
+ break run
+ case <-c.stop:
+ return
+ }
+ }
+ c.message <- Message {func(mh MessageHandler) {
+ mh.OnDisconnect(err)
+ }}
+ time.Sleep(sleep)
+ sleep = min(sleep * 2, 30 * time.Second)
+ }
+}
+
+func (c *Client) Stop() {
+ close(c.stop)
+}
+
+func (c *Client) Messages() <-chan Message {
+ return c.message
+}
+
+func (c *Client) Request(cmd proto.Command, callback func(proto.Command)) {
+ id := proto.GenId()
+ c.send <- proto.Line {'?', id, cmd}
+ c.activeRequests[id] = callback
+}
+
+func (m Message) Handle(mh MessageHandler) {
+ m.action(mh)
+}
diff --git a/client/cmd_buffer.go b/client/cmd_buffer.go
new file mode 100644
index 0000000..ae96a0f
--- /dev/null
+++ b/client/cmd_buffer.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "citrons.xyz/talk/client/buffer"
+ "citrons.xyz/talk/tui"
+ "fmt"
+)
+
+type cmdBuffer struct {
+ buffer.Buffer
+}
+
+type logMsg struct {
+ index int
+ text string
+ logType logType
+}
+var lastIndex = 0
+
+type logType int
+const (
+ logInfo = iota
+ logErr
+ logCmd
+)
+
+func (m logMsg) Id() string {
+ return fmt.Sprintf("log.%d", m.index)
+}
+
+func (m logMsg) Show(odd bool) {
+ var style *tui.Style
+ switch m.logType {
+ case logErr:
+ var bg int32 = tui.Red
+ if odd {
+ bg = tui.BrightRed
+ }
+ style = &tui.Style {Bg: bg, Fg: tui.Black}
+ case logCmd:
+ var bg int32 = tui.Blue
+ if odd {
+ bg = tui.BrightBlue
+ }
+ style = &tui.Style {Bg: bg, Fg: tui.Black}
+ default:
+ }
+
+ tui.Push("", tui.Box {
+ Width: tui.Fill, Height: tui.Children, Style: style, Dir: tui.Right,
+ })
+
+ tui.Push("", tui.Box {Width: tui.TextSize, Height: tui.TextSize})
+ tui.Text("* ", nil)
+ tui.Pop()
+
+ tui.Push("", tui.Box {Width: tui.Fill, Height: tui.TextSize})
+ tui.Text(m.text, nil)
+ tui.Pop()
+
+ tui.Pop()
+}
+
+func (b *cmdBuffer) info(f string, a ...any) {
+ lastIndex++
+ b.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logInfo})
+}
+
+func (b *cmdBuffer) err(f string, a ...any) {
+ lastIndex++
+ b.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logErr})
+}
+
+func (b *cmdBuffer) cmd(f string, a ...any) {
+ lastIndex++
+ b.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logCmd})
+}
diff --git a/client/main.go b/client/main.go
new file mode 100644
index 0000000..1f46a00
--- /dev/null
+++ b/client/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "citrons.xyz/talk/tui"
+ "time"
+)
+
+func main() {
+ tui.Start()
+
+ app := newApplication("localhost:27508")
+ go app.RunClient()
+
+ drawTick := time.Tick(time.Second / 60)
+ redraw := true
+ for {
+ select {
+ case m := <-app.Messages():
+ m.Handle(app)
+ redraw = true
+ case e := <-tui.Events():
+ app.onInput(e)
+ redraw = true
+ case <-drawTick:
+ if redraw {
+ app.show()
+ redraw = false
+ }
+ }
+ }
+}
diff --git a/client/object/object.go b/client/object/object.go
new file mode 100644
index 0000000..f42eaf8
--- /dev/null
+++ b/client/object/object.go
@@ -0,0 +1,95 @@
+package object
+
+import (
+ "citrons.xyz/talk/proto"
+)
+
+type ObjCache struct {
+ stream ObjStream
+ objects map[string]cacheEntry
+}
+
+type cacheEntry struct {
+ o proto.Object
+ refCount int
+ cached bool
+}
+
+type ObjStream interface {
+ Sub(id string)
+ GetInfo(id string, callback func(proto.Command))
+ Unsub(id string)
+}
+
+func NewCache(stream ObjStream) ObjCache {
+ return ObjCache {stream, make(map[string]cacheEntry)}
+}
+
+func (oc *ObjCache) Get(id string) *proto.Object {
+ entry := oc.objects[id]
+ if !entry.cached {
+ return nil
+ }
+ return &entry.o
+}
+
+func (oc *ObjCache) Fetch(id string, callback func(*proto.Object)) {
+ entry := oc.objects[id]
+ if !entry.cached {
+ oc.stream.GetInfo(id, func(cmd proto.Command) {
+ if cmd.Kind == "i" && len(cmd.Args) > 0 {
+ callback(&cmd.Args[0])
+ } else {
+ callback(nil)
+ }
+ })
+ }
+ callback(&entry.o)
+}
+
+func (oc *ObjCache) Watch(id string) {
+ entry, ok := oc.objects[id]
+ if !ok {
+ oc.stream.Sub(id)
+ }
+ oc.objects[id] = entry
+}
+
+func (oc *ObjCache) Unwatch(id string) {
+ entry, ok := oc.objects[id]
+ if ok {
+ entry.refCount--
+ if entry.refCount <= 0 {
+ oc.stream.Unsub(id)
+ delete(oc.objects, id)
+ } else {
+ oc.objects[id] = entry
+ }
+ }
+}
+
+func (oc *ObjCache) Update(id string, new proto.Object) {
+ entry := oc.objects[id]
+ if entry.cached && new.Kind == entry.o.Kind {
+ for k, v := range new.Fields {
+ entry.o.Fields[k] = v
+ }
+ } else {
+ entry.o = new
+ }
+ entry.cached = true
+ oc.objects[id] = entry
+}
+
+func (oc *ObjCache) Gone(id string) {
+ entry := oc.objects[id]
+ if entry.cached {
+ obj := entry.o
+ obj.Fields["kind"] = obj.Kind
+ obj.Kind = "gone"
+ entry.o = obj
+ oc.objects[id] = entry
+ }
+}
+
+