diff --git a/README.md b/README.md index 5b893e1..9b4cb3c 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,30 @@ # nanite -`nanite` is a terminal [Nanochat] client. +`nanite` is a terminal client for the [Nanochat] protocol. -![](./assets/screenshot.png) +Requires Go 1.24.4 or higher. Build using `make build`. -## build +> The upstream URL for this repository is . +> The repositories hosted on GitHub and Codeberg are mirrors. -``` -$ go build . -``` - -## usage - -``` -$ ./nanite -usage: ./nanite host [port] -$ ./nanite very.real-server.com -``` - -keybindings: +# keybindings - `Ctrl+C`: quit - `Ctrl+L`: refresh screen - `Ctrl+P`: poll +- Emacs-like bindings for text editing -commands: +# commands -- `/dial hostname`: connect to server -- `/hangup`: disconnect -- `/q`, `/quit`: quit -- `/nick [nickname]`: change nick, if no arguments, show current nick -- `/me`: IRC `/me` alike -- `/poll [seconds]`: change polling interval, if no arguments, poll manually - -## won't support (yet) - -- sixel (tried, it seems to be complicated to get it to work with Vaxis' pager - widget) +- `/clear`: clear message log +- `/dial host [port]`: connect to server +- `/hangup`: disconnect from server +- `/help`: see command list +- `/me ...`: send IRC-style `/me` message +- `/nick [nickname]`: change nickname or see current nickname if no arguments given +- `/poll [time]`: set polling rate to time or poll manually if no arguments given +- `/quit`: self-explanatory (aliased to `/q`) +- `/script [name]`: run script or see script list if no arguments given (aliased to `/.`) +- `/send ...`: send raw message [Nanochat]: https://git.phial.org/d6/nanochat -[Vaxis]: https://git.sr.ht/~rockorager/vaxis diff --git a/assets/screenshot.png b/assets/screenshot.png deleted file mode 100644 index 04f587d..0000000 Binary files a/assets/screenshot.png and /dev/null differ diff --git a/command.go b/command.go index 82ceb3a..99f3996 100644 --- a/command.go +++ b/command.go @@ -2,129 +2,116 @@ package main import ( "fmt" + "maps" + "os" + "path" + "slices" "strconv" "strings" + "time" ) -func (app *App) Stat() (res string, err error) { - if _, err := app.conn.Write([]byte("STAT\n")); err != nil { - return "", err - } +type Command func(*App, string) - var str strings.Builder - for range 3 { - if !app.conn.Scanner.Scan() { - return "", app.conn.Scanner.Err() - } - str.Write(app.conn.Scanner.Bytes()) - str.WriteRune(' ') - } +var CommandMap map[string]Command +var AliasMap map[string]string - return str.String(), nil -} - -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.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() - } - numRaw := app.conn.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.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() - } - numRaw := app.conn.Scanner.Text() - num, err = strconv.Atoi(numRaw) - if err != nil { - return 0, err - } - return num, nil -} - -func (app *App) Skip(since int) (num int, err error) { - if _, err := fmt.Fprintf(app.conn, "SKIP %d\n", since); err != nil { - return 0, err - } - - if !app.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() - } - num, err = strconv.Atoi(app.conn.Scanner.Text()) - if err != nil { - return 0, err - } - - for range num { - if !app.conn.Scanner.Scan() { - return 0, err - } - app.incoming <- MessageEvent(app.conn.Scanner.Text()) - } - - if !app.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() - } - last, err := strconv.Atoi(app.conn.Scanner.Text()) - if err != nil { - return 0, err - } - app.incoming <- SetLastEvent(last) - - return num, nil -} - -func (app *App) Last(n int) (num int, err error) { - if n == 0 { - return 0, nil - } - - if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil { - return 0, err - } - - var nsrv int - if !app.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() - } - nsrvRaw := app.conn.Scanner.Text() - nsrv, err = strconv.Atoi(nsrvRaw) - if err != nil { - return 0, err - } - - if nsrv != 0 { - for range nsrv { - if !app.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() +func initCommandMap() { + CommandMap = map[string]Command{ + "help": func(app *App, rest string) { + var s strings.Builder + for _, name := range slices.Sorted(maps.Keys(CommandMap)) { + s.WriteString(name) + s.WriteRune(' ') } - app.incoming <- MessageEvent(app.conn.Scanner.Text()) - } + app.AppendSystemMessage("commands: %s", s.String()) + }, + "script": func(app *App, rest string) { + if rest == "" { + if err := app.RefreshScripts(); err != nil { + app.AppendSystemMessage("failed to refresh scripts") + } + var s strings.Builder + for _, script := range slices.Sorted(maps.Keys(app.scripts)) { + s.WriteString(script) + s.WriteRune(' ') + } + app.AppendSystemMessage("scripts: %s", s.String()) + } else { + if err := app.LoadScript(rest); err != nil { + app.AppendSystemMessage("error loading script \"%s\": %s", rest, err) + } + } + }, + "send": func(app *App, rest string) { + app.outgoing <- MessageEvent(rest) + }, + "dial": func(app *App, rest string) { + args := strings.Fields(rest) + if len(args) < 1 || len(args) > 2 { + app.AppendSystemMessage("usage: /dial host [port]") + } + host := args[0] + port := "44322" + if len(args) == 2 { + port = args[1] + } + app.outgoing <- DialEvent{host, port} + }, + "hangup": func(app *App, rest string) { + app.outgoing <- HangupEvent{} + }, + "nick": func(app *App, rest string) { + if rest == "" { + app.AppendSystemMessage("your nickname is %s", app.nick) + } else { + app.SetNick(rest) + app.AppendSystemMessage("your nickname is now %s", app.nick) + if err := os.WriteFile(path.Join(app.cfgHome, "nick"), []byte(rest), 0o600); err != nil { + app.AppendSystemMessage("failed to persist nickname: %s", err) + } + } + }, + "poll": func(app *App, rest string) { + if rest == "" { + app.outgoing <- ManualPollEvent(app.last) + } else { + if app.conn == nil { + app.AppendSystemMessage("not connected to any server") + return + } + num, err := strconv.Atoi(rest) + if err != nil { + app.AppendSystemMessage("invalid number \"%s\"", rest) + } else { + if num == 0 { + app.conn.ticker.Stop() + app.conn.rate = 0 + app.AppendSystemMessage("disabled automatic polling") + } else { + app.conn.rate = time.Second * time.Duration(num) + app.conn.ticker.Stop() + app.conn.ticker = time.NewTicker(app.conn.rate) + app.AppendSystemMessage("polling rate set to %s", app.conn.rate.String()) + } + } + } + }, + "me": func(app *App, rest string) { + app.outgoing <- MessageEvent(fmt.Sprintf("%s %s", app.nick, rest)) + }, + "clear": func(app *App, rest string) { + clear(app.pager.Segments) + app.pager.Layout() + app.AppendSystemMessage("cleared message history") + }, + "quit": func(app *App, rest string) { + app.stop() + }, } - var last int - if !app.conn.Scanner.Scan() { - return 0, app.conn.Scanner.Err() + AliasMap = map[string]string { + "q": "quit", + ".": "script", } - lastRaw := app.conn.Scanner.Text() - last, err = strconv.Atoi(lastRaw) - if err != nil { - return 0, err - } - app.incoming <- SetLastEvent(last) - - return nsrv, nil } diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..f8c443e --- /dev/null +++ b/default.nix @@ -0,0 +1,7 @@ +{ pkgs ? import {} }: +pkgs.buildGoModule { + pname = "nanite"; + version = "0.1.0+5701062c7f"; + src = ./.; + vendorHash = "sha256-10zAs/SEJp3ImoaJ3/uJ23jjsnzOceLvizrvrWYI97k="; +} diff --git a/display.go b/display.go index cf1b2a8..92d0936 100644 --- a/display.go +++ b/display.go @@ -5,7 +5,7 @@ import ( "math" "strings" - "codeberg.org/lobo/nanite/widgets/pager" + "git.rhzm.org/lobo/nanite/widgets/pager" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/textinput" ) @@ -87,14 +87,14 @@ func (app *App) submitTextInput() { name, rest, _ := strings.Cut(app.input.String()[1:], " ") if cmd, ok := CommandMap[name]; ok { cmd(app, rest) + } else if alias, ok := AliasMap[name]; ok { + cmd := CommandMap[alias] + cmd(app, rest) } else { app.AppendSystemMessage("unknown command \"%s\"", name) } } else { - message := fmt.Sprintf("%s: %s", app.nick, app.input.String()) - app.AppendMessage(message) - app.outgoing <- MessageEvent(message) - app.outgoing <- StatEvent("") + app.outgoing <- MessageEvent(fmt.Sprintf("%s: %s", app.nick, app.input.String())) } app.input.SetContent("") @@ -108,6 +108,10 @@ func (app *App) HandleTerminalEvent(ev vaxis.Event) { app.pager.ScrollUp() case "Down": app.pager.ScrollDown() + case "Page_Up": + app.pager.ScrollUpN(app.w.log.Height * 2 / 3) + case "Page_Down": + app.pager.ScrollDownN(app.w.log.Height * 2 / 3) case "Enter": app.submitTextInput() case "Ctrl+p": diff --git a/event.go b/event.go index 6b9e1aa..7a5be88 100644 --- a/event.go +++ b/event.go @@ -11,6 +11,7 @@ import ( type IncomingEvent interface { HandleIncoming(*App) } + type OutgoingEvent interface { HandleOutgoing(*App) error } @@ -27,7 +28,9 @@ func (ev DialEvent) HandleOutgoing(app *App) error { var err error if app.conn != nil { - app.incoming <- SystemMessageEvent(fmt.Sprintf("already connected to %s:%s", app.conn.host, app.conn.port)) + app.incoming <- SystemMessageEvent( + fmt.Sprintf("already connected to %s:%s", app.conn.host, app.conn.port), + ) return nil } conn, err := net.Dial("tcp", net.JoinHostPort(ev.Host, ev.Port)) @@ -51,8 +54,8 @@ func (ev DialEvent) HandleOutgoing(app *App) error { if err != nil { return err } - delta := time.Since(latStart).Round(time.Second) - delta = min(max(time.Second, delta*3/2), 5*time.Second) + delta := time.Since(latStart) + delta = min(max(time.Second, delta*3/2), 5*time.Second).Round(time.Second) app.conn.rate = delta app.conn.ticker = time.NewTicker(delta) @@ -75,9 +78,13 @@ func (ev DialEvent) HandleOutgoing(app *App) error { return nil } -type HangupEvent struct{} +type HangupEvent struct{ host, port string } -func (ev HangupEvent) HandleOutgoing(app *App) error { +func (ev HangupEvent) HandleIncoming(app *App) { + app.AppendSystemMessage("disconnected from %s:%s", ev.host, ev.port) +} + +func (_ HangupEvent) HandleOutgoing(app *App) error { if app.conn == nil { app.incoming <- SystemMessageEvent("not connected to any server") return nil @@ -92,7 +99,7 @@ func (ev HangupEvent) HandleOutgoing(app *App) error { app.conn.ticker.Stop() app.conn.ticker = nil app.conn = nil - app.incoming <- SystemMessageEvent(fmt.Sprintf("disconnected from %s:%s", host, port)) + app.incoming <- HangupEvent{host, port} return nil } @@ -111,11 +118,13 @@ func (ev MessageEvent) HandleIncoming(app *App) { } func (ev MessageEvent) HandleOutgoing(app *App) error { + app.incoming <- ev num, err := app.Send(string(ev)) if err != nil { return err } app.incoming <- SetLastEvent(num) + app.outgoing <- StatEvent("") return nil } @@ -171,9 +180,9 @@ func (ev ManualPollEvent) HandleOutgoing(app *App) error { func (ev ManualPollEvent) HandleIncoming(app *App) { if int(ev) == 0 { - app.AppendSystemMessage("poll: no new messages") + app.AppendSystemMessage("no new messages") } else { - app.AppendSystemMessage("poll: retrieving %d messages", ev) + app.AppendSystemMessage("retrieving %d messages", ev) } } diff --git a/go.mod b/go.mod index 884d00d..d25d0d6 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module codeberg.org/lobo/nanite +module git.rhzm.org/lobo/nanite go 1.24.4 diff --git a/main.go b/main.go index 6e2fb57..94a51ff 100644 --- a/main.go +++ b/main.go @@ -8,97 +8,15 @@ import ( "net" "os" "path" - "strconv" "strings" "time" - "codeberg.org/lobo/nanite/widgets/pager" + "git.rhzm.org/lobo/nanite/widgets/pager" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/textinput" ) -var CommandMap map[string]func(*App, string) - -func init() { - CommandMap = map[string]func(*App, string){ - "help": func(app *App, rest string) { - var s strings.Builder - for name, _ := range CommandMap { - if name == "q" { - continue - } - s.WriteString(name) - s.WriteRune(' ') - } - app.AppendSystemMessage("commands: %s", s.String()) - }, - "dial": func(app *App, rest string) { - args := strings.Fields(rest) - if len(args) < 1 || len(args) > 2 { - app.AppendSystemMessage("usage: /connect host [port]") - } - host := args[0] - port := "44322" - if len(args) == 2 { - port = args[1] - } - app.outgoing <- DialEvent{host, port} - }, - "hangup": func(app *App, rest string) { - app.outgoing <- HangupEvent{} - }, - "nick": func(app *App, rest string) { - if rest == "" { - app.AppendSystemMessage("nick: your nickname is %s", app.nick) - } else { - app.SetNick(rest) - app.AppendSystemMessage("nick: your nickname is now %s", app.nick) - if err := os.WriteFile(path.Join(app.cfgHome, "nick"), []byte(rest), 0o700); err != nil { - app.AppendSystemMessage("nick: failed to persist nickname: %s", err) - } - } - }, - "poll": func(app *App, rest string) { - if rest == "" { - app.outgoing <- ManualPollEvent(app.last) - } else { - num, err := strconv.Atoi(rest) - if err != nil { - app.AppendSystemMessage("poll: invalid number %s", rest) - } else { - if num == 0 { - app.conn.ticker.Stop() - app.conn.rate = 0 - app.AppendSystemMessage("poll: disabled automatic polling") - } else { - app.conn.rate = time.Second * time.Duration(num) - app.conn.ticker.Stop() - app.conn.ticker = time.NewTicker(app.conn.rate) - app.AppendSystemMessage("poll: polling every %s", app.conn.rate.String()) - } - } - } - }, - "me": func(app *App, rest string) { - msg := fmt.Sprintf("%s %s", app.nick, rest) - app.AppendMessage(msg) - app.outgoing <- MessageEvent(msg) - }, - "clear": func(app *App, rest string) { - app.pager.Segments = []vaxis.Segment{} - app.pager.Layout() - app.AppendSystemMessage("cleared message history") - }, - "quit": func(app *App, rest string) { - app.stop() - }, - "q": func(app *App, rest string) { - app.stop() - }, - } -} - type App struct { ctx context.Context stop context.CancelFunc @@ -113,12 +31,15 @@ type App struct { outgoing chan OutgoingEvent error chan error - vx *vaxis.Vaxis - w struct { + scripts map[string]string + + vx *vaxis.Vaxis + pager *pager.Model + input *textinput.Model + w struct { log, title, input vaxis.Window } - pager *pager.Model - input *textinput.Model + cfgHome string } @@ -133,12 +54,15 @@ type Conn struct { } func (app *App) AppendMessage(data string) { - // TODO: make messages without a nick italic + nick, _, found := strings.Cut(data, ": ") + style := vaxis.Style{} + if !found || strings.TrimSpace(nick) != nick { + style.Attribute = vaxis.AttrItalic + } app.pager.Segments = append(app.pager.Segments, - vaxis.Segment{Text: data}, + vaxis.Segment{Text: data, Style: style}, vaxis.Segment{Text: "\n"}, ) - app.last += 1 app.pager.Offset = math.MaxInt } @@ -154,7 +78,7 @@ func (app *App) AppendSystemMessage(format string, args ...any) { func (app *App) SetNick(nick string) { nick = strings.TrimSpace(nick) - app.input.SetPrompt(fmt.Sprintf("%s: ", nick)) + app.input.SetPrompt(fmt.Sprintf("[%s] ", nick)) app.input.Prompt.Attribute = vaxis.AttrBold app.nick = nick } @@ -167,7 +91,7 @@ func (app *App) EnsureConfigDir() error { app.cfgHome = path.Join(userCfg, "nanite") stat, err := os.Stat(app.cfgHome) if err != nil { - if err := os.Mkdir(app.cfgHome, 0o700); err != nil { + if err := os.MkdirAll(app.cfgHome, 0o700); err != nil { return err } } else { @@ -186,16 +110,25 @@ func NewApp() *App { app.error = make(chan error) if err := app.EnsureConfigDir(); err != nil { - app.error <- err + panic(err) + } + + if err := app.RefreshScripts(); err != nil { + panic(err) } go func() { for { select { case ev := <-app.outgoing: - if err := ev.HandleOutgoing(app); err != nil { - app.error <- err - return + _, dial := ev.(DialEvent) + if dial || app.conn != nil { + if err := ev.HandleOutgoing(app); err != nil { + app.error <- err + return + } + } else { + app.incoming <- SystemMessageEvent("not connected to any server") } case <-app.ctx.Done(): return @@ -227,7 +160,13 @@ func (app *App) Loop() error { func (app *App) Finish() { app.stop() app.FinishUI() - HangupEvent{}.HandleOutgoing(app) + if app.conn != nil { + HangupEvent{}.HandleOutgoing(app) + } +} + +func init() { + initCommandMap() } func main() { @@ -253,6 +192,7 @@ func main() { app.AppendSystemMessage("welcome to nanite! :3") if err := app.Loop(); err != nil { + app.FinishUI() panic(err) } } diff --git a/makefile b/makefile new file mode 100644 index 0000000..c2c6fd9 --- /dev/null +++ b/makefile @@ -0,0 +1,9 @@ +.PHONY: build install +PREFIX=$(HOME)/.local + +build: + go build . + +install: build + install -Dd $(PREFIX)/bin + install nanite $(PREFIX)/bin/nanite diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..1b4c00f --- /dev/null +++ b/protocol.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "strconv" + "strings" +) + +func (app *App) Stat() (res string, err error) { + if _, err := app.conn.Write([]byte("STAT\n")); err != nil { + return "", err + } + + var str strings.Builder + for range 3 { + if !app.conn.Scanner.Scan() { + return "", app.conn.Scanner.Err() + } + str.Write(app.conn.Scanner.Bytes()) + str.WriteRune(' ') + } + + return str.String(), nil +} + +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.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + numRaw := app.conn.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.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + numRaw := app.conn.Scanner.Text() + num, err = strconv.Atoi(numRaw) + if err != nil { + return 0, err + } + return num, nil +} + +func (app *App) Skip(since int) (num int, err error) { + if _, err := fmt.Fprintf(app.conn, "SKIP %d\n", since); err != nil { + return 0, err + } + + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + num, err = strconv.Atoi(app.conn.Scanner.Text()) + if err != nil { + return 0, err + } + + for range num { + if !app.conn.Scanner.Scan() { + return 0, err + } + app.incoming <- MessageEvent(app.conn.Scanner.Text()) + } + + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + last, err := strconv.Atoi(app.conn.Scanner.Text()) + if err != nil { + return 0, err + } + app.incoming <- SetLastEvent(last) + + return num, nil +} + +func (app *App) Last(n int) (num int, err error) { + if n == 0 { + return 0, nil + } + + if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil { + return 0, err + } + + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + numRaw := app.conn.Scanner.Text() + num, err = strconv.Atoi(numRaw) + if err != nil { + return 0, err + } + + for range num { + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + app.incoming <- MessageEvent(app.conn.Scanner.Text()) + } + + var last int + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() + } + lastRaw := app.conn.Scanner.Text() + last, err = strconv.Atoi(lastRaw) + if err != nil { + return 0, err + } + app.incoming <- SetLastEvent(last) + + return num, nil +} diff --git a/script.go b/script.go new file mode 100644 index 0000000..10c5d3b --- /dev/null +++ b/script.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +func (app *App) RefreshScripts() error { + app.scripts = make(map[string]string) + + scriptDir := path.Join(app.cfgHome, "scripts") + if _, err := os.Stat(scriptDir); errors.Is(err, fs.ErrNotExist) { + return nil + } + + return filepath.Walk(scriptDir, func(p string, i os.FileInfo, err error) error { + switch { + case err != nil: + return err + case i.IsDir(): + return nil + default: + if data, err := os.ReadFile(p); err != nil { + return err + } else { + app.scripts[path.Base(p)] = string(data) + return nil + } + } + }) +} + +func (app *App) LoadScript(name string) error { + if script, ok := app.scripts[name]; ok { + for line := range strings.Lines(script) { + cmdName, rest, _ := strings.Cut(strings.TrimSpace(line), " ") + if cmd, ok := CommandMap[cmdName]; ok { + cmd(app, rest) + } else { + return fmt.Errorf("unknown command \"%s\"", cmdName) + } + } + } else { + return fmt.Errorf("not found") + } + return nil +}