first working commit

This commit is contained in:
Lobo 2025-10-11 20:20:36 -03:00
commit 68525224d5
9 changed files with 633 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/chat.log
/nanite

14
README.md Normal file
View file

@ -0,0 +1,14 @@
# nanite
it ony a [nanochat] client!
wrote this while simultaneously learning how to use [vaxis] and it sure was an experience
## use it
```
$ go build .
$ ./nanite very.real-server.com
```
[nanochat]: https://git.phial.org/d6/nanochat
[vaxis]: https://git.sr.ht/~rockorager/vaxis

74
command.go Normal file
View file

@ -0,0 +1,74 @@
package main
import (
"fmt"
"strconv"
)
func (app *App) Send(data string) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "SEND %s\n", data); err != nil {
return 0, err
}
if !app.scanner.Scan() {
return 0, app.scanner.Err()
}
numRaw := app.scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
return num, nil
}
func (app *App) Poll(since int) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "POLL %d\n", since); err != nil {
return 0, err
}
if !app.scanner.Scan() {
return 0, app.scanner.Err()
}
numRaw := app.scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
return num, nil
}
func (app *App) Last(n int) (err error) {
if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil {
return err
}
var nsrv int
if !app.scanner.Scan() {
return app.scanner.Err()
}
nsrvRaw := app.scanner.Text()
nsrv, err = strconv.Atoi(nsrvRaw)
if err != nil {
return err
}
for range nsrv {
if !app.scanner.Scan() {
return app.scanner.Err()
}
app.incoming <- Message(app.scanner.Text())
}
var last int
if !app.scanner.Scan() {
return app.scanner.Err()
}
lastRaw := app.scanner.Text()
last, err = strconv.Atoi(lastRaw)
if err != nil {
return err
}
app.incoming <- SetLast(last)
return nil
}

96
display.go Normal file
View file

@ -0,0 +1,96 @@
package main
import (
"fmt"
"math"
"codeberg.org/lobo/nanite/widgets/pager"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/textinput"
)
func (app *App) InitUI() error {
var err error
app.vx, err = vaxis.New(vaxis.Options{})
if err != nil {
return err
}
app.input = textinput.New()
app.pager = &pager.Model{
WrapMode: pager.WrapWords,
}
app.resize()
return nil
}
func (app *App) FinishUI() {
app.vx.Close()
}
func (app *App) resize() {
app.dirty = true
win := app.vx.Window()
app.w.log = win.New(0, 1, win.Width, win.Height-3)
app.w.title = win.New(0, 0, win.Width, 1)
app.w.status = win.New(0, win.Height-2, win.Width, 1)
app.w.input = win.New(0, win.Height-1, win.Width, 1)
app.pager.Offset = math.MaxInt
}
func (app *App) Redraw() {
if !app.dirty {
return
}
app.dirty = false
// set window title and draw titlebar
app.w.title.Clear()
titlebarStyle := vaxis.Style{Attribute: vaxis.AttrBold}
if app.conn != nil {
app.vx.SetTitle(fmt.Sprintf("nanite (%s:%s)", app.host, app.port))
app.w.title.PrintTruncate(0,
vaxis.Segment{
Text: "• ",
Style: titlebarStyle,
},
vaxis.Segment{
Text: app.host,
Style: titlebarStyle,
},
vaxis.Segment{
Text: ":",
Style: titlebarStyle,
},
vaxis.Segment{
Text: app.port,
Style: titlebarStyle,
},
)
} else {
app.vx.SetTitle("nanite (disconnected)")
app.w.title.PrintTruncate(0,
vaxis.Segment{
Text: "✖ (disconnected)",
Style: titlebarStyle,
},
)
}
// draw statusbar
statusStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
app.w.status.Fill(vaxis.Cell{Style: statusStyle})
app.w.status.PrintTruncate(0,
vaxis.Segment{
Text: app.nick,
Style: statusStyle,
},
)
// let the widgets draw themselves
app.pager.Layout()
app.pager.Draw(app.w.log)
app.input.Draw(app.w.input)
app.vx.Render()
}

32
event.go Normal file
View file

@ -0,0 +1,32 @@
package main
type IncomingEvent interface {
HandleIncoming(*App)
}
type OutgoingEvent interface {
HandleOutgoing(*App)
}
type Message string
func (m Message) HandleIncoming(app *App) {
app.AppendMessage(string(m))
}
func (m Message) HandleOutgoing(app *App) {
num, _ := app.Send(string(m))
app.incoming <- SetLast(num)
}
type Poll int
func (p Poll) HandleOutgoing(app *App) {
num, _ := app.Poll(int(p))
go app.Last(num)
}
type SetLast int
func (m SetLast) HandleIncoming(app *App) {
app.last = int(m)
}

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module codeberg.org/lobo/nanite
go 1.24.4
require (
git.sr.ht/~rockorager/vaxis v0.15.0
github.com/rivo/uniseg v0.4.7
)
require (
github.com/containerd/console v1.0.3 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sixel v0.0.5 // indirect
github.com/soniakeys/quant v1.0.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/sys v0.35.0 // indirect
)

59
go.sum Normal file
View file

@ -0,0 +1,59 @@
git.sr.ht/~rockorager/vaxis v0.15.0 h1:r9VUcDfKRNe9Rp988konQ1Gy8vbtpQajGSYXNAYx8hI=
git.sr.ht/~rockorager/vaxis v0.15.0/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8=
github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

167
main.go Normal file
View file

@ -0,0 +1,167 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"math"
"net"
"os"
"sync"
"time"
"codeberg.org/lobo/nanite/widgets/pager"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/textinput"
)
var (
ErrAlreadyConnected = errors.New("already connected")
)
type App struct {
ctx context.Context
stop context.CancelFunc
mu sync.RWMutex
conn net.Conn
scanner *bufio.Scanner
host, port string
nick string
last int
ticker *time.Ticker
incoming chan IncomingEvent
outgoing chan OutgoingEvent
vx *vaxis.Vaxis
w struct {
log, title, status, input vaxis.Window
}
pager *pager.Model
input *textinput.Model
dirty bool
}
func (app *App) Connect(host, port string) (err error) {
if app.conn != nil {
return ErrAlreadyConnected
}
app.host = host
app.port = port
app.conn, err = net.Dial("tcp", net.JoinHostPort(host, port))
if err != nil {
return err
}
app.scanner = bufio.NewScanner(app.conn)
app.incoming = make(chan IncomingEvent)
app.outgoing = make(chan OutgoingEvent)
app.ticker = time.NewTicker(2 * time.Second)
go func() {
for {
select {
case ev := <-app.outgoing:
ev.HandleOutgoing(app)
case <-app.ctx.Done():
return
}
}
}()
return nil
}
func (app *App) Disconnect() {
if app.conn != nil {
app.conn.Write([]byte("QUIT\n"))
app.conn.Close()
app.conn = nil
}
}
func (app *App) AppendMessage(data string) {
app.pager.Segments = append(app.pager.Segments,
vaxis.Segment{Text: data},
vaxis.Segment{Text: "\n"},
)
app.last += 1
app.pager.Offset = math.MaxInt
app.dirty = true
}
func (app *App) HandleTerminalEvent(ev vaxis.Event) {
app.dirty = true
switch ev := ev.(type) {
case vaxis.Resize:
app.resize()
case vaxis.Key:
if ev.MatchString("ctrl+c") {
app.stop()
}
switch {
case ev.MatchString("up"):
app.pager.ScrollUp()
case ev.MatchString("down"):
app.pager.ScrollDown()
case ev.MatchString("ctrl+l"):
app.Redraw()
app.vx.Refresh()
app.dirty = false
case ev.MatchString("enter"):
if len(app.input.Characters()) != 0 {
message := fmt.Sprintf("%s: %s", app.nick, app.input.String())
app.AppendMessage(message)
app.outgoing <- Message(message)
app.input.SetContent("")
}
}
}
app.input.Update(ev)
}
func main() {
args := os.Args
if len(args) < 2 || len(args) > 3 {
fmt.Printf("usage: %s host [port]\n", args[0])
os.Exit(1)
}
port := "44322"
if len(args) == 3 {
port = args[2]
}
app := App{}
app.ctx, app.stop = context.WithCancel(context.Background())
defer app.stop()
if err := app.Connect(args[1], port); err != nil {
panic(err)
}
defer app.Disconnect()
app.InitUI()
defer app.FinishUI()
app.nick = "wolfdog"
go app.Last(20)
for {
select {
case ev := <-app.vx.Events():
app.HandleTerminalEvent(ev)
case ev := <-app.incoming:
ev.HandleIncoming(&app)
case <-app.ticker.C:
app.outgoing <- Poll(app.last)
case <-app.ctx.Done():
return
}
app.Redraw()
}
}

171
widgets/pager/pager.go Normal file
View file

@ -0,0 +1,171 @@
// This is a "fork" of Vaxis' pager widget that adds Unicode line breaking.
// See <https://git.sr.ht/~rockorager/vaxis/tree/v0.15.0/item/widgets/pager/pager.go>.
package pager
import (
"strings"
"git.sr.ht/~rockorager/vaxis"
"github.com/rivo/uniseg"
)
const (
WrapFast = iota
WrapWords
)
var defaultFill = vaxis.Character{Grapheme: " "}
type Model struct {
Segments []vaxis.Segment
lines []*line
Fill vaxis.Cell
Offset int
WrapMode int
width int
}
type line struct {
characters []vaxis.Cell
}
func (l *line) append(t vaxis.Cell) {
l.characters = append(l.characters, t)
}
func (m *Model) Draw(win vaxis.Window) {
w, h := win.Size()
if w != m.width {
m.width = w
m.Layout()
}
if len(m.lines)-m.Offset < h {
m.Offset = len(m.lines) - h
}
if m.Offset < 0 {
m.Offset = 0
}
if m.Fill.Grapheme == "" {
m.Fill.Character = defaultFill
}
win.Fill(m.Fill)
for row, l := range m.lines {
if row < m.Offset {
continue
}
if (row - m.Offset) >= h {
return
}
col := 0
for _, cell := range l.characters {
win.SetCell(col, row-m.Offset, cell)
col += cell.Width
}
}
}
func (m *Model) layoutFast() {
l := &line{}
col := 0
for _, seg := range m.Segments {
for _, char := range vaxis.Characters(seg.Text) {
if strings.ContainsRune(char.Grapheme, '\n') {
m.lines = append(m.lines, l)
l = &line{}
col = 0
continue
}
cell := vaxis.Cell{
Character: char,
Style: seg.Style,
}
l.append(cell)
col += char.Width
if col >= m.width {
m.lines = append(m.lines, l)
l = &line{}
col = 0
}
}
}
}
func (m *Model) layoutSlow() {
cols := m.width
col := 0
l := &line{}
var (
state = -1
segment string
)
for _, seg := range m.Segments {
rest := seg.Text
for len(rest) > 0 {
segment, rest, _, state = uniseg.FirstLineSegmentInString(rest, state)
chars := vaxis.Characters(segment)
total := 0
for _, char := range chars {
total += char.Width
}
switch {
case total > cols:
case total+col > cols:
m.lines = append(m.lines, l)
l = &line{}
col = 0
default:
}
for _, char := range chars {
if uniseg.HasTrailingLineBreakInString(char.Grapheme) {
m.lines = append(m.lines, l)
l = &line{}
col = 0
continue
}
cell := vaxis.Cell{
Character: char,
Style: seg.Style,
}
l.append(cell)
col += char.Width
if col >= cols {
m.lines = append(m.lines, l)
l = &line{}
col = 0
}
}
}
}
}
func (m *Model) Layout() {
m.lines = []*line{}
switch m.WrapMode {
case WrapFast:
m.layoutFast()
case WrapWords:
m.layoutSlow()
}
}
// Scrolls the pager down n lines, if it can
func (m *Model) ScrollDown() {
m.Offset += 1
}
func (m *Model) ScrollUp() {
m.Offset -= 1
}
func (m *Model) ScrollDownN(n int) {
m.Offset += n
}
func (m *Model) ScrollUpN(n int) {
m.Offset -= n
}