diff --git a/README.md b/README.md index 9b4cb3c..5b893e1 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,42 @@ # nanite -`nanite` is a terminal client for the [Nanochat] protocol. +`nanite` is a terminal [Nanochat] client. -Requires Go 1.24.4 or higher. Build using `make build`. +![](./assets/screenshot.png) -> The upstream URL for this repository is . -> The repositories hosted on GitHub and Codeberg are mirrors. +## build -# keybindings +``` +$ go build . +``` + +## usage + +``` +$ ./nanite +usage: ./nanite host [port] +$ ./nanite very.real-server.com +``` + +keybindings: - `Ctrl+C`: quit - `Ctrl+L`: refresh screen - `Ctrl+P`: poll -- Emacs-like bindings for text editing -# commands +commands: -- `/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 +- `/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) [Nanochat]: https://git.phial.org/d6/nanochat +[Vaxis]: https://git.sr.ht/~rockorager/vaxis diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000..04f587d Binary files /dev/null and b/assets/screenshot.png differ diff --git a/command.go b/command.go index 99f3996..82ceb3a 100644 --- a/command.go +++ b/command.go @@ -2,116 +2,129 @@ package main import ( "fmt" - "maps" - "os" - "path" - "slices" "strconv" "strings" - "time" ) -type Command func(*App, string) - -var CommandMap map[string]Command -var AliasMap map[string]string - -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.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() - }, +func (app *App) Stat() (res string, err error) { + if _, err := app.conn.Write([]byte("STAT\n")); err != nil { + return "", err } - AliasMap = map[string]string { - "q": "quit", - ".": "script", + 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 + } + + 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() + } + 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 nsrv, nil } diff --git a/default.nix b/default.nix deleted file mode 100644 index f8c443e..0000000 --- a/default.nix +++ /dev/null @@ -1,7 +0,0 @@ -{ 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 92d0936..cf1b2a8 100644 --- a/display.go +++ b/display.go @@ -5,7 +5,7 @@ import ( "math" "strings" - "git.rhzm.org/lobo/nanite/widgets/pager" + "codeberg.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 { - app.outgoing <- MessageEvent(fmt.Sprintf("%s: %s", app.nick, app.input.String())) + message := fmt.Sprintf("%s: %s", app.nick, app.input.String()) + app.AppendMessage(message) + app.outgoing <- MessageEvent(message) + app.outgoing <- StatEvent("") } app.input.SetContent("") @@ -108,10 +108,6 @@ 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 7a5be88..6b9e1aa 100644 --- a/event.go +++ b/event.go @@ -11,7 +11,6 @@ import ( type IncomingEvent interface { HandleIncoming(*App) } - type OutgoingEvent interface { HandleOutgoing(*App) error } @@ -28,9 +27,7 @@ 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)) @@ -54,8 +51,8 @@ func (ev DialEvent) HandleOutgoing(app *App) error { if err != nil { return err } - delta := time.Since(latStart) - delta = min(max(time.Second, delta*3/2), 5*time.Second).Round(time.Second) + delta := time.Since(latStart).Round(time.Second) + delta = min(max(time.Second, delta*3/2), 5*time.Second) app.conn.rate = delta app.conn.ticker = time.NewTicker(delta) @@ -78,13 +75,9 @@ func (ev DialEvent) HandleOutgoing(app *App) error { return nil } -type HangupEvent struct{ host, port string } +type HangupEvent struct{} -func (ev HangupEvent) HandleIncoming(app *App) { - app.AppendSystemMessage("disconnected from %s:%s", ev.host, ev.port) -} - -func (_ HangupEvent) HandleOutgoing(app *App) error { +func (ev HangupEvent) HandleOutgoing(app *App) error { if app.conn == nil { app.incoming <- SystemMessageEvent("not connected to any server") return nil @@ -99,7 +92,7 @@ func (_ HangupEvent) HandleOutgoing(app *App) error { app.conn.ticker.Stop() app.conn.ticker = nil app.conn = nil - app.incoming <- HangupEvent{host, port} + app.incoming <- SystemMessageEvent(fmt.Sprintf("disconnected from %s:%s", host, port)) return nil } @@ -118,13 +111,11 @@ 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 } @@ -180,9 +171,9 @@ func (ev ManualPollEvent) HandleOutgoing(app *App) error { func (ev ManualPollEvent) HandleIncoming(app *App) { if int(ev) == 0 { - app.AppendSystemMessage("no new messages") + app.AppendSystemMessage("poll: no new messages") } else { - app.AppendSystemMessage("retrieving %d messages", ev) + app.AppendSystemMessage("poll: retrieving %d messages", ev) } } diff --git a/go.mod b/go.mod index d25d0d6..884d00d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.rhzm.org/lobo/nanite +module codeberg.org/lobo/nanite go 1.24.4 diff --git a/main.go b/main.go index 94a51ff..6e2fb57 100644 --- a/main.go +++ b/main.go @@ -8,15 +8,97 @@ import ( "net" "os" "path" + "strconv" "strings" "time" - "git.rhzm.org/lobo/nanite/widgets/pager" + "codeberg.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 @@ -31,15 +113,12 @@ type App struct { outgoing chan OutgoingEvent error chan error - scripts map[string]string - - vx *vaxis.Vaxis - pager *pager.Model - input *textinput.Model - w struct { + vx *vaxis.Vaxis + w struct { log, title, input vaxis.Window } - + pager *pager.Model + input *textinput.Model cfgHome string } @@ -54,15 +133,12 @@ type Conn struct { } func (app *App) AppendMessage(data string) { - nick, _, found := strings.Cut(data, ": ") - style := vaxis.Style{} - if !found || strings.TrimSpace(nick) != nick { - style.Attribute = vaxis.AttrItalic - } + // TODO: make messages without a nick italic app.pager.Segments = append(app.pager.Segments, - vaxis.Segment{Text: data, Style: style}, + vaxis.Segment{Text: data}, vaxis.Segment{Text: "\n"}, ) + app.last += 1 app.pager.Offset = math.MaxInt } @@ -78,7 +154,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 } @@ -91,7 +167,7 @@ func (app *App) EnsureConfigDir() error { app.cfgHome = path.Join(userCfg, "nanite") stat, err := os.Stat(app.cfgHome) if err != nil { - if err := os.MkdirAll(app.cfgHome, 0o700); err != nil { + if err := os.Mkdir(app.cfgHome, 0o700); err != nil { return err } } else { @@ -110,25 +186,16 @@ func NewApp() *App { app.error = make(chan error) if err := app.EnsureConfigDir(); err != nil { - panic(err) - } - - if err := app.RefreshScripts(); err != nil { - panic(err) + app.error <- err } go func() { for { select { case ev := <-app.outgoing: - _, 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") + if err := ev.HandleOutgoing(app); err != nil { + app.error <- err + return } case <-app.ctx.Done(): return @@ -160,13 +227,7 @@ func (app *App) Loop() error { func (app *App) Finish() { app.stop() app.FinishUI() - if app.conn != nil { - HangupEvent{}.HandleOutgoing(app) - } -} - -func init() { - initCommandMap() + HangupEvent{}.HandleOutgoing(app) } func main() { @@ -192,7 +253,6 @@ func main() { app.AppendSystemMessage("welcome to nanite! :3") if err := app.Loop(); err != nil { - app.FinishUI() panic(err) } } diff --git a/makefile b/makefile deleted file mode 100644 index c2c6fd9..0000000 --- a/makefile +++ /dev/null @@ -1,9 +0,0 @@ -.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 deleted file mode 100644 index 1b4c00f..0000000 --- a/protocol.go +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 10c5d3b..0000000 --- a/script.go +++ /dev/null @@ -1,52 +0,0 @@ -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 -}