Browse Source

More events, text input improvements

master
Tyler Sommer 1 year ago
parent
commit
60c6ca5fc7
Signed by: tyler-sommer GPG Key ID: C09C010500DBD008
  1. 140
      builtin_cmds.go
  2. 3
      go.mod
  3. 4
      go.sum
  4. 45
      handlers.go
  5. 196
      handlers_irc.go
  6. 136
      server.go
  7. 118
      text_input.go
  8. 55
      widget.go
  9. 61
      widget_nicklist.go
  10. 164
      window.go
  11. 120
      window_writer.go

140
builtin_cmds.go

@ -17,11 +17,47 @@ var builtIns = map[string]Command{
"wc": closeWindow,
"join": joinChannel,
"part": partChannel,
"mode": modeChange,
"topic": topicChange,
"whois": whoisNick,
"names": namesChannel,
"nick": changeNick,
"me": actionMessage,
"msg": msgTarget,
"connect": connectServer,
"disconnect": disconnectServer,
"echo": func(srv *Server, args []string) {
win := srv.WindowManager.Active()
_, _ = win.WriteString(strings.Join(args[1:], " "))
},
"raw": func(srv *Server, args []string) {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.SendRaw(strings.Join(args[1:], " "))
win := srv.WindowManager.Active()
if win != nil {
_, _ = win.WriteString("-> " + strings.Join(args[1:], " "))
}
return nil
})
},
}
func connectServer(srv *Server, _ []string) {
go func() {
if err := srv.irc.Connect(); err != nil {
logrus.Errorln("unable to connect:", err)
}
}()
}
func disconnectServer(srv *Server, _ []string) {
go func() {
if err := srv.irc.Disconnect(); err != nil {
logrus.Errorln("unable to disconnect:", err)
}
}()
}
func exitProgram(srv *Server, _ []string) {
@ -32,6 +68,51 @@ func exitProgram(srv *Server, _ []string) {
}
}
func topicChange(srv *Server, args []string) {
if len(args) < 2 || !strings.HasPrefix("#", args[1]) {
win := srv.WindowManager.Active()
if win == nil || !strings.HasPrefix(win.Title(), "#") {
logrus.Warnln("topicChange: couldnt determine current channel")
return
}
args = append(append([]string{}, args[0], win.Title()), args[1:]...)
}
target := args[1]
if len(args) == 2 {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.SendRawf("TOPIC %s", target)
return nil
})
return
}
topic := strings.Join(args[2:], " ")
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.SendRawf("TOPIC %s :%s", target, topic)
return nil
})
}
func modeChange(srv *Server, args []string) {
if len(args) < 2 || strings.HasPrefix(args[1], "+") || strings.HasPrefix(args[1], "-") {
win := srv.WindowManager.Active()
t := ""
if win == nil || win.Title() == "status" {
srv.mu.RLock()
t = srv.currentNick
srv.mu.RUnlock()
} else {
t = win.Title()
}
args = append(append([]string{}, args[0], t), args[1:]...)
}
target := args[1]
modes := args[2:]
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.Mode(target, modes...)
return nil
})
}
func selectWindow(srv *Server, args []string) {
if len(args) < 2 {
logrus.Warnln("selectWindow: expected one argument")
@ -54,17 +135,20 @@ func closeWindow(srv *Server, args []string) {
var err error
ch, err = strconv.Atoi(args[1])
if err != nil {
logrus.Warnln("selectWindow: expected first argument to be an integer")
logrus.Warnln("closeWindow: expected first argument to be an integer")
return
}
}
win := srv.WindowManager.Index(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")
if ch, ok := win.(*Channel); ok {
srv.mu.RLock()
myNick := srv.currentNick
srv.mu.RUnlock()
if ch.HasUser(myNick) {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.Part(win.Title())
return nil
})
}
}
srv.WindowManager.CloseIndex(ch)
@ -75,12 +159,10 @@ func joinChannel(srv *Server, args []string) {
logrus.Warnln("joinChannel: expected one argument")
return
}
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(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) {
@ -88,12 +170,10 @@ func partChannel(srv *Server, args []string) {
logrus.Warnln("partChannel: expected one argument")
return
}
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(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) {
@ -101,12 +181,10 @@ func whoisNick(srv *Server, args []string) {
logrus.Warnln("whoisNick: expected one argument")
return
}
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(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) {
@ -136,12 +214,10 @@ func namesChannel(srv *Server, args []string) {
})
srv.events.Bind("irc.353", irc353Handler)
srv.events.Bind("irc.366", irc366Handler)
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(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) {
@ -149,12 +225,10 @@ func changeNick(srv *Server, args []string) {
logrus.Warnln("changeNick: expected one argument")
return
}
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.Nick(args[1])
return nil
}); err != nil {
logrus.Warnln("changeNick: error changing nick:", err)
}
})
}
func actionMessage(srv *Server, args []string) {
@ -163,12 +237,10 @@ func actionMessage(srv *Server, args []string) {
if window == nil || window.Title() == "status" {
return
}
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.Action(window.Title(), message)
return nil
}); err != nil {
logrus.Warnln("actionMessage: error sending message:", err)
}
})
srv.mu.Lock()
myNick := MyNick(srv.currentNick)
srv.mu.Unlock()
@ -185,12 +257,10 @@ func msgTarget(srv *Server, args []string) {
return
}
message := strings.Join(args[2:], " ")
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(func(conn *irc.Connection) error {
conn.Privmsg(target, message)
return nil
}); err != nil {
logrus.Warnln("msgTarget: error sending message:", err)
}
})
window := srv.WindowManager.Named(target)
if !strings.HasPrefix(target, "#") {
// direct message!

3
go.mod

@ -3,7 +3,7 @@ module code.dopame.me/veonik/squirssi
go 1.14
require (
code.dopame.me/veonik/squircy3 v0.0.0-20200924052855-a0f559182525
code.dopame.me/veonik/squircy3 v0.0.0-20200926012754-a6ef0aa7a541
github.com/dop251/goja v0.0.0-20200526165454-f1752421c432
github.com/gizak/termui/v3 v3.1.0
github.com/mattn/go-runewidth v0.0.9
@ -11,6 +11,7 @@ require (
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.6.0
github.com/thoj/go-ircevent v0.0.0-20190807115034-8e7ce4b5a1eb
golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect
gopkg.in/mattes/go-expand-tilde.v1 v1.0.0-20150330173918-cb884138e64c
)

4
go.sum

@ -1,5 +1,5 @@
code.dopame.me/veonik/squircy3 v0.0.0-20200924052855-a0f559182525 h1:NkCqYaQxM02/IPmh2Epl1yi3/+SBrskLDwx3e7R3XNg=
code.dopame.me/veonik/squircy3 v0.0.0-20200924052855-a0f559182525/go.mod h1:D0PZ58ANI0zuFIgCLrhmBSZQmLeKFd0vL+6YP2AamYc=
code.dopame.me/veonik/squircy3 v0.0.0-20200926012754-a6ef0aa7a541 h1:hTCz0TCr2kvSCKiUYkIxGFmsOondPRu2YF+H8rrgFdk=
code.dopame.me/veonik/squircy3 v0.0.0-20200926012754-a6ef0aa7a541/go.mod h1:D0PZ58ANI0zuFIgCLrhmBSZQmLeKFd0vL+6YP2AamYc=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

45
handlers.go

@ -30,6 +30,9 @@ func onUIDirty(srv *Server, _ *event.Event) {
// ui.KEYPRESS event is emitted. This is done to avoid extra lag between
// pressing a key and seeing the UI react.
func onUIKeyPress(srv *Server, key string) {
if key != "<Tab>" {
srv.tabber.Clear()
}
switch key {
case "<C-c>":
srv.inputTextBox.Append(string(0x03))
@ -60,18 +63,13 @@ func onUIKeyPress(srv *Server, key string) {
case "<Backspace>":
srv.inputTextBox.Backspace()
srv.RenderOnly(InputTextBox)
case "<Delete>":
srv.inputTextBox.DeleteNext()
srv.RenderOnly(InputTextBox)
case "<C-5>":
srv.mu.Lock()
srv.statusBar.FocusRight()
srv.mu.Unlock()
srv.Update()
srv.Render()
srv.WindowManager.SelectNext()
case "<Escape>":
srv.mu.Lock()
srv.statusBar.FocusLeft()
srv.mu.Unlock()
srv.Update()
srv.Render()
srv.WindowManager.SelectPrev()
case "<Up>":
win := srv.WindowManager.Active()
if win == nil {
@ -96,23 +94,22 @@ func onUIKeyPress(srv *Server, key string) {
msg := srv.HistoryManager.Next(win)
srv.inputTextBox.Set(msg)
srv.RenderOnly(InputTextBox)
case "<Left>":
srv.inputTextBox.CursorPrev()
srv.RenderOnly(InputTextBox)
case "<Right>":
srv.inputTextBox.CursorNext()
srv.RenderOnly(InputTextBox)
case "<Tab>":
win := srv.WindowManager.Active()
if ch, ok := win.(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
}
if ch, ok := win.(*Channel); ok {
var tabbed string
if srv.tabber.Active() {
tabbed = srv.tabber.Tab()
} else {
tabbed = srv.tabber.Reset(srv.inputTextBox.Peek(), ch)
}
srv.inputTextBox.Set(ModedText{Kind: srv.inputTextBox.Mode(), Text: tabbed})
}
srv.RenderOnly(InputTextBox)
case "<Enter>":

196
handlers_irc.go

@ -1,7 +1,6 @@
package squirssi
import (
"fmt"
"strings"
"sync"
"time"
@ -9,6 +8,7 @@ import (
"code.dopame.me/veonik/squircy3/event"
"code.dopame.me/veonik/squircy3/irc"
"github.com/sirupsen/logrus"
irc2 "github.com/thoj/go-ircevent"
)
func bindIRCHandlers(srv *Server, events *event.Dispatcher) {
@ -26,6 +26,11 @@ func bindIRCHandlers(srv *Server, events *event.Dispatcher) {
events.Bind("irc.353", HandleIRCEvent(srv, onIRC353))
events.Bind("irc.366", HandleIRCEvent(srv, onIRC366))
events.Bind("irc.QUIT", HandleIRCEvent(srv, onIRCQuit))
events.Bind("irc.MODE", HandleIRCEvent(srv, onIRCMode))
events.Bind("irc.324", HandleIRCEvent(srv, onIRC324))
events.Bind("irc.332", HandleIRCEvent(srv, onIRC332))
events.Bind("irc.331", HandleIRCEvent(srv, onIRC331))
events.Bind("irc.TOPIC", HandleIRCEvent(srv, onIRCTopic))
errorCodes := []string{"irc.401", "irc.403", "irc.404", "irc.405", "irc.406", "irc.407", "irc.408", "irc.421"}
for _, code := range errorCodes {
events.Bind(code, HandleIRCEvent(srv, onIRCError))
@ -34,6 +39,7 @@ func bindIRCHandlers(srv *Server, events *event.Dispatcher) {
for _, code := range whoisCodes {
events.Bind(code, HandleIRCEvent(srv, onIRCWhois))
}
events.Bind("debug.IRC", event.HandlerFunc(handleIRCDebugEvent))
}
type IRCEvent struct {
@ -48,6 +54,52 @@ type IRCEvent struct {
Args []string
}
func normalizeDebugEvent(ev *event.Event) *IRCEvent {
if ev.Data == nil {
return nil
}
v, ok := ev.Data["source"].(*irc2.Event)
if !ok {
return nil
}
return &IRCEvent{
Code: v.Code,
Raw: v.Raw,
Nick: v.Nick,
Host: v.Host,
Source: v.Source,
User: v.User,
Target: v.Arguments[0],
Message: v.Message(),
Args: v.Arguments,
}
}
var debugIgnore = map[string]struct{}{
"PRIVMSG": {},
"CTCP_ACTION": {},
"TOPIC": {},
"MODE": {},
"KICK": {},
"NICK": {},
"QUIT": {},
"JOIN": {},
"PART": {},
"366": {},
"353": {},
"324": {},
"331": {},
"332": {},
}
func handleIRCDebugEvent(ev *event.Event) {
nev := normalizeDebugEvent(ev)
if _, ok := debugIgnore[nev.Code]; ok {
return
}
logrus.Debugf("irc.%s - %s => %s", nev.Code, nev.Target, strings.Join(nev.Args[1:], " "))
}
func NormalizeIRCEvent(ev *event.Event) *IRCEvent {
if ev.Data == nil {
return nil
@ -74,16 +126,63 @@ func HandleIRCEvent(srv *Server, h IRCEventHandler) event.Handler {
})
}
func onIRC324(srv *Server, ev *IRCEvent) {
modes := strings.Join(ev.Args[2:], " ")
win := srv.WindowManager.Named(ev.Args[1])
blankBefore := false
if ch, ok := win.(*Channel); ok {
ch.mu.Lock()
if ch.modes == "" {
blankBefore = true
}
ch.modes = modes
ch.mu.Unlock()
}
if win == nil {
logrus.Warnln("received MODES for something without a window:", ev.Args[1], ev.Args[2:])
return
}
// dont write the message the first time we get modes since that is done automatically on join
if !blankBefore {
WriteModes(win, modes)
}
}
func onIRC331(srv *Server, ev *IRCEvent) {
target := ev.Args[1]
win := srv.WindowManager.Named(target)
if ch, ok := win.(*Channel); ok {
ch.mu.Lock()
ch.topic = ""
ch.mu.Unlock()
Write331(win)
}
}
func onIRC332(srv *Server, ev *IRCEvent) {
target := ev.Args[1]
win := srv.WindowManager.Named(target)
if ch, ok := win.(*Channel); ok {
topic := strings.Join(ev.Args[2:], " ")
ch.mu.Lock()
ch.topic = topic
ch.mu.Unlock()
Write332(win, topic)
}
}
func onIRCConnect(srv *Server, _ *IRCEvent) {
err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(func(conn *irc.Connection) error {
srv.mu.Lock()
defer srv.mu.Unlock()
srv.currentNick = conn.GetNick()
conn.AddCallback("*", func(ev *irc2.Event) {
srv.events.Emit("debug.IRC", map[string]interface{}{
"source": ev,
})
})
return nil
})
if err != nil {
logrus.Warnln("failed to set current nick:", err)
}
}
func onIRCDisconnect(srv *Server, _ *IRCEvent) {
@ -93,12 +192,46 @@ func onIRCDisconnect(srv *Server, _ *IRCEvent) {
srv.currentNick = ""
}
func onIRCMode(srv *Server, ev *IRCEvent) {
target := ev.Target
nick := SomeNick(ev.Nick)
mode := strings.Join(ev.Args[1:], " ")
srv.mu.RLock()
if ev.Nick == srv.currentNick {
nick.me = true
}
srv.mu.RUnlock()
win := srv.WindowManager.Named(target)
if win != nil {
WriteMode(win, nick, mode)
} else {
WriteMode(srv.WindowManager.Index(0), nick, mode)
}
}
func onIRCTopic(srv *Server, ev *IRCEvent) {
target := ev.Target
nick := SomeNick(ev.Nick)
topic := strings.Join(ev.Args[1:], " ")
srv.mu.RLock()
if ev.Nick == srv.currentNick {
nick.me = true
}
srv.mu.RUnlock()
win := srv.WindowManager.Named(target)
if win != nil {
WriteTopic(win, nick, topic)
} else {
logrus.Warnln("received topic with no channel window:", target, nick, topic)
}
}
func onIRCNick(srv *Server, ev *IRCEvent) {
nick := SomeNick(ev.Nick)
newNick := ev.Message
srv.mu.Lock()
if ev.Nick == srv.currentNick {
nick = MyNick(srv.currentNick)
nick.me = true
srv.currentNick = newNick
}
srv.mu.Unlock()
@ -110,7 +243,7 @@ func onIRCKick(srv *Server, ev *IRCEvent) {
kicked := SomeNick(ev.Args[1])
srv.mu.RLock()
if kicked.string == srv.currentNick {
kicked = MyNick(srv.currentNick)
kicked.me = true
}
srv.mu.RUnlock()
if kicked.me {
@ -129,8 +262,8 @@ func onIRCKick(srv *Server, ev *IRCEvent) {
logrus.Errorln("received kick with no Window:", channel, ev.Message, ev.Nick)
return
}
if kicked.me {
win.Notice()
if ch, ok := win.(*Channel); ok {
ch.DeleteUser(kicked.string)
}
WriteKick(win, kicked, ev.Message)
}
@ -169,28 +302,27 @@ func onIRC366(srv *Server, ev *IRCEvent) {
}
namesCache.Lock()
defer namesCache.Unlock()
ch.mu.Lock()
ch.users = namesCache.values[chanName]
ch.mu.Unlock()
ch.SetUsers(namesCache.values[chanName])
delete(namesCache.values, chanName)
srv.WindowManager.events.Emit("ui.DIRTY", nil)
}
func onIRCError(_ *Server, ev *IRCEvent) {
func onIRCError(srv *Server, ev *IRCEvent) {
var kind string
if len(ev.Args) > 1 {
kind = ev.Args[1]
} else {
kind = ev.Target
}
logrus.Errorf("%s: %s", kind, ev.Message)
win := srv.WindowManager.Named(kind)
WriteError(win, kind, ev.Message)
}
func onIRCWhois(srv *Server, ev *IRCEvent) {
kind := ev.Args[1]
nick := ev.Args[1]
data := ev.Args[2:]
if _, err := srv.WindowManager.status.Write([]byte(fmt.Sprintf("WHOIS %s => %s", kind, strings.Join(data, " ")))); err != nil {
logrus.Warnln("failed to write whois result to status:", err)
}
win := srv.WindowManager.Named(nick)
WriteWhois(win, nick, data)
}
func onIRCNames(srv *Server, ev *IRCEvent) {
@ -205,30 +337,39 @@ func onIRCNames(srv *Server, ev *IRCEvent) {
}
target := ev.Target
if strings.HasPrefix(target, "#") {
if err := srv.irc.Do(func(conn *irc.Connection) error {
srv.IRCDoAsync(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 onIRCJoin(srv *Server, ev *IRCEvent) {
target := ev.Target
win := srv.WindowManager.Named(target)
nick := SomeNick(ev.Nick)
srv.mu.RLock()
myNick := srv.currentNick
if ev.Nick == srv.currentNick {
nick.me = true
}
srv.mu.RUnlock()
if win == nil {
ch := &Channel{
bufferedWindow: newBufferedWindow(target, srv.events),
users: []string{},
users: []User{},
usersIndexed: make(map[string]int),
}
srv.WindowManager.Append(ch)
win = ch
if nick.me {
srv.WindowManager.SelectIndex(srv.WindowManager.Len() - 1)
modeChange(srv, []string{"mode"})
}
}
if ch, ok := win.(*Channel); ok {
ch.AddUser(SomeUser(nick.string))
}
WriteJoin(win, Nick{ev.Nick, ev.Nick == myNick})
WriteJoin(win, nick)
}
func onIRCPart(srv *Server, ev *IRCEvent) {
@ -237,7 +378,7 @@ func onIRCPart(srv *Server, ev *IRCEvent) {
win := srv.WindowManager.Named(target)
srv.mu.RLock()
if ev.Nick == srv.currentNick {
nick = MyNick(srv.currentNick)
nick.me = true
}
srv.mu.RUnlock()
if win == nil {
@ -247,6 +388,9 @@ func onIRCPart(srv *Server, ev *IRCEvent) {
}
return
}
if ch, ok := win.(*Channel); ok {
ch.DeleteUser(nick.string)
}
WritePart(win, nick, ev.Message)
}

136
server.go

@ -3,12 +3,12 @@ package squirssi
import (
"fmt"
"strings"
"sync"
"code.dopame.me/veonik/squircy3/event"
"code.dopame.me/veonik/squircy3/irc"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
tb "github.com/nsf/termbox-go"
"github.com/sirupsen/logrus"
@ -35,7 +35,7 @@ func (f *logFormatter) Format(entry *logrus.Entry) ([]byte, error) {
case logrus.PanicLevel:
lvl = "[PANIC](fg:white,bg:red,mod:bold)"
}
return []byte(fmt.Sprintf("%s[|](fg:grey) [%s](fg:gray100)", lvl, entry.Message)), nil
return []byte(fmt.Sprintf("%s[โ”‚](fg:grey) [%s](fg:gray100)", lvl, entry.Message)), nil
}
type HistoryManager struct {
@ -58,7 +58,6 @@ func (hm *HistoryManager) Append(win Window, input ModedText) {
hm.cursors[win] = len(hm.histories[win])
hm.append(win, input)
hm.cursors[win] = len(hm.histories[win])
logrus.Debugln("resetting cursor for", win.Title(), "now on", hm.cursors[win])
}
func (hm *HistoryManager) Insert(win Window, input ModedText) {
@ -71,7 +70,6 @@ func (hm *HistoryManager) Insert(win Window, input ModedText) {
}
func (hm *HistoryManager) append(win Window, input ModedText) {
logrus.Debugln("inserting to history for", win.Title(), input, "at index", hm.cursors[win])
hm.histories[win] = append(append(append([]ModedText{}, hm.histories[win][:hm.cursors[win]]...), input), hm.histories[win][hm.cursors[win]:]...)
}
@ -79,7 +77,6 @@ func (hm *HistoryManager) current(win Window) ModedText {
if hm.cursors[win] < 0 {
hm.cursors[win] = 0
}
logrus.Debugln("currently have %d records for %s", len(hm.histories[win]), win.Title())
if hm.cursors[win] >= len(hm.histories[win]) {
hm.cursors[win] = len(hm.histories[win])
return ModedText{}
@ -98,7 +95,6 @@ func (hm *HistoryManager) Previous(win Window) ModedText {
defer hm.mu.Unlock()
hm.cursors[win] -= 1
res := hm.current(win)
logrus.Debugln("previous history for", win.Title(), hm.cursors[win])
return res
}
@ -107,10 +103,74 @@ func (hm *HistoryManager) Next(win Window) ModedText {
defer hm.mu.Unlock()
hm.cursors[win] += 1
res := hm.current(win)
logrus.Debugln("next history for", win.Title(), hm.cursors[win])
return res
}
type Tabber struct {
active bool
input string
match string
matches []string
extra string
pos int
mu sync.Mutex
}
func NewTabber() *Tabber {
return &Tabber{}
}
func (t *Tabber) Active() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.active
}
func (t *Tabber) Clear() {
t.mu.Lock()
defer t.mu.Unlock()
t.active = false
}
func (t *Tabber) Reset(input string, channel *Channel) string {
t.mu.Lock()
defer t.mu.Unlock()
parts := strings.Split(input, " ")
t.match = parts[len(parts)-1]
t.extra = ""
if t.match == parts[0] {
t.extra = ": "
}
var m []string
for _, v := range channel.Users() {
if strings.HasPrefix(v, t.match) {
m = append(m, v+t.extra)
}
}
// put the match on the end of the stack so we can tab back to it.
m = append(m, t.match)
t.input = input
t.matches = m
t.pos = 0
t.active = true
return strings.Replace(input, t.match, t.matches[t.pos], 1)
}
func (t *Tabber) Tab() string {
t.mu.Lock()
defer t.mu.Unlock()
if !t.active {
return ""
}
t.pos++
if t.pos >= len(t.matches) {
t.pos = 0
}
return strings.Replace(t.input, t.match, t.matches[t.pos], 1)
}
// A Server handles user interaction and displaying screen elements.
type Server struct {
*logrus.Logger
@ -123,7 +183,9 @@ type Server struct {
inputTextBox *ModedTextInput
chatPane *ChatPane
userListPane *widgets.Table
userListPane *UserList
tabber *Tabber
events *event.Dispatcher
irc *irc.Manager
@ -147,6 +209,8 @@ func NewServer(ev *event.Dispatcher, irc *irc.Manager) (*Server, error) {
events: ev,
irc: irc,
tabber: NewTabber(),
done: make(chan struct{}),
}
srv.initUI()
@ -166,22 +230,34 @@ func (srv *Server) OnInterrupt(fn Interrupter) *Server {
return srv
}
func (srv *Server) IRCDoAsync(fn func(conn *irc.Connection) error) {
go func() {
err := srv.irc.Do(fn)
if err != nil {
logrus.Errorln("irc command failed:", err)
}
}()
}
func (srv *Server) initUI() {
ui.StyleParserColorMap["gray"] = colors.Grey66
ui.StyleParserColorMap["grey"] = colors.Grey66
ui.StyleParserColorMap["gray"] = colors.Grey35
ui.StyleParserColorMap["grey"] = colors.Grey35
ui.StyleParserColorMap["gray82"] = colors.Grey82
ui.StyleParserColorMap["grey82"] = colors.Grey82
ui.StyleParserColorMap["gray100"] = colors.Grey100
ui.StyleParserColorMap["grey100"] = colors.Grey100
srv.userListPane = widgets.NewTable()
srv.userListPane.Rows = [][]string{}
srv.userListPane.Border = false
srv.userListPane.BorderStyle.Fg = ui.ColorBlack
srv.userListPane.RowSeparator = false
srv.userListPane = NewUserList()
srv.userListPane.Rows = []string{}
srv.userListPane.Border = true
srv.userListPane.BorderRight = false
srv.userListPane.BorderLeft = false
srv.userListPane.BorderTop = true
srv.userListPane.BorderBottom = true
srv.userListPane.BorderStyle.Fg = colors.Grey42
srv.userListPane.Title = "Users"
srv.userListPane.TextAlignment = ui.AlignRight
srv.userListPane.PaddingRight = 1
srv.userListPane.PaddingRight = 0
srv.userListPane.TitleStyle.Fg = colors.Grey100
srv.chatPane = NewChatPane()
srv.chatPane.Rows = []string{}
@ -190,10 +266,13 @@ func (srv *Server) initUI() {
srv.chatPane.PaddingLeft = 1
srv.chatPane.PaddingRight = 1
srv.chatPane.WrapText = true
srv.chatPane.TitleStyle.Fg = colors.Grey100
srv.chatPane.SubTitleStyle.Fg = colors.White
srv.chatPane.ModeStyle.Fg = colors.Grey42
srv.statusBar = NewActivityTabPane()
srv.statusBar.ActiveTabStyle.Fg = colors.DodgerBlue1
srv.statusBar.NoticeStyle = ui.NewStyle(colors.DodgerBlue1, colors.White)
srv.statusBar.NoticeStyle = ui.NewStyle(colors.White, colors.DodgerBlue1)
srv.statusBar.ActivityStyle = ui.NewStyle(ui.ColorBlack, ui.ColorWhite)
srv.statusBar.Border = true
srv.statusBar.BorderTop = true
@ -236,26 +315,31 @@ func (srv *Server) Update() {
srv.chatPane.SelectedRow = win.CurrentLine()
srv.chatPane.Rows = win.Lines()
srv.chatPane.Title = win.Title()
if ch, ok := win.(*Channel); ok {
srv.chatPane.SubTitle = ch.Topic()
srv.chatPane.ModeText = ch.Modes()
} else {
srv.chatPane.SubTitle = ""
srv.chatPane.ModeText = ""
}
srv.chatPane.LeftPadding = 12
if srv.statusBar.ActiveTabIndex != 0 {
srv.chatPane.LeftPadding = 17
}
srv.mainWindow.Items = nil
var rows [][]string
if v, ok := win.(WindowWithUserList); ok {
for _, nick := range v.Users() {
rows = append(rows, []string{nick})
}
srv.userListPane.Rows = v.UserList()
srv.userListPane.Title = fmt.Sprintf("%d users", len(srv.userListPane.Rows))
srv.mainWindow.Set(
ui.NewCol(.9, srv.chatPane),
ui.NewCol(.1, srv.userListPane),
ui.NewCol(.85, srv.chatPane),
ui.NewCol(.15, srv.userListPane),
)
} else {
srv.mainWindow.Set(
ui.NewCol(1, srv.chatPane),
)
}
srv.userListPane.Rows = rows
}
type screenElement int
@ -302,7 +386,7 @@ func (srv *Server) resize(w, h int) {
// the actual size will be updated after rendering occurs
srv.pageHeight = h - 8
srv.pageWidth = int(float64(w)*.9) - 8
srv.statusBar.SetRect(0, srv.screenHeight-3, srv.screenWidth, srv.screenHeight)
srv.statusBar.SetRect(0, srv.screenHeight-2, 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())
}

118
text_input.go

@ -1,6 +1,7 @@
package squirssi
import (
"fmt"
"strings"
"github.com/gizak/termui/v3/widgets"
@ -14,6 +15,9 @@ const CursorFullBlock = "โ–ˆ"
type TextInput struct {
*widgets.Paragraph
input string
cursorPos int
// Character used to indicate the TextInput is focused and
// awaiting input.
cursor string
@ -33,25 +37,53 @@ func NewTextInput(cursor string) *TextInput {
return &TextInput{
Paragraph: widgets.NewParagraph(),
cursor: cursor,
cursorLen: len(cursor),
cursorLen: len("[ ](mod:reverse)"),
}
}
func (i *TextInput) curs() string {
if len(i.input) == i.cursorPos {
return i.cursor
}
return fmt.Sprintf("[%s](mod:reverse)", i.input[i.cursorPos:i.cursorPos+1])
}
func (i *TextInput) update() {
t := i.input[:i.cursorPos] + i.curs()
if len(i.input) > i.cursorPos {
t = t + i.input[i.cursorPos+1:]
}
t = strings.Replace(t, string(0x03), "[C](mod:reverse)", -1)
t = strings.Replace(t, string(0x02), "[B](mod:reverse)", -1)
t = strings.Replace(t, string(0x1F), "[U](mod:reverse)", -1)
i.Text = i.Prefix() + t
}
func (i *TextInput) CursorPrev() {
i.Lock()
defer i.Unlock()
if i.cursorPos <= 0 {
return
}
i.cursorPos--
i.update()
}
func (i *TextInput) CursorNext() {
i.Lock()
defer i.Unlock()
if i.cursorPos >= len(i.input) {
return
}
i.cursorPos++
i.update()
}
// 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()
i.Text = strings.Replace(i.Text, "[C](mod:reverse)", string(0x03), -1)
i.Text = strings.Replace(i.Text, "[B](mod:reverse)", string(0x02), -1)
i.Text = strings.Replace(i.Text, "[U](mod:reverse)", string(0x1F), -1)
t := i.Text[i.prefixLen : len(i.Text)-i.cursorLen]
i.Text = strings.Replace(i.Text, string(0x03), "[C](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x02), "[B](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x1F), "[U](mod:reverse)", -1)
return t
return i.input
}
// Consume returns and clears the current input in the TextInput.
@ -62,62 +94,53 @@ func (i *TextInput) Consume() string {
}
i.Lock()
defer i.Unlock()
t := i.Text[i.prefixLen : len(i.Text)-i.cursorLen]
t = strings.Replace(t, "[C](mod:reverse)", string(0x03), -1)
t = strings.Replace(t, "[B](mod:reverse)", string(0x02), -1)
t = strings.Replace(t, "[U](mod:reverse)", string(0x1F), -1)
return t
return i.input
}
// Len returns the length of the contents of the TextInput.
func (i *TextInput) Len() int {
i.Lock()
defer i.Unlock()
i.Text = strings.Replace(i.Text, "[C](mod:reverse)", string(0x03), -1)
i.Text = strings.Replace(i.Text, "[B](mod:reverse)", string(0x02), -1)
i.Text = strings.Replace(i.Text, "[U](mod:reverse)", string(0x1F), -1)
l := len(i.Text) - i.cursorLen - i.prefixLen
i.Text = strings.Replace(i.Text, string(0x03), "[C](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x02), "[B](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x1F), "[U](mod:reverse)", -1)
return l
return len(i.input)
}
// Reset the contents of the TextInput.
func (i *TextInput) Reset() {
i.Lock()
defer i.Unlock()
prefix := ""
if i.Prefix != nil {
prefix = i.Prefix()
}
i.prefixLen = len(prefix)
i.Text = prefix + i.cursor
i.cursorPos = 0
i.input = ""
i.update()
}
// Append adds the given string to the end of the editable content.
func (i *TextInput) Append(in string) {
i.Lock()
defer i.Unlock()
in = strings.Replace(in, string(0x03), "[C](mod:reverse)", -1)
in = strings.Replace(in, string(0x02), "[B](mod:reverse)", -1)
in = strings.Replace(in, string(0x1F), "[U](mod:reverse)", -1)
i.Text = i.Text[0:len(i.Text)-i.cursorLen] + in + i.cursor
i.input = i.input[:i.cursorPos] + in + i.input[i.cursorPos:]
i.cursorPos += len(in)
i.update()
}
// Remove the last character from the end of the editable content.
func (i *TextInput) Backspace() {
i.Lock()
defer i.Unlock()
i.Text = strings.Replace(i.Text, "[C](mod:reverse)", string(0x03), -1)
i.Text = strings.Replace(i.Text, "[B](mod:reverse)", string(0x02), -1)
i.Text = strings.Replace(i.Text, "[U](mod:reverse)", string(0x1F), -1)
if len(i.Text) > i.prefixLen+i.cursorLen {
i.Text = (i.Text)[0:len(i.Text)-i.cursorLen-1] + i.cursor
if i.cursorPos > 0 {
i.input = i.input[:i.cursorPos-1] + i.input[i.cursorPos:]
i.cursorPos--
i.update()
}
}
// Remove the last character from the end of the editable content.
func (i *TextInput) DeleteNext() {
i.Lock()
defer i.Unlock()
if i.cursorPos < len(i.input) {
i.input = i.input[:i.cursorPos] + i.input[i.cursorPos+1:]
i.update()
}
i.Text = strings.Replace(i.Text, string(0x03), "[C](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x02), "[B](mod:reverse)", -1)
i.Text = strings.Replace(i.Text, string(0x1F), "[U](mod:reverse)", -1)
}
// InputMode defines different kinds of input handled by a ModedTextInput.
@ -197,3 +220,12 @@ func (i *ModedTextInput) Consume() ModedText {
Text: txt,
}
}
func (i *ModedTextInput) Backspace() {
if i.Len() == 0 {
if i.Mode() != ModeMessage {
i.ToggleMode()
}
return
}
i.TextInput.Backspace()
}

55
widget.go

@ -14,6 +14,9 @@ import (
"code.dopame.me/veonik/squirssi/colors"
)
// A ChatPane contains the messages for the screen.
// This widget is based on the termui List widget.
// ChatPanes support both termui formatting as well as IRC formatting.
type ChatPane struct {
ui.Block
Rows []string
@ -21,6 +24,12 @@ type ChatPane struct {
TextStyle ui.Style
SelectedRow int
LeftPadding int
ModeText string
ModeStyle ui.Style
SubTitle string
SubTitleStyle ui.Style
}
func NewChatPane() *ChatPane {
@ -115,17 +124,32 @@ func WrapCellsPadded(cells []ui.Cell, width uint, leftPadding int) []ui.Cell {
wrappedCells := []ui.Cell{}
i := 0
twoLines := false
printPipe := false
loop:
for x, _rune := range wrapped {
if _rune == 'โ”‚' {
printPipe = true
}
if _rune == '\n' {
wrappedCells = append(wrappedCells, ui.Cell{_rune, ui.StyleClear})
for j := 0; j < leftPadding; j++ {
wrappedCells = append(wrappedCells, ui.Cell{' ', ui.StyleClear})
}
wrappedCells = append(wrappedCells, ui.Cell{'|', ui.NewStyle(colors.Grey62)}, ui.Cell{' ', ui.StyleClear})
v := []rune(wrapped)
if printPipe {
wrappedCells = append(wrappedCells, ui.Cell{ui.VERTICAL_LINE, ui.NewStyle(colors.Grey35)})
}
wrappedCells = append(wrappedCells, ui.Cell{' ', ui.StyleClear})
if !twoLines {
wrapped = wordwrap.WrapString(strings.ReplaceAll(string(v[x+1:]), "\n", ""), width-uint(leftPadding))
// the first time we wrap, we use the full available width, but the
// next lines are padded before they starts so that the text lines
// up on all lines with the nick and timestamp in a "gutter".
// so after wrapping the first time, recalculate the wrapping using the
// padded width. this only needs to happen once.
lPad := uint(leftPadding)
if printPipe {
lPad++
}
wrapped = wordwrap.WrapString(strings.ReplaceAll(wrapped[x+1:], "\n", " "), width-lPad)
twoLines = true
goto loop
}
@ -140,6 +164,26 @@ loop:
func (self *ChatPane) Draw(buf *ui.Buffer) {
self.Block.Draw(buf)
tcells := ui.ParseStyles(self.Title, self.TitleStyle)
if self.ModeText != "" {
tcells = append(tcells, ui.Cell{'(', self.TitleStyle})
tcells = append(tcells, ui.RunesToStyledCells([]rune(self.ModeText), self.ModeStyle)...)
tcells = append(tcells, ui.Cell{')', self.TitleStyle})
}
if self.SubTitle != "" {
tcells = append(tcells, ui.RunesToStyledCells([]rune{ui.HORIZONTAL_LINE, ui.HORIZONTAL_LINE}, ui.NewStyle(colors.Grey42))...)
tcells = append(tcells, ui.ParseStyles(self.SubTitle, self.SubTitleStyle)...)
}
pt := image.Pt(self.Min.X+2, self.Min.Y)
if self.Max.X > 0 && len(tcells) >= self.Max.X-5 {
tcells = append(tcells[:self.Max.X-5], ui.Cell{ui.ELLIPSES, self.SubTitleStyle})
}
for i := 0; i < len(tcells); i++ {
cc := tcells[i]
buf.SetCell(cc, pt)
pt.X++
}
point := self.Inner.Min
rows := make([]int, len(self.Rows))
@ -209,6 +253,11 @@ func (self *ChatPane) Draw(buf *ui.Buffer) {
}
}
// ActivityTabPane contains the tabs for available windows.
// This widget compounds a termui TabPane widget with highlighting of tabs
// in two additional ways: notice and activity. Notice is intended for when
// a tab wants extra attention (ie. user was mentioned) vs activity where
// there are just some new lines since last touched.
type ActivityTabPane struct {
*widgets.TabPane

61
widget_nicklist.go

@ -0,0 +1,61 @@
package squirssi
import (
"image"
ui "github.com/gizak/termui/v3"
)
// A UserList contains a list of users on a channel.
// This widget is based on the termui Table widget.
type UserList struct {
ui.Block
Rows []string
TextStyle ui.Style
}
func NewUserList() *UserList {
return &UserList{
Block: *ui.NewBlock(),
TextStyle: ui.Theme.Table.Text,
}
}
func (self *UserList) Draw(buf *ui.Buffer) {
self.Block.Draw(buf)
columnWidth := self.Inner.Dx()
yCoordinate := self.Inner.Min.Y
// draw rows
for i := 0; i < len(self.Rows) && yCoordinate < self.Inner.Max.Y; i++ {
row := self.Rows[i]
colXCoordinate := self.Inner.Min.X
rowStyle := self.TextStyle
col := ui.ParseStyles(row, rowStyle)
// draw row cell
if len(col) > columnWidth {
for _, cx := range ui.BuildCellWithXArray(col) {
k, cell := cx.X, cx.Cell
if k == columnWidth || colXCoordinate+k == self.Inner.Max.X {
cell.Rune = ui.ELLIPSES
buf.SetCell(cell, image.Pt(colXCoordinate+k-1, yCoordinate))
break
} else {
buf.SetCell(cell, image.Pt(colXCoordinate+k, yCoordinate))
}
}
} else {
stringXCoordinate := ui.MinInt(colXCoordinate+columnWidth, self.Inner.Max.X) - len(col)
for _, cx := range ui.BuildCellWithXArray(col) {
k, cell := cx.X, cx.Cell
buf.SetCell(cell, image.Pt(stringXCoordinate+k, yCoordinate))
}
}
yCoordinate++
}
}

164
window.go

@ -1,7 +1,11 @@
package squirssi
import (
"bytes"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
"time"
@ -11,6 +15,7 @@ import (
type Window interface {
io.Writer
io.StringWriter
// Title of the Window.
Title() string
@ -36,8 +41,10 @@ type Window interface {
type WindowWithUserList interface {
Window
Users() []string
UserList() []string
HasUser(name string) bool
UpdateUser(name, newNew string) bool
DeleteUser(name string) bool
}
type bufferedWindow struct {
@ -69,16 +76,33 @@ func (c *bufferedWindow) Title() string {
func (c *bufferedWindow) Write(p []byte) (n int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
defer c.events.Emit("ui.DIRTY", map[string]interface{}{
"name": c.name,
})
defer c.mu.Unlock()
lines := bytes.Split(p, []byte("\n"))
t := time.Now().Format("[15:04](fg:gray) ")
c.lines = append(c.lines, strings.TrimRight(t+string(p), "\n"))
const padding = " "
firstWritten := false
for _, l := range lines {
if len(l) == 0 {
continue
}
if !firstWritten {
c.lines = append(c.lines, strings.TrimRight(t+string(l), "\n"))
firstWritten = true
} else {
c.lines = append(c.lines, strings.TrimRight(padding+string(l), "\n"))
}
}
c.hasUnseen = true
return len(p), nil
}
func (c *bufferedWindow) WriteString(p string) (n int, err error) {
return c.Write([]byte(p))
}
func (c *bufferedWindow) Touch() {
c.mu.Lock()
defer c.mu.Unlock()
@ -101,7 +125,7 @@ func (c *bufferedWindow) HasNotice() bool {
func (c *bufferedWindow) HasActivity() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.hasUnseen
return c.hasUnseen && !c.hasNotice
}
func (c *bufferedWindow) AutoScroll() bool {
@ -144,27 +168,145 @@ func (c *StatusWindow) Title() string {
return "status"
}
type User struct {
string
modes string
}
func SomeUser(c string) User {
u := User{}
u.string = strings.ReplaceAll(strings.ReplaceAll(c, "@", ""), "+", "")
r := regexp.MustCompile("[^@+%]")
u.modes = r.ReplaceAllString(c, "")
return u
}
func (u User) String() string {
m := ""
if u.modes == "@" {
m = "[@](fg:cyan)"
} else if u.modes == "+" {
m = "[+](fg:yellow)"
}
return fmt.Sprintf("%s%s", m, u.string)
}
type Channel struct {
bufferedWindow
topic string
modes string
users []string
topic string
modes string
users []User
usersIndexed map[string]int
}
func (c *Channel) Topic() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.topic
}
func (c *Channel) Modes() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.modes
}
func (c *Channel) SetUsers(users []string) {
c.mu.Lock()
defer c.mu.Unlock()
c.usersIndexed = make(map[string]int)
r := make([]User, len(users))
for i, u := range users {
r[i] = SomeUser(u)
c.usersIndexed[r[i].string] = i
}