diff --git a/README.md b/README.md index 306e535..5b893e1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ `nanite` is a terminal [Nanochat] client. +![](./assets/screenshot.png) + ## build ``` @@ -24,10 +26,12 @@ keybindings: commands: +- `/dial hostname`: connect to server +- `/hangup`: disconnect - `/q`, `/quit`: quit - `/nick [nickname]`: change nick, if no arguments, show current nick -- `/me [is listening to music]`: IRC `/me` alike -- `/poll [n]`: change polling interval, if no arguments, poll manually +- `/me`: IRC `/me` alike +- `/poll [seconds]`: change polling interval, if no arguments, poll manually ## won't support (yet) 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 f1e98ff..82ceb3a 100644 --- a/command.go +++ b/command.go @@ -13,10 +13,10 @@ func (app *App) Stat() (res string, err error) { var str strings.Builder for range 3 { - if !app.scanner.Scan() { - return "", app.scanner.Err() + if !app.conn.Scanner.Scan() { + return "", app.conn.Scanner.Err() } - str.Write(app.scanner.Bytes()) + str.Write(app.conn.Scanner.Bytes()) str.WriteRune(' ') } @@ -28,10 +28,10 @@ func (app *App) Send(data string) (num int, err error) { return 0, err } - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - numRaw := app.scanner.Text() + numRaw := app.conn.Scanner.Text() num, err = strconv.Atoi(numRaw) if err != nil { return 0, err @@ -44,10 +44,10 @@ func (app *App) Poll(since int) (num int, err error) { return 0, err } - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - numRaw := app.scanner.Text() + numRaw := app.conn.Scanner.Text() num, err = strconv.Atoi(numRaw) if err != nil { return 0, err @@ -60,29 +60,29 @@ func (app *App) Skip(since int) (num int, err error) { return 0, err } - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - num, err = strconv.Atoi(app.scanner.Text()) + num, err = strconv.Atoi(app.conn.Scanner.Text()) if err != nil { return 0, err } for range num { - if !app.scanner.Scan() { + if !app.conn.Scanner.Scan() { return 0, err } - app.incoming <- Message(app.scanner.Text()) + app.incoming <- MessageEvent(app.conn.Scanner.Text()) } - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - last, err := strconv.Atoi(app.scanner.Text()) + last, err := strconv.Atoi(app.conn.Scanner.Text()) if err != nil { return 0, err } - app.incoming <- Last(last) + app.incoming <- SetLastEvent(last) return num, nil } @@ -97,10 +97,10 @@ func (app *App) Last(n int) (num int, err error) { } var nsrv int - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - nsrvRaw := app.scanner.Text() + nsrvRaw := app.conn.Scanner.Text() nsrv, err = strconv.Atoi(nsrvRaw) if err != nil { return 0, err @@ -108,23 +108,23 @@ func (app *App) Last(n int) (num int, err error) { if nsrv != 0 { for range nsrv { - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - app.incoming <- Message(app.scanner.Text()) + app.incoming <- MessageEvent(app.conn.Scanner.Text()) } } var last int - if !app.scanner.Scan() { - return 0, app.scanner.Err() + if !app.conn.Scanner.Scan() { + return 0, app.conn.Scanner.Err() } - lastRaw := app.scanner.Text() + lastRaw := app.conn.Scanner.Text() last, err = strconv.Atoi(lastRaw) if err != nil { return 0, err } - app.incoming <- Last(last) + app.incoming <- SetLastEvent(last) return nsrv, nil } diff --git a/display.go b/display.go index 7869600..cf1b2a8 100644 --- a/display.go +++ b/display.go @@ -37,23 +37,22 @@ func (app *App) resize() { } func (app *App) Redraw() { - app.w.title.Clear() - titleStyle := vaxis.Style{Attribute: vaxis.AttrBold} delimiterStyle := vaxis.Style{Attribute: vaxis.AttrDim} + app.w.title.Clear() if app.conn != nil { - titleString := fmt.Sprintf("nanite (%s:%s)", app.host, app.port) + titleString := fmt.Sprintf("nanite (%s:%s)", app.conn.host, app.conn.port) app.vx.SetTitle(titleString) rateString := "manual" - if app.rate != 0 { - rateString = app.rate.String() + if app.conn.rate != 0 { + rateString = app.conn.rate.String() } segments := []vaxis.Segment{ {Text: "• "}, - {Text: app.host, Style: titleStyle}, + {Text: app.conn.host, Style: titleStyle}, {Text: " │ ", Style: delimiterStyle}, {Text: fmt.Sprintf("↻ %s", rateString)}, } @@ -66,9 +65,6 @@ func (app *App) Redraw() { } app.w.title.PrintTruncate(0, segments...) - app.pager.Layout() - app.pager.Draw(app.w.log) - app.input.Draw(app.w.input) } else { app.vx.SetTitle("nanite (disconnected)") app.w.title.PrintTruncate(0, @@ -76,6 +72,9 @@ func (app *App) Redraw() { vaxis.Segment{Text: "disconnected", Style: titleStyle}, ) } + app.pager.Layout() + app.pager.Draw(app.w.log) + app.input.Draw(app.w.input) app.vx.Render() } @@ -94,8 +93,8 @@ func (app *App) submitTextInput() { } else { message := fmt.Sprintf("%s: %s", app.nick, app.input.String()) app.AppendMessage(message) - app.outgoing <- Message(message) - app.outgoing <- Stat("") + app.outgoing <- MessageEvent(message) + app.outgoing <- StatEvent("") } app.input.SetContent("") @@ -112,7 +111,7 @@ func (app *App) HandleTerminalEvent(ev vaxis.Event) { case "Enter": app.submitTextInput() case "Ctrl+p": - app.outgoing <- ManualPoll(app.last) + app.outgoing <- ManualPollEvent(app.last) case "Ctrl+l": app.Redraw() app.vx.Refresh() diff --git a/event.go b/event.go index b7de590..6b9e1aa 100644 --- a/event.go +++ b/event.go @@ -1,5 +1,13 @@ package main +import ( + "bufio" + "context" + "fmt" + "net" + "time" +) + type IncomingEvent interface { HandleIncoming(*App) } @@ -7,26 +15,121 @@ type OutgoingEvent interface { HandleOutgoing(*App) error } -type Message string - -func (m Message) HandleIncoming(app *App) { - app.AppendMessage(string(m)) - +type DialEvent struct { + Host, Port string } -func (m Message) HandleOutgoing(app *App) error { - num, err := app.Send(string(m)) + +func (ev DialEvent) HandleIncoming(app *App) { + app.AppendSystemMessage("connected to %s:%s", ev.Host, ev.Port) +} + +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)) + return nil + } + conn, err := net.Dial("tcp", net.JoinHostPort(ev.Host, ev.Port)) if err != nil { return err } - app.incoming <- Last(num) + ctx, stop := context.WithCancel(app.ctx) + + app.conn = &Conn{ + Conn: conn, + Scanner: bufio.NewScanner(conn), + host: ev.Host, + port: ev.Port, + ctx: ctx, + stop: stop, + } + + // calculate latency + latStart := time.Now() + _, err = app.Poll(0) + if err != nil { + return err + } + 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) + + go func() { + for { + select { + case <-app.conn.ticker.C: + app.outgoing <- PollEvent(app.last) + case <-app.conn.ctx.Done(): + return + } + } + }() + + app.outgoing <- FetchEvent(50) + app.outgoing <- StatEvent("") + app.incoming <- ev return nil } -type Poll int +type HangupEvent struct{} -func (p Poll) HandleOutgoing(app *App) error { - num, err := app.Poll(int(p)) +func (ev HangupEvent) HandleOutgoing(app *App) error { + if app.conn == nil { + app.incoming <- SystemMessageEvent("not connected to any server") + return nil + } + + host := app.conn.host + port := app.conn.port + + app.conn.stop() + app.conn.Write([]byte("QUIT\n")) + app.conn.Close() + app.conn.ticker.Stop() + app.conn.ticker = nil + app.conn = nil + app.incoming <- SystemMessageEvent(fmt.Sprintf("disconnected from %s:%s", host, port)) + + return nil +} + +type FetchEvent int + +func (ev FetchEvent) HandleOutgoing(app *App) error { + _, err := app.Last(int(ev)) + return err +} + +type MessageEvent string + +func (ev MessageEvent) HandleIncoming(app *App) { + app.AppendMessage(string(ev)) + +} +func (ev MessageEvent) HandleOutgoing(app *App) error { + num, err := app.Send(string(ev)) + if err != nil { + return err + } + app.incoming <- SetLastEvent(num) + + return nil +} + +type SystemMessageEvent string + +func (ev SystemMessageEvent) HandleIncoming(app *App) { + app.AppendSystemMessage("%s", string(ev)) +} + +type PollEvent int + +func (ev PollEvent) HandleOutgoing(app *App) error { + num, err := app.Poll(int(ev)) if err != nil { return err } @@ -39,19 +142,19 @@ func (p Poll) HandleOutgoing(app *App) error { return err } if num != 0 { - app.outgoing <- Stat("") + app.outgoing <- StatEvent("") } return nil } -type ManualPoll int +type ManualPollEvent int -func (p ManualPoll) HandleOutgoing(app *App) error { - num, err := app.Poll(int(p)) +func (ev ManualPollEvent) HandleOutgoing(app *App) error { + num, err := app.Poll(int(ev)) if err != nil { return err } - app.incoming <- ManualPoll(num) + app.incoming <- ManualPollEvent(num) if num == 0 { return nil } @@ -61,35 +164,35 @@ func (p ManualPoll) HandleOutgoing(app *App) error { return err } if num != 0 { - app.outgoing <- Stat("") + app.outgoing <- StatEvent("") } return nil } -func (p ManualPoll) HandleIncoming(app *App) { - if int(p) == 0 { +func (ev ManualPollEvent) HandleIncoming(app *App) { + if int(ev) == 0 { app.AppendSystemMessage("poll: no new messages") } else { - app.AppendSystemMessage("poll: retrieving %d messages", p) + app.AppendSystemMessage("poll: retrieving %d messages", ev) } } -type Last int +type SetLastEvent int -func (m Last) HandleIncoming(app *App) { - app.last = int(m) +func (ev SetLastEvent) HandleIncoming(app *App) { + app.last = int(ev) } -type Stat string +type StatEvent string -func (data Stat) HandleIncoming(app *App) { - app.stats = string(data) +func (ev StatEvent) HandleIncoming(app *App) { + app.stats = string(ev) } -func (_ Stat) HandleOutgoing(app *App) error { +func (_ StatEvent) HandleOutgoing(app *App) error { res, err := app.Stat() if err != nil { return err } - app.incoming <- Stat(res) + app.incoming <- StatEvent(res) return nil } diff --git a/main.go b/main.go index eb63325..6e2fb57 100644 --- a/main.go +++ b/main.go @@ -3,82 +3,111 @@ package main import ( "bufio" "context" - "errors" "fmt" "math" "net" "os" + "path" "strconv" "strings" "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") -) +var CommandMap map[string]func(*App, string) -var CommandMap = map[string]func(*App, string){ - "nick": func(app *App, rest string) { - args := strings.Fields(rest) - switch len(args) { - case 0: - app.AppendSystemMessage("nick: your nickname is %s", app.nick) - default: - app.SetNick(args[0]) - app.AppendSystemMessage("nick: your nickname is now %s", app.nick) - } - }, - "poll": func(app *App, rest string) { - if rest == "" { - app.outgoing <- ManualPoll(app.last) - } else { - num, err := strconv.Atoi(rest) - if err != nil { - app.AppendSystemMessage("poll: invalid number %s", rest) +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 { - if num == 0 { - app.ticker.Stop() - app.rate = 0 - app.AppendSystemMessage("poll: disabled automatic polling") - } else { - app.rate = time.Second * time.Duration(num) - app.ticker.Stop() - app.ticker = time.NewTicker(app.rate) - app.AppendSystemMessage("poll: polling every %s", app.rate.String()) + 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) } } - } - }, - "me": func(app *App, rest string) { - msg := fmt.Sprintf("%s %s", app.nick, rest) - app.AppendMessage(msg) - app.outgoing <- Message(msg) - }, - "quit": func(app *App, rest string) { - app.stop() - }, - "q": func(app *App, rest string) { - app.stop() - }, + }, + "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 - conn net.Conn - scanner *bufio.Scanner - host, port string - stats string + conn *Conn + stats string - nick string - last int - rate time.Duration - ticker *time.Ticker + nick string + last int incoming chan IncomingEvent outgoing chan OutgoingEvent @@ -86,44 +115,81 @@ type App struct { vx *vaxis.Vaxis w struct { - log, title, status, input vaxis.Window + log, title, input vaxis.Window } - pager *pager.Model - input *textinput.Model + pager *pager.Model + input *textinput.Model + cfgHome string } -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)) +type Conn struct { + net.Conn + *bufio.Scanner + host, port string + rate time.Duration + ticker *time.Ticker + ctx context.Context + stop context.CancelFunc +} + +func (app *App) AppendMessage(data string) { + // TODO: make messages without a nick italic + app.pager.Segments = append(app.pager.Segments, + vaxis.Segment{Text: data}, + vaxis.Segment{Text: "\n"}, + ) + app.last += 1 + app.pager.Offset = math.MaxInt +} + +func (app *App) AppendSystemMessage(format string, args ...any) { + st := vaxis.Style{Attribute: vaxis.AttrDim} + app.pager.Segments = append(app.pager.Segments, + vaxis.Segment{Text: "* ", Style: st}, + vaxis.Segment{Text: fmt.Sprintf(format, args...), Style: st}, + vaxis.Segment{Text: "\n"}, + ) + app.pager.Offset = math.MaxInt +} + +func (app *App) SetNick(nick string) { + nick = strings.TrimSpace(nick) + app.input.SetPrompt(fmt.Sprintf("%s: ", nick)) + app.input.Prompt.Attribute = vaxis.AttrBold + app.nick = nick +} + +func (app *App) EnsureConfigDir() error { + userCfg, err := os.UserConfigDir() if err != nil { return err } - app.scanner = bufio.NewScanner(app.conn) + app.cfgHome = path.Join(userCfg, "nanite") + stat, err := os.Stat(app.cfgHome) + if err != nil { + if err := os.Mkdir(app.cfgHome, 0o700); err != nil { + return err + } + } else { + if !stat.IsDir() { + return fmt.Errorf("expected %s to be directory", app.cfgHome) + } + } + return nil +} + +func NewApp() *App { + app := &App{} + app.ctx, app.stop = context.WithCancel(context.Background()) app.incoming = make(chan IncomingEvent, 256) app.outgoing = make(chan OutgoingEvent, 256) app.error = make(chan error) - // Calculate latency - now := time.Now() - _, err = app.Poll(0) - if err != nil { - app.Disconnect() - return err + if err := app.EnsureConfigDir(); err != nil { + app.error <- err } - delta := time.Since(now).Round(time.Second) - delta = min(max(time.Second, delta*3/2), 5*time.Second) - - app.rate = delta - app.ticker = time.NewTicker(delta) go func() { - app.outgoing <- Stat("") - app.Last(20) - for { select { case ev := <-app.outgoing: @@ -137,96 +203,56 @@ func (app *App) Connect(host, port string) (err error) { } }() - return nil -} - -func (app *App) Disconnect() { - if app.conn != nil { - app.conn.Write([]byte("QUIT\n")) - app.conn.Close() - app.conn = nil - } - if app.ticker != nil { - app.ticker.Stop() - app.ticker = nil - } - if app.incoming != nil { - close(app.incoming) - app.incoming = nil - } - if app.outgoing != nil { - close(app.outgoing) - app.outgoing = 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 -} - -func (app *App) AppendSystemMessage(format string, args ...any) { - st := vaxis.Style{Foreground: vaxis.ColorGray, Attribute: vaxis.AttrDim} - app.pager.Segments = append(app.pager.Segments, - vaxis.Segment{Text: "* ", Style: st}, - vaxis.Segment{Text: fmt.Sprintf(format, args...), Style: st}, - vaxis.Segment{Text: "\n"}, - ) - app.pager.Offset = math.MaxInt -} - -func (app *App) SetNick(nick string) { - app.input.SetPrompt(fmt.Sprintf("%s: ", nick)) - app.input.Prompt.Attribute = vaxis.AttrBold - app.nick = nick -} - -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() - app.InitUI() - app.Redraw() - defer app.FinishUI() - if err := app.Connect(args[1], port); err != nil { - panic(err) - } - defer app.Disconnect() - - app.SetNick("wolfdog") + return app +} +func (app *App) Loop() error { 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) + ev.HandleIncoming(app) case err := <-app.error: - app.FinishUI() - panic(err) + return err case <-app.ctx.Done(): - return + return nil } - app.Redraw() } } + +func (app *App) Finish() { + app.stop() + app.FinishUI() + HangupEvent{}.HandleOutgoing(app) +} + +func main() { + app := NewApp() + defer app.Finish() + + args := os.Args + if len(args) == 2 || len(args) == 3 { + host := args[1] + port := "44322" + if len(args) == 3 { + port = args[2] + } + app.outgoing <- DialEvent{host, port} + } + + if data, err := os.ReadFile(path.Join(app.cfgHome, "nick")); err == nil { + app.SetNick(string(data)) + } else { + app.SetNick("someone") + } + + app.AppendSystemMessage("welcome to nanite! :3") + + if err := app.Loop(); err != nil { + panic(err) + } +}