From 5b9b835cb1c3e42f0f00be6c648a9cbbbe0bef45 Mon Sep 17 00:00:00 2001 From: Tyler Sommer Date: Tue, 22 Sep 2020 06:01:10 -0600 Subject: [PATCH] Continue work on basic functionality --- builtin_cmds.go | 139 ++++++++++++++++- handlers.go | 394 ++++++++++++++++++++++++++++++++++++++++++++++-- plugin.go | 2 +- server.go | 305 +++++++++++++++++++++++-------------- text_input.go | 31 +++- window.go | 20 ++- 6 files changed, 748 insertions(+), 143 deletions(-) diff --git a/builtin_cmds.go b/builtin_cmds.go index f04cbce..3bcf483 100644 --- a/builtin_cmds.go +++ b/builtin_cmds.go @@ -2,30 +2,157 @@ package squirssi import ( "strconv" + "strings" + + "code.dopame.me/veonik/squircy3/event" + "code.dopame.me/veonik/squircy3/irc" + "github.com/sirupsen/logrus" ) type Command func(*Server, []string) var builtIns = map[string]Command{ - "w": selectWindow, - "wc": closeWindow, + "w": selectWindow, + "wc": closeWindow, + "join": joinChannel, + "part": partChannel, + "whois": whoisNick, + "names": namesChannel, + "nick": changeNick, } func selectWindow(srv *Server, args []string) { if len(args) < 2 { + logrus.Warnln("selectWindow: expected one argument") return } + var err error ch, err := strconv.Atoi(args[1]) if err != nil { + logrus.Warnln("selectWindow: expected first argument to be an integer") return } - if ch < len(srv.statusBar.TabNames) { - srv.statusBar.ActiveTabIndex = ch - srv.Update() - srv.Render() + srv.mu.Lock() + if ch >= len(srv.windows) { + logrus.Warnf("selectWindow: no window #%d", ch) + srv.mu.Unlock() + return } + srv.statusBar.ActiveTabIndex = ch + srv.mu.Unlock() + srv.Update() + srv.Render() } func closeWindow(srv *Server, args []string) { + var ch int + if len(args) < 2 { + srv.mu.Lock() + ch = srv.statusBar.ActiveTabIndex + srv.mu.Unlock() + } else { + var err error + ch, err = strconv.Atoi(args[1]) + if err != nil { + logrus.Warnln("selectWindow: expected first argument to be an integer") + return + } + } + win := srv.windows[ch] + if strings.HasPrefix(win.Title(), "#") { + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.Part(win.Title()) + return nil + }); err != nil { + logrus.Warnln("closeWindow: failed to part channel before closing window") + } + } + srv.CloseWindow(ch) +} + +func joinChannel(srv *Server, args []string) { + if len(args) < 2 { + logrus.Warnln("joinChannel: expected one argument") + return + } + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.Join(args[1]) + return nil + }); err != nil { + logrus.Warnln("joinChannel: error joining channel:", err) + } +} + +func partChannel(srv *Server, args []string) { + if len(args) < 2 { + logrus.Warnln("partChannel: expected one argument") + return + } + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.Part(args[1]) + return nil + }); err != nil { + logrus.Warnln("partChannel: error joining channel:", err) + } +} +func whoisNick(srv *Server, args []string) { + if len(args) < 2 { + logrus.Warnln("whoisNick: expected one argument") + return + } + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.SendRawf("WHOIS %s", args[1]) + return nil + }); err != nil { + logrus.Warnln("whoisNick: error getting whois info:", err) + } +} + +func namesChannel(srv *Server, args []string) { + if len(args) < 2 { + logrus.Warnln("namesChannel: expected one argument") + return + } + channel := args[1] + win := srv.WindowNamed(channel) + if win == nil { + logrus.Warnln("namesChannel: no window named", channel) + return + } + irc353Handler := event.HandlerFunc(func(ev *event.Event) { + args := ev.Data["Args"].([]string) + chanName := args[2] + nicks := args[3] + logrus.Infof("NAMES %s: %s", chanName, nicks) + }) + var irc366Handler event.Handler + irc366Handler = event.HandlerFunc(func(ev *event.Event) { + args := ev.Data["Args"].([]string) + chanName := args[1] + logrus.Infof("END NAMES %s", chanName) + srv.events.Unbind("irc.353", irc353Handler) + srv.events.Unbind("irc.366", irc366Handler) + }) + srv.events.Bind("irc.353", irc353Handler) + srv.events.Bind("irc.366", irc366Handler) + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.SendRawf("NAMES :%s", channel) + return nil + }); err != nil { + logrus.Warnln("namesChannel: error getting names:", err) + } +} + +func changeNick(srv *Server, args []string) { + if len(args) < 2 { + logrus.Warnln("changeNick: expected one argument") + return + } + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.Nick(args[1]) + return nil + }); err != nil { + logrus.Warnln("changeNick: error changing nick:", err) + } } diff --git a/handlers.go b/handlers.go index dc6a0ef..ccb6010 100644 --- a/handlers.go +++ b/handlers.go @@ -2,54 +2,418 @@ package squirssi import ( "fmt" + "os" + "strings" + "sync" + "time" "code.dopame.me/veonik/squircy3/event" + "code.dopame.me/veonik/squircy3/irc" "github.com/sirupsen/logrus" ) +func (srv *Server) bind() { + srv.events.Bind("ui.DIRTY", event.HandlerFunc(srv.onUIDirty)) + + srv.events.Bind("irc.CONNECT", event.HandlerFunc(srv.onIRCConnect)) + srv.events.Bind("irc.DISCONNECT", event.HandlerFunc(srv.onIRCDisconnect)) + srv.events.Bind("irc.PRIVMSG", event.HandlerFunc(srv.onIRCPrivmsg)) + srv.events.Bind("irc.JOIN", event.HandlerFunc(srv.onIRCJoin)) + srv.events.Bind("irc.PART", event.HandlerFunc(srv.onIRCPart)) + srv.events.Bind("irc.KICK", event.HandlerFunc(srv.onIRCKick)) + srv.events.Bind("irc.JOIN", event.HandlerFunc(srv.onIRCNames)) + srv.events.Bind("irc.PART", event.HandlerFunc(srv.onIRCNames)) + srv.events.Bind("irc.KICK", event.HandlerFunc(srv.onIRCNames)) + srv.events.Bind("irc.NICK", event.HandlerFunc(srv.onIRCNick)) + srv.events.Bind("irc.353", event.HandlerFunc(srv.onIRC353)) + srv.events.Bind("irc.366", event.HandlerFunc(srv.onIRC366)) + errorCodes := []string{"irc.401", "irc.403", "irc.404", "irc.405", "irc.406", "irc.407", "irc.408", "irc.421"} + for _, code := range errorCodes { + srv.events.Bind(code, event.HandlerFunc(srv.onIRCError)) + } + whoisCodes := []string{"irc.311", "irc.312", "irc.313", "irc.317", "irc.318", "irc.319", "irc.314", "irc.369"} + for _, code := range whoisCodes { + srv.events.Bind(code, event.HandlerFunc(srv.onIRCWhois)) + } +} + func (srv *Server) onUIDirty(_ *event.Event) { srv.Update() srv.Render() } +// onUIKeyPress handles keyboard input from termui. +// Not a regular event handler but instead called before the actual +// ui.KEYPRESS event is emitted. This is done to avoid extra lag between +// pressing a key and seeing the UI react. +func (srv *Server) onUIKeyPress(key string) { + switch key { + case "": + srv.Close() + os.Exit(0) + return + case "": + srv.ScrollPageUp() + case "": + srv.ScrollPageDown() + case "": + srv.ScrollTop() + case "": + srv.ScrollBottom() + case "": + srv.mu.Lock() + srv.inputTextBox.Append(" ") + srv.mu.Unlock() + srv.RenderOnly(InputTextBox) + case "": + srv.mu.Lock() + srv.inputTextBox.Backspace() + srv.mu.Unlock() + srv.RenderOnly(InputTextBox) + case "": + srv.mu.Lock() + srv.statusBar.FocusRight() + srv.mu.Unlock() + srv.Update() + srv.Render() + case "": + srv.mu.Lock() + srv.statusBar.FocusLeft() + srv.mu.Unlock() + srv.Update() + srv.Render() + case "": + srv.mu.Lock() + channel := srv.windows[srv.statusBar.ActiveTabIndex] + srv.mu.Unlock() + if ch, ok := channel.(WindowWithUserList); ok { + msg := srv.inputTextBox.Peek() + parts := strings.Split(msg, " ") + match := parts[len(parts)-1] + extra := "" + if match == parts[0] { + extra = ": " + } + for _, u := range ch.Users() { + nick := strings.ReplaceAll(strings.ReplaceAll(u, "@", ""), "+", "") + if strings.HasPrefix(nick, match) { + srv.inputTextBox.Append(strings.Replace(nick, match, "", 1) + extra) + break + } + } + } + srv.RenderOnly(InputTextBox) + case "": + srv.mu.Lock() + in := srv.inputTextBox.Consume() + if srv.inputTextBox.Mode() == ModeCommand { + srv.inputTextBox.ToggleMode() + } + active := srv.statusBar.ActiveTabIndex + channel := srv.windows[active] + myNick := srv.currentNick + srv.mu.Unlock() + if channel == nil { + return + } + if len(in.Text) == 0 { + // render anyway incase the textbox mode was changed + srv.RenderOnly(MainWindow) + return + } + switch in.Kind { + case ModeCommand: + args := strings.Split(in.Text, " ") + c := args[0] + if cmd, ok := builtIns[c]; ok { + cmd(srv, args) + } + case ModeMessage: + if active == 0 { + // status window doesn't accept messages + return + } + if err := srv.irc.Do(func(c *irc.Connection) error { + c.Privmsg(channel.Title(), in.Text) + return nil + }); err != nil { + logrus.Warnln("failed to send message:", err) + } + if _, err := channel.Write([]byte("<" + myNick + "> " + in.Text)); err != nil { + logrus.Warnln("failed to write message:", err) + } + } + + default: + if len(key) != 1 { + // a single key resulted in more than one character, probably not a regular char + return + } + srv.mu.Lock() + if key == "/" && srv.inputTextBox.Len() == 0 { + srv.inputTextBox.ToggleMode() + } else { + srv.inputTextBox.Append(key) + } + srv.mu.Unlock() + srv.RenderOnly(InputTextBox) + } +} + +func (srv *Server) onIRCConnect(_ *event.Event) { + err := srv.irc.Do(func(conn *irc.Connection) error { + srv.mu.Lock() + defer srv.mu.Unlock() + srv.currentNick = conn.GetNick() + return nil + }) + if err != nil { + logrus.Warnln("failed to set current nick:", err) + } +} + +func (srv *Server) onIRCDisconnect(_ *event.Event) { + logrus.Infoln("*** Disconnected") + srv.mu.Lock() + defer srv.mu.Unlock() + srv.currentNick = "" +} + +func (srv *Server) onIRCNick(ev *event.Event) { + logrus.Infoln("irc.NICK:", ev.Data) + nick := ev.Data["Nick"].(string) + newNick := ev.Data["Message"].(string) + srv.mu.Lock() + if nick == srv.currentNick { + srv.currentNick = newNick + } + wins := make([]Window, len(srv.windows)) + copy(wins, srv.windows) + srv.mu.Unlock() + for _, win := range wins { + if win.Title() == nick { + // direct message with nick, update title and print there + if dm, ok := win.(*DirectMessage); ok { + dm.mu.Lock() + dm.name = nick + dm.mu.Unlock() + } + if _, err := win.Write([]byte(fmt.Sprintf("*** %s is now known as %s", nick, newNick))); err != nil { + logrus.Warnln("failed to write nick change:", err) + } + } else if ch, ok := win.(*Channel); ok { + hasNick := false + ch.mu.Lock() + for i, u := range ch.users { + if strings.ReplaceAll(strings.ReplaceAll(u, "@", ""), "+", "") == nick { + ch.users[i] = newNick + hasNick = true + break + } + } + ch.mu.Unlock() + if hasNick { + if _, err := win.Write([]byte(fmt.Sprintf("*** %s is now known as %s", nick, newNick))); err != nil { + logrus.Warnln("failed to write nick change:", err) + } + } + } + } +} + +func (srv *Server) onIRCKick(ev *event.Event) { + args := ev.Data["Args"].([]string) + channel := ev.Data["Target"].(string) + kicked := args[1] + srv.mu.Lock() + myNick := srv.currentNick + srv.mu.Unlock() + if kicked == myNick { + go func() { + <-time.After(2 * time.Second) + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.Join(channel) + return nil + }); err != nil { + logrus.Warnln("failed to rejoin after kick:", err) + } + }() + return + } + win := srv.WindowNamed(channel) + if win == nil { + logrus.Errorln("received kick with no Window:", channel, ev.Data["Message"], ev.Data["Nick"]) + return + } + if _, err := win.Write([]byte(fmt.Sprintf("*** %s got kicked from %s (%s)", kicked, channel, ev.Data["Message"].(string)))); err != nil { + logrus.Warnln("failed to write message:", err) + } +} + +var namesCache = &struct { + sync.Mutex + values map[string][]string +}{values: make(map[string][]string)} + +func (srv *Server) onIRC353(ev *event.Event) { + // NAMES + args := ev.Data["Args"].([]string) + chanName := args[2] + nicks := strings.Split(args[3], " ") + win := srv.WindowNamed(chanName) + if win == nil { + logrus.Warnln("received NAMES for channel with no window:", chanName) + return + } + namesCache.Lock() + defer namesCache.Unlock() + namesCache.values[chanName] = append(namesCache.values[chanName], nicks...) +} + +func (srv *Server) onIRC366(ev *event.Event) { + // END NAMES + args := ev.Data["Args"].([]string) + chanName := args[1] + win := srv.WindowNamed(chanName) + if win == nil { + logrus.Warnln("received END NAMES for channel with no window:", chanName) + return + } + ch, ok := win.(*Channel) + if !ok { + logrus.Warnln("received END NAMES for a non channel:", chanName) + return + } + namesCache.Lock() + defer namesCache.Unlock() + ch.mu.Lock() + ch.users = namesCache.values[chanName] + ch.mu.Unlock() + delete(namesCache.values, chanName) +} + +func (srv *Server) onIRCError(ev *event.Event) { + args := ev.Data["Args"].([]string) + var kind string + if len(args) > 1 { + kind = args[1] + } else { + kind = ev.Data["Target"].(string) + } + logrus.Errorf("%s: %s", kind, ev.Data["Message"]) +} + +func (srv *Server) onIRCWhois(ev *event.Event) { + args := ev.Data["Args"].([]string) + kind := args[1] + data := args[2:] + if _, err := srv.status.Write([]byte(fmt.Sprintf("WHOIS %s => %s", kind, strings.Join(data, " ")))); err != nil { + logrus.Warnln("failed to write whois result to status:", err) + } +} + +func (srv *Server) onIRCNames(ev *event.Event) { + if ev.Name == "irc.PART" || ev.Name == "irc.KICK" { + srv.mu.Lock() + myNick := srv.currentNick + srv.mu.Unlock() + nick := ev.Data["Nick"].(string) + if nick == myNick { + // dont bother trying to get names when we are the one leaving + return + } + } + target := ev.Data["Target"].(string) + if strings.HasPrefix(target, "#") { + if err := srv.irc.Do(func(conn *irc.Connection) error { + conn.SendRawf("NAMES :%s", target) + return nil + }); err != nil { + logrus.Warnln("failed to run NAMES on user change:", err) + } + } +} + func (srv *Server) onIRCJoin(ev *event.Event) { + target := ev.Data["Target"].(string) + win := srv.WindowNamed(target) + if win == nil { + srv.mu.Lock() + ch := &Channel{ + bufferedWindow: newBufferedWindow(target, srv.events), + users: []string{srv.currentNick}, + } + srv.windows = append(srv.windows, ch) + srv.mu.Unlock() + win = ch + } + if _, err := win.Write([]byte(fmt.Sprintf("*** %s joined %s", ev.Data["Nick"], win.Title()))); err != nil { + logrus.Warnln("%s: failed to write join message:", err) + } +} + +func (srv *Server) onIRCPart(ev *event.Event) { var win Window + var ch int target := ev.Data["Target"].(string) - for _, w := range srv.windows { + nick := ev.Data["Nick"].(string) + srv.mu.Lock() + for i, w := range srv.windows { if w.Title() == target { win = w + ch = i break } } + myNick := srv.currentNick + srv.mu.Unlock() if win == nil { - ch := &Channel{ - bufferedWindow: newBufferedWindow(target, srv.events), - users: []string{"veonik"}, + if nick != myNick { + // dont bother logging if we are the ones leaving + logrus.Errorln("received message with no Window:", target, ev.Data["Message"], ev.Data["Nick"]) } - srv.windows = append(srv.windows, ch) + return + } + if nick == myNick { + // its me! + srv.CloseWindow(ch) + } + if _, err := win.Write([]byte(fmt.Sprintf("*** %s parted %s", nick, win.Title()))); err != nil { + logrus.Warnln("%s: failed to write part message:", err) } } func (srv *Server) onIRCPrivmsg(ev *event.Event) { var win Window + direct := false target := ev.Data["Target"].(string) + nick := ev.Data["Nick"].(string) + srv.mu.Lock() + if target == srv.currentNick { + // its a direct message! + direct = true + target = nick + } for _, w := range srv.windows { if w.Title() == target { win = w break } } + srv.mu.Unlock() if win == nil { - logrus.Warnln("received message with no Window:", target, ev.Data["Message"], ev.Data["Nick"]) - } else { - if v, ok := win.(*Channel); ok { - if v.current == len(v.lines)-1 { - v.current++ - } - if _, err := v.Write([]byte(fmt.Sprintf("<%s> %s", ev.Data["Nick"], ev.Data["Message"]))); err != nil { - logrus.Warnln("error writing to Channel:", err) - return - } + if !direct { + logrus.Warnln("received message with no Window:", target, ev.Data["Message"], nick) + return + } else { + srv.mu.Lock() + ch := &DirectMessage{bufferedWindow: newBufferedWindow(target, srv.events)} + srv.windows = append(srv.windows, ch) + win = ch + srv.mu.Unlock() } } + if _, err := win.Write([]byte(fmt.Sprintf("<%s> %s", nick, ev.Data["Message"]))); err != nil { + logrus.Warnln("error writing to Window:", err) + } } diff --git a/plugin.go b/plugin.go index 057a078..8ad7693 100644 --- a/plugin.go +++ b/plugin.go @@ -16,7 +16,7 @@ func FromPlugins(m *plugin.Manager) (*Server, error) { } mplg, ok := plg.(*squirssiPlugin) if !ok { - return nil, errors.Errorf("event: received unexpected plugin type") + return nil, errors.Errorf("event: received unexpected plugin type %T", plg) } return mplg.server, nil } diff --git a/server.go b/server.go index 1bfbb58..2abb7b5 100644 --- a/server.go +++ b/server.go @@ -3,8 +3,6 @@ package squirssi import ( "fmt" - "os" - "strings" "sync" "code.dopame.me/veonik/squircy3/event" @@ -16,6 +14,7 @@ import ( "code.dopame.me/veonik/squirssi/colors" ) +// A Server handles user interaction and displaying screen elements. type Server struct { *logrus.Logger @@ -31,12 +30,38 @@ type Server struct { events *event.Dispatcher irc *irc.Manager + currentNick string + windows []Window status *Status mu sync.Mutex } +type logFormatter struct{} + +func (f *logFormatter) Format(entry *logrus.Entry) ([]byte, error) { + lvl := "" + switch entry.Level { + case logrus.InfoLevel: + lvl = "[INFO ](fg:blue)" + case logrus.DebugLevel: + lvl = "[DEBUG](fg:white)" + case logrus.WarnLevel: + lvl = "[WARN ](fg:yellow)" + case logrus.ErrorLevel: + lvl = "[ERROR](fg:red)" + case logrus.FatalLevel: + lvl = "[FATAL](fg:white,bg:red)" + case logrus.TraceLevel: + lvl = "[TRACE](fg:white)" + case logrus.PanicLevel: + lvl = "[PANIC](fg:white,bg:red)" + } + return []byte(fmt.Sprintf("%s: %s", lvl, entry.Message)), nil +} + +// NewServer creates a new server. func NewServer(ev *event.Dispatcher, irc *irc.Manager) (*Server, error) { if err := ui.Init(); err != nil { return nil, err @@ -50,11 +75,106 @@ func NewServer(ev *event.Dispatcher, irc *irc.Manager) (*Server, error) { events: ev, irc: irc, + + status: &Status{bufferedWindow: newBufferedWindow("status", ev)}, } + srv.windows = []Window{srv.status} + srv.Logger.SetOutput(srv.status) + srv.Logger.SetFormatter(&logFormatter{}) srv.initUI() return srv, nil } +// WindowNamed returns the window with the given name, if it exists. +func (srv *Server) WindowNamed(name string) Window { + var win Window + srv.mu.Lock() + defer srv.mu.Unlock() + for _, w := range srv.windows { + if w.Title() == name { + win = w + break + } + } + return win +} + +// CloseWindow closes a window denoted by tab index. +func (srv *Server) CloseWindow(ch int) { + if ch == 0 { + logrus.Warnln("CloseWindow: cant close status window") + return + } + srv.mu.Lock() + if ch >= len(srv.windows) { + logrus.Warnf("CloseWindow: no window #%d", ch) + srv.mu.Unlock() + return + } + srv.windows = append(srv.windows[:ch], srv.windows[ch+1:]...) + if ch >= srv.statusBar.ActiveTabIndex { + srv.statusBar.ActiveTabIndex = ch - 1 + } + srv.mu.Unlock() + srv.Update() + srv.Render() +} + +// ScrollPageUp scrolls one opage up in the current window. +func (srv *Server) ScrollPageUp() { + srv.mu.Lock() + srv.chatPane.ScrollPageUp() + row := srv.chatPane.SelectedRow + channel := srv.windows[srv.statusBar.ActiveTabIndex] + srv.mu.Unlock() + if channel == nil { + return + } + channel.ScrollTo(row) + srv.Render() +} + +// ScrollPageDown scrolls one page down in the current window. +func (srv *Server) ScrollPageDown() { + srv.mu.Lock() + srv.chatPane.ScrollPageDown() + row := srv.chatPane.SelectedRow + channel := srv.windows[srv.statusBar.ActiveTabIndex] + srv.mu.Unlock() + if channel == nil { + return + } + channel.ScrollTo(row) + srv.Render() +} + +// ScrollTop scrolls to the top of the current window. +func (srv *Server) ScrollTop() { + srv.mu.Lock() + srv.chatPane.ScrollTop() + row := srv.chatPane.SelectedRow + channel := srv.windows[srv.statusBar.ActiveTabIndex] + srv.mu.Unlock() + if channel == nil { + return + } + channel.ScrollTo(row) + srv.Render() +} + +// ScrollBottom scrolls to the end of the current window. +func (srv *Server) ScrollBottom() { + srv.mu.Lock() + srv.chatPane.ScrollBottom() + channel := srv.windows[srv.statusBar.ActiveTabIndex] + srv.mu.Unlock() + if channel == nil { + return + } + channel.ScrollTo(-1) + srv.Render() +} + func (srv *Server) initUI() { srv.userListPane = widgets.NewTable() srv.userListPane.Rows = [][]string{} @@ -71,6 +191,7 @@ func (srv *Server) initUI() { srv.chatPane.Border = true srv.chatPane.PaddingLeft = 1 srv.chatPane.PaddingRight = 1 + srv.chatPane.WrapText = true srv.statusBar = &ActivityTabPane{ TabPane: widgets.NewTabPane(" 0 "), @@ -89,39 +210,16 @@ func (srv *Server) initUI() { srv.inputTextBox.Border = false srv.mainWindow = ui.NewGrid() - - srv.status = &Status{bufferedWindow: newBufferedWindow("status", srv.events)} - srv.windows = []Window{srv.status} } +// Close ends the UI session, returning control of stdout. func (srv *Server) Close() { + srv.mu.Lock() + defer srv.mu.Unlock() ui.Close() } -type screenElement int - -const ( - InputTextBox screenElement = iota - StatusBar - MainWindow -) - -func (srv *Server) RenderOnly(items ...screenElement) { - var its []ui.Drawable - for _, it := range items { - switch it { - case InputTextBox: - its = append(its, srv.inputTextBox) - case StatusBar: - its = append(its, srv.statusBar) - case MainWindow: - its = append(its, srv.mainWindow) - } - } - ui.Render(its...) -} - -func tabNames(windows []Window) ([]string, map[int]struct{}) { +func tabNames(windows []Window, active int) ([]string, map[int]struct{}) { res := make([]string, len(windows)) activity := make(map[int]struct{}) for i := 0; i < len(windows); i++ { @@ -129,25 +227,31 @@ func tabNames(windows []Window) ([]string, map[int]struct{}) { if win.HasActivity() { activity[i] = struct{}{} } - res[i] = fmt.Sprintf(" %d ", i) + if active == i { + res[i] = fmt.Sprintf(" %s ", win.Title()) + } else { + res[i] = fmt.Sprintf(" %d ", i) + } } return res, activity } +// Update refreshes the state of the UI but stops short of rendering. func (srv *Server) Update() { srv.mu.Lock() defer srv.mu.Unlock() - channel := srv.windows[srv.statusBar.ActiveTabIndex] - if channel == nil { + win := srv.windows[srv.statusBar.ActiveTabIndex] + if win == nil { return } - srv.statusBar.TabNames, srv.statusBar.TabsWithActivity = tabNames(srv.windows) - srv.chatPane.Rows = channel.Lines() - srv.chatPane.Title = channel.Title() - srv.chatPane.SelectedRow = channel.CurrentLine() + win.Touch() + srv.statusBar.TabNames, srv.statusBar.TabsWithActivity = tabNames(srv.windows, srv.statusBar.ActiveTabIndex) + srv.chatPane.Rows = win.Lines() + srv.chatPane.Title = win.Title() + srv.chatPane.SelectedRow = win.CurrentLine() srv.mainWindow.Items = nil var rows [][]string - if v, ok := channel.(WindowWithUserList); ok { + if v, ok := win.(WindowWithUserList); ok { for _, nick := range v.Users() { rows = append(rows, []string{nick}) } @@ -163,101 +267,64 @@ func (srv *Server) Update() { srv.userListPane.Rows = rows } -func (srv *Server) Render() { - srv.mu.Lock() - defer srv.mu.Unlock() - ui.Render(srv.mainWindow, srv.statusBar, srv.inputTextBox) -} +type screenElement int -func (srv *Server) handleKey(e ui.Event) { - switch e.ID { - case "": - srv.Close() - os.Exit(0) - return - case "": - srv.chatPane.ScrollPageUp() - srv.Render() - case "": - srv.chatPane.ScrollPageDown() - srv.Render() - case "": - srv.inputTextBox.Append(" ") - srv.RenderOnly(InputTextBox) - case "": - srv.inputTextBox.Backspace() - srv.RenderOnly(InputTextBox) - case "": - srv.statusBar.FocusRight() - srv.Update() - srv.Render() - case "": - srv.statusBar.FocusLeft() - srv.Update() - srv.Render() - case "": - case "": - in := srv.inputTextBox.Consume() - if srv.inputTextBox.Mode() == ModeCommand { - srv.inputTextBox.ToggleMode() - } - channel := srv.windows[srv.statusBar.ActiveTabIndex] - if channel == nil { - return - } - if len(in.Text) == 0 { - return - } - switch in.Kind { - case ModeCommand: - args := strings.Split(in.Text, " ") - c := args[0] - if cmd, ok := builtIns[c]; ok { - cmd(srv, args) - } - case ModeMessage: - if err := srv.irc.Do(func(c *irc.Connection) error { - c.Privmsg(channel.Title(), in.Text) - _, err := channel.Write([]byte(" " + in.Text)) - return err - }); err != nil { - logrus.Warnln("failed to send message:", err) - } - } +const ( + InputTextBox screenElement = iota + StatusBar + MainWindow +) - default: - if len(e.ID) != 1 { - // a single key resulted in more than one character, probably not a regular char - return - } - if e.ID == "/" && srv.inputTextBox.Len() == 0 { - srv.inputTextBox.ToggleMode() - } else { - srv.inputTextBox.Append(e.ID) +func (srv *Server) preRender() { + if len(srv.chatPane.Rows) == 0 { + srv.chatPane.SelectedRow = 0 + } else if srv.chatPane.SelectedRow < 0 { + srv.chatPane.ScrollBottom() + } +} + +// RenderOnly renders select screen elements rather than the whole screen. +func (srv *Server) RenderOnly(items ...screenElement) { + srv.mu.Lock() + defer srv.mu.Unlock() + var its []ui.Drawable + for _, it := range items { + switch it { + case InputTextBox: + its = append(its, srv.inputTextBox) + case StatusBar: + its = append(its, srv.statusBar) + case MainWindow: + srv.preRender() + its = append(its, srv.mainWindow) } - srv.RenderOnly(InputTextBox) } + ui.Render(its...) +} + +// Render renders the current state to the screen. +func (srv *Server) Render() { + srv.mu.Lock() + defer srv.mu.Unlock() + srv.preRender() + ui.Render(srv.mainWindow, srv.statusBar, srv.inputTextBox) } func (srv *Server) resize() { + srv.mu.Lock() + defer srv.mu.Unlock() srv.statusBar.SetRect(0, srv.ScreenHeight-3, srv.ScreenWidth, srv.ScreenHeight) srv.inputTextBox.SetRect(0, srv.ScreenHeight-srv.statusBar.Dy()-1, srv.ScreenWidth, srv.ScreenHeight-srv.statusBar.Dy()) srv.mainWindow.SetRect(0, 0, srv.ScreenWidth, srv.ScreenHeight-srv.statusBar.Dy()-srv.inputTextBox.Dy()) } -func (srv *Server) bind() { - srv.events.Bind("ui.DIRTY", event.HandlerFunc(srv.onUIDirty)) - srv.events.Bind("irc.JOIN", event.HandlerFunc(srv.onIRCJoin)) - srv.events.Bind("irc.PRIVMSG", event.HandlerFunc(srv.onIRCPrivmsg)) -} - +// Start begins the UI event loop and does the initial render. func (srv *Server) Start() { srv.bind() srv.inputTextBox.Reset() srv.resize() srv.Update() srv.Render() - srv.Logger.SetOutput(srv.status) uiEvents := ui.PollEvents() @@ -265,7 +332,10 @@ func (srv *Server) Start() { e := <-uiEvents switch e.Type { case ui.KeyboardEvent: - srv.handleKey(e) + // handle keyboard input outside of the event emitter to avoid + // too long a delay between keypress and the UI reacting. + srv.onUIKeyPress(e.ID) + srv.RenderOnly(InputTextBox) srv.events.Emit("ui.KEYPRESS", map[string]interface{}{ "key": e.ID, }) @@ -285,14 +355,17 @@ func (srv *Server) Start() { if !ok { panic(fmt.Sprintf("received termui Resize event but Payload was unexpected type %T", e.Payload)) } + srv.mu.Lock() srv.ScreenHeight = resize.Height srv.ScreenWidth = resize.Width + srv.mu.Unlock() srv.resize() + srv.Update() + srv.Render() srv.events.Emit("ui.RESIZE", map[string]interface{}{ "width": resize.Width, "height": resize.Height, }) - srv.Render() } } } diff --git a/text_input.go b/text_input.go index a59d0ff..9dab2ff 100644 --- a/text_input.go +++ b/text_input.go @@ -35,22 +35,38 @@ func NewTextInput(cursor string) *TextInput { } } +// Peek returns the current input in the TextInput without clearing. +func (i *TextInput) Peek() string { + if i.Len() < 1 { + return "" + } + i.Lock() + defer i.Unlock() + return i.Text[i.prefixLen : len(i.Text)-i.cursorLen] +} + // Consume returns and clears the current input in the TextInput. func (i *TextInput) Consume() string { defer i.Reset() if i.Len() < 1 { return "" } + i.Lock() + defer i.Unlock() return i.Text[i.prefixLen : len(i.Text)-i.cursorLen] } // Len returns the length of the contents of the TextInput. func (i *TextInput) Len() int { + i.Lock() + defer i.Unlock() return len(i.Text) - i.cursorLen - i.prefixLen } // Reset the contents of the TextInput. func (i *TextInput) Reset() { + i.Lock() + defer i.Unlock() prefix := "" if i.Prefix != nil { prefix = i.Prefix() @@ -61,12 +77,16 @@ func (i *TextInput) Reset() { // Append adds the given string to the end of the editable content. func (i *TextInput) Append(in string) { + i.Lock() + defer i.Unlock() i.Text = i.Text[0:len(i.Text)-i.cursorLen] + in + i.cursor } // Remove the last character from the end of the editable content. func (i *TextInput) Backspace() { - if len(i.Paragraph.Text) > i.prefixLen+i.cursorLen { + i.Lock() + defer i.Unlock() + if len(i.Text) > i.prefixLen+i.cursorLen { i.Text = (i.Text)[0:len(i.Text)-i.cursorLen-1] + i.cursor } } @@ -105,16 +125,20 @@ func NewModedTextInput(cursor string) *ModedTextInput { // Mode returns the current editing mode. func (i *ModedTextInput) Mode() InputMode { + i.Lock() + defer i.Unlock() return i.mode } // ToggleMode switches between the editing modes. func (i *ModedTextInput) ToggleMode() { + i.Lock() if i.mode == ModeMessage { i.mode = ModeCommand } else { i.mode = ModeMessage } + i.Unlock() i.Reset() } @@ -126,8 +150,11 @@ type ModedText struct { // Consume returns and clears the ModedText in the ModedTextInput. func (i *ModedTextInput) Consume() ModedText { + txt := i.TextInput.Consume() + i.Lock() + defer i.Unlock() return ModedText{ Kind: i.mode, - Text: i.TextInput.Consume(), + Text: txt, } } diff --git a/window.go b/window.go index 1746d8c..53eb4df 100644 --- a/window.go +++ b/window.go @@ -4,6 +4,7 @@ import ( "io" "strings" "sync" + "time" "code.dopame.me/veonik/squircy3/event" ) @@ -15,9 +16,12 @@ type Window interface { Title() string // Contents of the Window, separated by line. Lines() []string + // The bottom-most visible line number, or negative to indicate // the window is pinned to the end of input. CurrentLine() int + // Set the current line to pos. Set to negative to pin to the end of input. + ScrollTo(pos int) // Clears the activity indicator for the window, it it's set. Touch() @@ -43,8 +47,9 @@ type bufferedWindow struct { func newBufferedWindow(name string, events *event.Dispatcher) bufferedWindow { return bufferedWindow{ - name: name, - events: events, + name: name, + events: events, + current: -1, } } @@ -58,7 +63,8 @@ func (c *bufferedWindow) Write(p []byte) (n int, err error) { defer c.events.Emit("ui.DIRTY", map[string]interface{}{ "name": c.name, }) - c.lines = append(c.lines, strings.TrimRight(string(p), "\n")) + t := time.Now().Format("[15:04:05] ") + c.lines = append(c.lines, strings.TrimRight(t+string(p), "\n")) c.hasUnseen = true return len(p), nil } @@ -87,6 +93,12 @@ func (c *bufferedWindow) CurrentLine() int { return c.current } +func (c *bufferedWindow) ScrollTo(pos int) { + c.mu.Lock() + defer c.mu.Unlock() + c.current = pos +} + type Status struct { bufferedWindow } @@ -104,6 +116,8 @@ type Channel struct { } func (c *Channel) Users() []string { + c.mu.Lock() + defer c.mu.Unlock() return c.users }