diff options
author | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-25 23:16:54 +0200 |
---|---|---|
committer | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-26 17:53:40 +0200 |
commit | e91396fe94643b2745e6350210d3637d9770d5b4 (patch) | |
tree | 23fdbd7a0eb9ae7d6409c0d0aed31f4512d51bc8 | |
parent | Allow /part with a reason (diff) |
General refactor yay
- Centralize message formatting
- Make line formatting more flexible
- Provide more information in irc events
- Refactor command handling
- Add a /help command
- Make /me reply to last query if from home
- Enforce argument for /me
- Make BufferList and Editor public
- Batch processing of IRC events
-rw-r--r-- | app.go | 407 | ||||
-rw-r--r-- | commands.go | 339 | ||||
-rw-r--r-- | doc/senpai.1.scd | 3 | ||||
-rw-r--r-- | irc/events.go | 49 | ||||
-rw-r--r-- | irc/states.go | 207 | ||||
-rw-r--r-- | irc/tokens.go | 127 | ||||
-rw-r--r-- | ui/buffers.go | 153 | ||||
-rw-r--r-- | ui/editor.go | 46 | ||||
-rw-r--r-- | ui/style.go | 8 | ||||
-rw-r--r-- | ui/ui.go | 69 | ||||
-rw-r--r-- | window.go | 19 |
11 files changed, 879 insertions, 548 deletions
@@ -3,7 +3,7 @@ package senpai import ( "crypto/tls" "fmt" - "log" + "hash/fnv" "os/exec" "strings" "time" @@ -46,10 +46,17 @@ func NewApp(cfg Config) (app *App, err error) { } var conn *tls.Conn - app.win.AddLine(ui.Home, ui.NewLineNow("--", fmt.Sprintf("Connecting to %s...", cfg.Addr))) + app.addLineNow(ui.Home, ui.Line{ + Head: "--", + Body: fmt.Sprintf("Connecting to %s...", cfg.Addr), + }) conn, err = tls.Dial("tcp", cfg.Addr, nil) if err != nil { - app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Connection failed")) + app.addLineNow(ui.Home, ui.Line{ + Head: "!!", + HeadColor: ui.ColorRed, + Body: "Connection failed", + }) err = nil return } @@ -66,7 +73,11 @@ func NewApp(cfg Config) (app *App, err error) { Debug: cfg.Debug, }) if err != nil { - app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Registration failed")) + app.addLineNow(ui.Home, ui.Line{ + Head: "!!", + HeadColor: ui.ColorRed, + Body: "Registration failed", + }) } return @@ -84,7 +95,17 @@ func (app *App) Run() { if app.s != nil { select { case ev := <-app.s.Poll(): - app.handleIRCEvent(ev) + evs := []irc.Event{ev} + Batch: + for i := 0; i < 64; i++ { + select { + case ev := <-app.s.Poll(): + evs = append(evs, ev) + default: + break Batch + } + } + app.handleIRCEvents(evs) case ev := <-app.win.Events: app.handleUIEvent(ev) } @@ -95,99 +116,114 @@ func (app *App) Run() { } } +func (app *App) handleIRCEvents(evs []irc.Event) { + for _, ev := range evs { + app.handleIRCEvent(ev) + } + app.draw() +} + func (app *App) handleIRCEvent(ev irc.Event) { switch ev := ev.(type) { case irc.RawMessageEvent: - head := "DEBUG IN --" + head := "IN --" if ev.Outgoing { - head = "DEBUG OUT --" + head = "OUT --" } else if !ev.IsValid { - head = "DEBUG IN ??" + head = "IN ??" } - app.win.AddLine(ui.Home, ui.NewLineNow(head, ev.Message)) + app.win.AddLine(ui.Home, false, ui.Line{ + At: time.Now(), + Head: head, + Body: ev.Message, + }) case irc.RegisteredEvent: - line := "Connected to the server" + body := "Connected to the server" if app.s.Nick() != app.cfg.Nick { - line += " as " + app.s.Nick() + body += " as " + app.s.Nick() } - app.win.AddLine(ui.Home, ui.NewLineNow("--", line)) + app.win.AddLine(ui.Home, false, ui.Line{ + At: time.Now(), + Head: "--", + Body: body, + }) case irc.SelfNickEvent: - line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick) - app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, true)) + app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{ + At: ev.Time, + Head: "--", + Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()), + Highlight: true, + }) case irc.UserNickEvent: - line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick) - app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, false)) + for _, c := range app.s.ChannelsSharedWith(ev.User.Name) { + app.win.AddLine(c, false, ui.Line{ + At: ev.Time, + Head: "--", + Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User.Name), + Mergeable: true, + }) + } case irc.SelfJoinEvent: app.win.AddBuffer(ev.Channel) app.s.RequestHistory(ev.Channel, time.Now()) case irc.UserJoinEvent: - line := fmt.Sprintf("\x033+\x0314%s\x03", ev.Nick) - app.win.AddLine(ev.Channel, ui.NewLine(ev.Time, "--", line, true, false)) + app.win.AddLine(ev.Channel, false, ui.Line{ + At: time.Now(), + Head: "--", + Body: fmt.Sprintf("\x033+\x0314%s\x03", ev.User.Name), + Mergeable: true, + }) case irc.SelfPartEvent: app.win.RemoveBuffer(ev.Channel) case irc.UserPartEvent: - line := fmt.Sprintf("\x034-\x0314%s\x03", ev.Nick) - for _, channel := range ev.Channels { - app.win.AddLine(channel, ui.NewLine(ev.Time, "--", line, true, false)) + app.win.AddLine(ev.Channel, false, ui.Line{ + At: ev.Time, + Head: "--", + Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name), + Mergeable: true, + }) + case irc.UserQuitEvent: + for _, c := range ev.Channels { + app.win.AddLine(c, false, ui.Line{ + At: ev.Time, + Head: "--", + Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name), + Mergeable: true, + }) } - case irc.QueryMessageEvent: - if ev.Command == "PRIVMSG" { - isHighlight := true - head := ev.Nick - if app.s.NickCf() == strings.ToLower(ev.Nick) { - isHighlight = false - head = "\u2192 " + ev.Target - } else { - app.lastQuery = ev.Nick - } - l := ui.LineFromIRCMessage(ev.Time, head, ev.Content, false, false) - app.win.AddLine(ui.Home, l) - app.win.TypingStop(ui.Home, ev.Nick) - if isHighlight { - app.notifyHighlight(ui.Home, ev.Nick, ev.Content) - } - } else if ev.Command == "NOTICE" { - l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, true, false) - app.win.AddLine("", l) - app.win.TypingStop("", ev.Nick) - } else { - log.Panicf("received unknown command for query event: %q\n", ev.Command) + case irc.MessageEvent: + buffer, line, hlNotification := app.formatMessage(ev) + app.win.AddLine(buffer, hlNotification, line) + if hlNotification { + app.notifyHighlight(buffer, ev.User.Name, ev.Content) } - case irc.ChannelMessageEvent: - isHighlight := app.isHighlight(ev.Nick, ev.Content) - l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, ev.Command == "NOTICE", isHighlight) - app.win.AddLine(ev.Channel, l) - app.win.TypingStop(ev.Channel, ev.Nick) - if isHighlight { - app.notifyHighlight(ev.Channel, ev.Nick, ev.Content) + app.win.TypingStop(buffer, ev.User.Name) + if !ev.TargetIsChannel && app.s.NickCf() != app.s.Casemap(ev.User.Name) { + app.lastQuery = ev.User.Name } - case irc.QueryTagEvent: - if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused { - app.win.TypingStart(ui.Home, ev.Nick) - } else if ev.Typing == irc.TypingDone { - app.win.TypingStop(ui.Home, ev.Nick) + case irc.TagEvent: + buffer := ev.Target + if !ev.TargetIsChannel { + buffer = ui.Home } - case irc.ChannelTagEvent: if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused { - app.win.TypingStart(ev.Channel, ev.Nick) + app.win.TypingStart(buffer, ev.User.Name) } else if ev.Typing == irc.TypingDone { - app.win.TypingStop(ev.Channel, ev.Nick) + app.win.TypingStop(buffer, ev.User.Name) } case irc.HistoryEvent: var lines []ui.Line for _, m := range ev.Messages { switch m := m.(type) { - case irc.ChannelMessageEvent: - isHighlight := app.isHighlight(m.Nick, m.Content) - l := ui.LineFromIRCMessage(m.Time, m.Nick, m.Content, m.Command == "NOTICE", isHighlight) - lines = append(lines, l) + case irc.MessageEvent: + _, line, _ := app.formatMessage(m) + lines = append(lines, line) default: - panic("TODO") } } app.win.AddLines(ev.Target, lines) case error: - log.Panicln(ev) + panic(ev) } } @@ -258,18 +294,28 @@ func (app *App) handleUIEvent(ev tcell.Event) { case tcell.KeyCR, tcell.KeyLF: buffer := app.win.CurrentBuffer() input := app.win.InputEnter() - app.handleInput(buffer, input) + err := app.handleInput(buffer, input) + if err != nil { + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: time.Now(), + Head: "!!", + HeadColor: ui.ColorRed, + Body: fmt.Sprintf("%q: %s", input, err), + }) + } case tcell.KeyRune: app.win.InputRune(ev.Rune()) app.typing() + default: + return } + default: + return } + app.draw() } -func (app *App) isHighlight(nick, content string) bool { - if app.s.NickCf() == strings.ToLower(nick) { - return false - } +func (app *App) isHighlight(content string) bool { contentCf := strings.ToLower(content) if app.highlights == nil { return strings.Contains(contentCf, app.s.NickCf()) @@ -300,8 +346,12 @@ func (app *App) notifyHighlight(buffer, nick, content string) { command := r.Replace(app.cfg.OnHighlight) err = exec.Command(sh, "-c", command).Run() if err != nil { - line := fmt.Sprintf("Failed to invoke on-highlight command: %v", err) - app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", line)) + app.win.AddLine(ui.Home, false, ui.Line{ + At: time.Now(), + Head: "ERROR --", + HeadColor: ui.ColorRed, + Body: fmt.Sprintf("Failed to invoke on-highlight command: %v", err), + }) } } @@ -320,139 +370,6 @@ func (app *App) typing() { } } -func parseCommand(s string) (command, args string) { - if s == "" { - return - } - - if s[0] != '/' { - args = s - return - } - - i := strings.IndexByte(s, ' ') - if i < 0 { - i = len(s) - } - - command = strings.ToUpper(s[1:i]) - args = strings.TrimLeft(s[i:], " ") - - return -} - -func (app *App) handleInput(buffer, content string) { - cmd, args := parseCommand(content) - - switch cmd { - case "": - if buffer == ui.Home || len(strings.TrimSpace(args)) == 0 { - return - } - - app.s.PrivMsg(buffer, args) - if !app.s.HasCapability("echo-message") { - app.win.AddLine(buffer, ui.NewLineNow(app.s.Nick(), args)) - } - case "QUOTE": - app.s.SendRaw(args) - case "J", "JOIN": - app.s.Join(args) - case "PART": - channel := buffer - reason := args - spl := strings.SplitN(args, " ", 2) - if 0 < len(spl) && app.s.IsChannel(spl[0]) { - channel = spl[0] - if 1 < len(spl) { - reason = spl[1] - } else { - reason = "" - } - } - - if channel != ui.Home { - app.s.Part(channel, reason) - } - case "NAMES": - if buffer == ui.Home { - return - } - - var sb strings.Builder - sb.WriteString("\x0314Names: ") - for _, name := range app.s.Names(buffer) { - if name.PowerLevel != "" { - sb.WriteString("\x033") - sb.WriteString(name.PowerLevel) - sb.WriteString("\x0314") - } - sb.WriteString(name.Nick) - sb.WriteRune(' ') - } - line := sb.String() - app.win.AddLine(buffer, ui.NewLineNow("--", line[:len(line)-1])) - case "TOPIC": - if buffer == ui.Home { - return - } - - if args == "" { - var line string - - topic, who, at := app.s.Topic(buffer) - if who == "" { - line = fmt.Sprintf("\x0314Topic: %s", topic) - } else { - line = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic) - } - app.win.AddLine(buffer, ui.NewLineNow("--", line)) - } else { - app.s.SetTopic(buffer, args) - } - case "ME": - if buffer == ui.Home { - return - } - - args := fmt.Sprintf("\x01ACTION %s\x01", args) - app.s.PrivMsg(buffer, args) - if !app.s.HasCapability("echo-message") { - line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), args, false, false) - app.win.AddLine(buffer, line) - } - case "MSG": - split := strings.SplitN(args, " ", 2) - if len(split) < 2 { - return - } - - target := split[0] - content := split[1] - app.s.PrivMsg(target, content) - if !app.s.HasCapability("echo-message") { - if app.s.IsChannel(target) { - buffer = ui.Home - } else { - buffer = target - } - line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), content, false, false) - app.win.AddLine(buffer, line) - } - case "R": - if buffer != ui.Home { - return - } - - app.s.PrivMsg(app.lastQuery, args) - if !app.s.HasCapability("echo-message") { - head := "\u2192 " + app.lastQuery - line := ui.LineFromIRCMessage(time.Now(), head, args, false, false) - app.win.AddLine(ui.Home, line) - } - } -} - func (app *App) completions(cursorIdx int, text []rune) []ui.Completion { var cs []ui.Completion @@ -468,10 +385,10 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion { } start++ word := string(text[start:cursorIdx]) - wordCf := strings.ToLower(word) + wordCf := app.s.Casemap(word) for _, name := range app.s.Names(app.win.CurrentBuffer()) { - if strings.HasPrefix(strings.ToLower(name.Nick), wordCf) { - nickComp := []rune(name.Nick) + if strings.HasPrefix(app.s.Casemap(name.Name.Name), wordCf) { + nickComp := []rune(name.Name.Name) if start == 0 { nickComp = append(nickComp, ':') } @@ -499,6 +416,82 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion { return cs } +func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) { + isFromSelf := app.s.NickCf() == app.s.Casemap(ev.User.Name) + isHighlight := app.isHighlight(ev.Content) + isAction := strings.HasPrefix(ev.Content, "\x01ACTION") + isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG" + isNotice := ev.Command == "NOTICE" + + if !ev.TargetIsChannel && isNotice { + buffer = app.win.CurrentBuffer() + } else if !ev.TargetIsChannel { + buffer = ui.Home + } else { + buffer = ev.Target + } + + hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf + hlNotification = (isHighlight || isQuery) && !isFromSelf + + head := ev.User.Name + headColor := ui.ColorWhite + if isFromSelf && isQuery { + head = "\u2192 " + ev.Target + headColor = app.identColor(ev.Target) + } else if isAction || isNotice { + head = "*" + } else { + headColor = app.identColor(head) + } + + body := strings.TrimSuffix(ev.Content, "\x01") + if isNotice && isAction { + c := ircColorSequence(app.identColor(ev.User.Name)) + body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User.Name, body[7:]) + } else if isAction { + c := ircColorSequence(app.identColor(ev.User.Name)) + body = fmt.Sprintf("%s%s\x0F%s", c, ev.User.Name, body[7:]) + } else if isNotice { + c := ircColorSequence(app.identColor(ev.User.Name)) + body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User.Name, body) + } + + line = ui.Line{ + At: ev.Time, + Head: head, + Body: body, + HeadColor: headColor, + Highlight: hlLine, + } + return +} + +func ircColorSequence(code int) string { + var c [3]rune + c[0] = 0x03 + c[1] = rune(code/10) + '0' + c[2] = rune(code%10) + '0' + return string(c[:]) +} + +// see <https://modern.ircdocs.horse/formatting.html> +var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91} + +func (app *App) identColor(s string) (code int) { + h := fnv.New32() + _, _ = h.Write([]byte(s)) + + code = int(h.Sum32()) % (99 - len(identColorBlacklist)) + for _, c := range identColorBlacklist { + if c <= code { + code++ + } + } + + return +} + func cleanMessage(s string) string { var res strings.Builder var sb ui.StyleBuffer diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..72c70b6 --- /dev/null +++ b/commands.go @@ -0,0 +1,339 @@ +package senpai + +import ( + "fmt" + "strings" + "time" + + "git.sr.ht/~taiite/senpai/irc" + "git.sr.ht/~taiite/senpai/ui" +) + +type command struct { + MinArgs int + AllowHome bool + Usage string + Desc string + Handle func(app *App, buffer string, args []string) error +} + +type commandSet map[string]*command + +var commands commandSet + +func init() { + commands = commandSet{ + "": { + MinArgs: 1, + Handle: commandDo, + }, + "HELP": { + AllowHome: true, + Usage: "[command]", + Desc: "show the list of commands, or how to use the given one", + Handle: commandDoHelp, + }, + "JOIN": { + MinArgs: 1, + AllowHome: true, + Usage: "<channels> [keys]", + Desc: "join a channel", + Handle: commandDoJoin, + }, + "ME": { + AllowHome: true, + MinArgs: 1, + Usage: "<message>", + Desc: "send an action (reply to last query if sent from home)", + Handle: commandDoMe, + }, + "MSG": { + AllowHome: true, + MinArgs: 2, + Usage: "<target> <message>", + Desc: "send a message to the given target", + Handle: commandDoMsg, + }, + "NAMES": { + Desc: "show the member list of the current channel", + Handle: commandDoNames, + }, + "PART": { + AllowHome: true, + Usage: "[channel] [reason]", + Desc: "part a channel", + Handle: commandDoPart, + }, + "QUOTE": { + MinArgs: 1, + AllowHome: true, + Usage: "<raw message>", + Desc: "send raw protocol data", + Handle: commandDoQuote, + }, + "R": { + AllowHome: true, + MinArgs: 1, + Usage: "<message>", + Desc: "reply to the last query", + Handle: commandDoR, + }, + "TOPIC": { + Usage: "[topic]", + Desc: "show or set the topic of the current channel", + Handle: commandDoTopic, + }, + } +} + +func commandDo(app *App, buffer string, args []string) (err error) { + app.s.PrivMsg(buffer, args[0]) + if !app.s.HasCapability("echo-message") { + buffer, line, _ := app.formatMessage(irc.MessageEvent{ + User: &irc.Prefix{Name: app.s.Nick()}, + Target: buffer, + TargetIsChannel: true, + Command: "PRIVMSG", + Content: args[0], + Time: time.Now(), + }) + app.win.AddLine(buffer, false, line) + } + return +} + +func commandDoHelp(app *App, buffer string, args []string) (err error) { + // TODO + t := time.Now() + if len(args) == 0 { + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Head: "--", + Body: "Available commands:", + }) + for cmdName, cmd := range commands { + if cmd.Desc == "" { + continue + } + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Body: fmt.Sprintf(" \x02%s\x02 %s", cmdName, cmd.Usage), + }) + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Body: fmt.Sprintf(" %s", cmd.Desc), + }) + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + }) + } + } else { + search := strings.ToUpper(args[0]) + found := false + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Head: "--", + Body: fmt.Sprintf("Commands that match \"%s\":", search), + }) + for cmdName, cmd := range commands { + if !strings.Contains(cmdName, search) { + continue + } + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Body: fmt.Sprintf("\x02%s\x02 %s", cmdName, cmd.Usage), + }) + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Body: fmt.Sprintf(" %s", cmd.Desc), + }) + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + }) + found = true + } + if !found { + app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ + At: t, + Body: fmt.Sprintf(" no command matches %q", args[0]), + }) + } + } + return +} + +func commandDoJoin(app *App, buffer string, args []string) (err error) { + app.s.Join(args[0]) + return +} + +func commandDoMe(app *App, buffer string, args []string) (err error) { + if buffer == ui.Home { + buffer = app.lastQuery + } + content := fmt.Sprintf("\x01ACTION %s\x01", args[0]) + app.s.PrivMsg(buffer, content) + if !app.s.HasCapability("echo-message") { + buffer, line, _ := app.formatMessage(irc.MessageEvent{ + User: &irc.Prefix{Name: app.s.Nick()}, + Target: buffer, + TargetIsChannel: true, + Command: "PRIVMSG", + Content: content, + Time: time.Now(), + }) + app.win.AddLine(buffer, false, line) + } + return +} + +func commandDoMsg(app *App, buffer string, args []string) (err error) { + target := args[0] + content := args[1] + app.s.PrivMsg(target, content) + if !app.s.HasCapability("echo-message") { + buffer, line, _ := app.formatMessage(irc.MessageEvent{ + User: &irc.Prefix{Name: app.s.Nick()}, + Target: target, + TargetIsChannel: true, + Command: "PRIVMSG", + Content: content, + Time: time.Now(), + }) + app.win.AddLine(buffer, false, line) + } + return +} + +func commandDoNames(app *App, buffer string, args []string) (err error) { + var sb strings.Builder + sb.WriteString("\x0314Names: ") + for _, name := range app.s.Names(buffer) { + if name.PowerLevel != "" { + sb.WriteString("\x033") + sb.WriteString(name.PowerLevel) + sb.WriteString("\x0314") + } + sb.WriteString(name.Name.Name) + sb.WriteRune(' ') + } + body := sb.String() + app.win.AddLine(buffer, false, ui.Line{ + At: time.Now(), + Head: "--", + Body: body[:len(body)-1], + }) + return +} + +func commandDoPart(app *App, buffer string, args []string) (err error) { + channel := buffer + reason := "" + if 0 < len(args) { + if app.s.IsChannel(args[0]) { + channel = args[0] + if 1 < len(args) { + reason = args[1] + } + } else { + reason = args[0] + } + } + + if channel != ui.Home { + app.s.Part(channel, reason) + } else { + err = fmt.Errorf("cannot part home!") + } + return +} + +func commandDoQuote(app *App, buffer string, args []string) (err error) { + app.s.SendRaw(args[0]) + return +} + +func commandDoR(app *App, buffer string, args []string) (err error) { + app.s.PrivMsg(app.lastQuery, args[0]) + if !app.s.HasCapability("echo-message") { + buffer, line, _ := app.formatMessage(irc.MessageEvent{ + User: &irc.Prefix{Name: app.s.Nick()}, + Target: app.lastQuery, + TargetIsChannel: true, + Command: "PRIVMSG", + Content: args[0], + Time: time.Now(), + }) + app.win.AddLine(buffer, false, line) + } + return +} + +func commandDoTopic(app *App, buffer string, args []string) (err error) { + if len(args) == 0 { + var body string + + topic, who, at := app.s.Topic(buffer) + if who == nil { + body = fmt.Sprintf("\x0314Topic: %s", topic) + } else { + body = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic) + } + app.win.AddLine(buffer, false, ui.Line{ + At: time.Now(), + Head: "--", + Body: body, + }) + } else { + app.s.SetTopic(buffer, args[0]) + } + return +} + +func parseCommand(s string) (command, args string) { + if s == "" { + return + } + + if s[0] != '/' { + args = s + return + } + + i := strings.IndexByte(s, ' ') + if i < 0 { + i = len(s) + } + + command = strings.ToUpper(s[1:i]) + args = strings.TrimLeft(s[i:], " ") + + return +} + +func (app *App) handleInput(buffer, content string) error { + cmdName, rawArgs := parseCommand(content) + + cmd, ok := commands[cmdName] + if !ok { + return fmt.Errorf("command %q doesn't exist", cmdName) + } + + var args []string + if rawArgs == "" { + args = nil + } else if cmd.MinArgs == 0 { + args = []string{rawArgs} + } else { + args = strings.SplitN(rawArgs, " ", cmd.MinArgs) + } + + if len(args) < cmd.MinArgs { + return fmt.Errorf("usage: %s %s", cmdName, cmd.Usage) + } + if buffer == ui.Home && !cmd.AllowHome { + return fmt.Errorf("command %q cannot be executed from home", cmdName) + } + + return cmd.Handle(app, buffer, args) +} diff --git a/doc/senpai.1.scd b/doc/senpai.1.scd index 6134441..3556a95 100644 --- a/doc/senpai.1.scd +++ b/doc/senpai.1.scd @@ -100,6 +100,9 @@ be interpreted as a command: _name_ is matched case-insensitively. It can be one of the following: +*HELP* [search] + Show the list of command (or a commands that match the given search terms). + *J*, *JOIN* <channel> Join the given channel. diff --git a/irc/events.go b/irc/events.go index cd26369..62622b4 100644 --- a/irc/events.go +++ b/irc/events.go @@ -16,13 +16,12 @@ type RegisteredEvent struct{} type SelfNickEvent struct { FormerNick string - NewNick string Time time.Time } type UserNickEvent struct { + User *Prefix FormerNick string - NewNick string Time time.Time } @@ -31,7 +30,7 @@ type SelfJoinEvent struct { } type UserJoinEvent struct { - Nick string + User *Prefix Channel string Time time.Time } @@ -41,38 +40,32 @@ type SelfPartEvent struct { } type UserPartEvent struct { - Nick string - Channels []string - Time time.Time -} - -type QueryMessageEvent struct { - Nick string - Target string - Command string - Content string + User *Prefix + Channel string Time time.Time } -type ChannelMessageEvent struct { - Nick string - Channel string - Command string - Content string - Time time.Time +type UserQuitEvent struct { + User *Prefix + Channels []string + Time time.Time } -type QueryTagEvent struct { - Nick string - Typing int - Time time.Time +type MessageEvent struct { + User *Prefix + Target string + TargetIsChannel bool + Command string + Content string + Time time.Time } -type ChannelTagEvent struct { - Nick string - Channel string - Typing int - Time time.Time +type TagEvent struct { + User *Prefix + Target string + TargetIsChannel bool + Typing int + Time time.Time } type HistoryEvent struct { diff --git a/irc/states.go b/irc/states.go index 2628a9e..8e08275 100644 --- a/irc/states.go +++ b/irc/states.go @@ -106,7 +106,7 @@ type ( ) type User struct { - Nick string + Name *Prefix AwayMsg string } @@ -114,7 +114,7 @@ type Channel struct { Name string Members map[*User]string Topic string - TopicWho string + TopicWho *Prefix TopicTime time.Time Secret bool } @@ -161,13 +161,13 @@ type Session struct { func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error) { s := &Session{ conn: conn, - msgs: make(chan Message, 16), - acts: make(chan action, 16), - evts: make(chan Event, 16), + msgs: make(chan Message, 64), + acts: make(chan action, 64), + evts: make(chan Event, 64), debug: params.Debug, typingStamps: map[string]time.Time{}, nick: params.Nickname, - nickCf: strings.ToLower(params.Nickname), + nickCf: CasemapASCII(params.Nickname), user: params.Username, real: params.RealName, auth: params.Auth, @@ -191,7 +191,7 @@ func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error) for r.Scan() { line := r.Text() - msg, err := Tokenize(line) + msg, err := ParseMessage(line) if err != nil { continue } @@ -248,22 +248,43 @@ func (s *Session) IsChannel(name string) bool { return strings.IndexAny(name, "#&") == 0 // TODO compute CHANTYPES } -func (s *Session) Names(channel string) []Name { - var names []Name - if c, ok := s.channels[strings.ToLower(channel)]; ok { - names = make([]Name, 0, len(c.Members)) +func (s *Session) Casemap(name string) string { + // TODO use CASEMAPPING + return CasemapASCII(name) +} + +func (s *Session) Names(channel string) []Member { + var names []Member + if c, ok := s.channels[s.Casemap(channel)]; ok { + names = make([]Member, 0, len(c.Members)) for u, pl := range c.Members { - names = append(names, Name{ + names = append(names, Member{ PowerLevel: pl, - Nick: u.Nick, + Name: u.Name.Copy(), }) } } return names } -func (s *Session) Topic(channel string) (topic string, who string, at time.Time) { - channelCf := strings.ToLower(channel) +func (s *Session) ChannelsSharedWith(name string) []string { + var user *User + if u, ok := s.users[s.Casemap(name)]; ok { + user = u + } else { + return nil + } + var channels []string + for _, c := range s.channels { + if _, ok := c.Members[user]; ok { + channels = append(channels, c.Name) + } + } + return channels +} + +func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) { + channelCf := s.Casemap(channel) if c, ok := s.channels[channelCf]; ok { topic = c.Topic who = c.TopicWho @@ -326,7 +347,7 @@ func (s *Session) typing(act actionTyping) (err error) { return } - to := strings.ToLower(act.Channel) + to := s.Casemap(act.Channel) now := time.Now() if t, ok := s.typingStamps[to]; ok && now.Sub(t).Seconds() < 3.0 { @@ -429,7 +450,7 @@ func (s *Session) handleStart(msg Message) (err error) { } s.acct = msg.Params[2] - _, _, s.host = FullMask(msg.Params[1]) + s.host = ParsePrefix(msg.Params[1]).Host case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs: err = s.send("CAP END\r\n") if err != nil { @@ -449,7 +470,7 @@ func (s *Session) handleStart(msg Message) (err error) { ls = msg.Params[2] } - for _, c := range TokenizeCaps(ls) { + for _, c := range ParseCaps(ls) { if c.Enable { s.availableCaps[c.Name] = c.Value } else { @@ -507,9 +528,11 @@ func (s *Session) handle(msg Message) (err error) { switch msg.Command { case rplWelcome: s.nick = msg.Params[0] - s.nickCf = strings.ToLower(s.nick) + s.nickCf = s.Casemap(s.nick) s.registered = true - s.users[s.nickCf] = &User{Nick: s.nick} + s.users[s.nickCf] = &User{Name: &Prefix{ + Name: s.nick, User: s.user, Host: s.host, + }} s.evts <- RegisteredEvent{} if s.host == "" { @@ -521,7 +544,7 @@ func (s *Session) handle(msg Message) (err error) { case rplIsupport: s.updateFeatures(msg.Params[1 : len(msg.Params)-1]) case rplWhoreply: - if s.nickCf == strings.ToLower(msg.Params[5]) { + if s.nickCf == s.Casemap(msg.Params[5]) { s.host = msg.Params[3] } case "CAP": @@ -556,7 +579,7 @@ func (s *Session) handle(msg Message) (err error) { delete(s.enabledCaps, c) } case "NEW": - diff := TokenizeCaps(msg.Params[2]) + diff := ParseCaps(msg.Params[2]) for _, c := range diff { if c.Enable { @@ -587,7 +610,7 @@ func (s *Session) handle(msg Message) (err error) { return } case "DEL": - diff := TokenizeCaps(msg.Params[2]) + diff := ParseCaps(msg.Params[2]) for i := range diff { diff[i].Enable = !diff[i].Enable @@ -623,9 +646,8 @@ func (s *Session) handle(msg Message) (err error) { } } case "JOIN": - nick, _, _ := FullMask(msg.Prefix) - nickCf := strings.ToLower(nick) - channelCf := strings.ToLower(msg.Params[0]) + nickCf := s.Casemap(msg.Prefix.Name) + channelCf := s.Casemap(msg.Params[0]) if nickCf == s.nickCf { s.channels[channelCf] = Channel{ @@ -635,21 +657,20 @@ func (s *Session) handle(msg Message) (err error) { s.evts <- SelfJoinEvent{Channel: msg.Params[0]} } else if c, ok := s.channels[channelCf]; ok { if _, ok := s.users[nickCf]; !ok { - s.users[nickCf] = &User{Nick: nick} + s.users[nickCf] = &User{Name: msg.Prefix.Copy()} } c.Members[s.users[nickCf]] = "" t := msg.TimeOrNow() s.evts <- UserJoinEvent{ + User: msg.Prefix.Copy(), Channel: c.Name, - Nick: nick, Time: t, } } case "PART": - nick, _, _ := FullMask(msg.Prefix) - nickCf := strings.ToLower(nick) - channelCf := strings.ToLower(msg.Params[0]) + nickCf := s.Casemap(msg.Prefix.Name) + channelCf := s.Casemap(msg.Params[0]) if nickCf == s.nickCf { if c, ok := s.channels[channelCf]; ok { @@ -666,15 +687,14 @@ func (s *Session) handle(msg Message) (err error) { t := msg.TimeOrNow() s.evts <- UserPartEvent{ - Channels: []string{c.Name}, - Nick: nick, - Time: t, + User: msg.Prefix.Copy(), + Channel: c.Name, + Time: t, } } } case "QUIT": - nick, _, _ := FullMask(msg.Prefix) - nickCf := strings.ToLower(nick) + nickCf := s.Casemap(msg.Prefix.Name) if u, ok := s.users[nickCf]; ok { t := msg.TimeOrNow() @@ -687,24 +707,24 @@ func (s *Session) handle(msg Message) (err error) { } } - s.evts <- UserPartEvent{ + s.evts <- UserQuitEvent{ + User: msg.Prefix.Copy(), Channels: channels, - Nick: nick, Time: t, } } case rplNamreply: - channelCf := strings.ToLower(msg.Params[2]) + channelCf := s.Casemap(msg.Params[2]) if c, ok := s.channels[channelCf]; ok { c.Secret = msg.Params[1] == "@" - for _, name := range TokenizeNames(msg.Params[3], "~&@%+") { - nick := name.Nick - nickCf := strings.ToLower(nick) + // TODO compute CHANTYPES + for _, name := range ParseNameReply(msg.Params[3], "~&@%+") { + nickCf := s.Casemap(name.Name.Name) if _, ok := s.users[nickCf]; !ok { - s.users[nickCf] = &User{Nick: nick} + s.users[nickCf] = &User{Name: name.Name.Copy()} } c.Members[s.users[nickCf]] = name.PowerLevel } @@ -712,40 +732,38 @@ func (s *Session) handle(msg Message) (err error) { s.channels[channelCf] = c } case rplTopic: - channelCf := strings.ToLower(msg.Params[1]) + channelCf := s.Casemap(msg.Params[1]) if c, ok := s.channels[channelCf]; ok { c.Topic = msg.Params[2] s.channels[channelCf] = c } case rplTopicwhotime: - channelCf := strings.ToLower(msg.Params[1]) + channelCf := s.Casemap(msg.Params[1]) t, _ := strconv.ParseInt(msg.Params[3], 10, 64) if c, ok := s.channels[channelCf]; ok { - c.TopicWho, _, _ = FullMask(msg.Params[2]) + c.TopicWho = ParsePrefix(msg.Params[2]) c.TopicTime = time.Unix(t, 0) s.channels[channelCf] = c } case rplNotopic: - channelCf := strings.ToLower(msg.Params[1]) + channelCf := s.Casemap(msg.Params[1]) if c, ok := s.channels[channelCf]; ok { c.Topic = "" s.channels[channelCf] = c } case "TOPIC": - nick, _, _ := FullMask(msg.Prefix) - channelCf := strings.ToLower(msg.Params[0]) + channelCf := s.Casemap(msg.Params[0]) if c, ok := s.channels[channelCf]; ok { c.Topic = msg.Params[1] - c.TopicWho = nick + c.TopicWho = msg.Prefix.Copy() c.TopicTime = msg.TimeOrNow() s.channels[channelCf] = c } case "PRIVMSG", "NOTICE": s.evts <- s.privmsgToEvent(msg) case "TAGMSG": - nick, _, _ := FullMask(msg.Prefix) - nickCf := strings.ToLower(nick) - targetCf := strings.ToLower(msg.Params[0]) + nickCf := s.Casemap(msg.Prefix.Name) + targetCf := s.Casemap(msg.Params[0]) if nickCf == s.nickCf { // TAGMSG from self @@ -765,23 +783,17 @@ func (s *Session) handle(msg Message) (err error) { break } - t := msg.TimeOrNow() - if targetCf == s.nickCf { - // TAGMSG to self - s.evts <- QueryTagEvent{ - Nick: nick, - Typing: typing, - Time: t, - } - } else if c, ok := s.channels[targetCf]; ok { - // TAGMSG to channelCf - s.evts <- ChannelTagEvent{ - Nick: nick, - Channel: c.Name, - Typing: typing, - Time: t, - } + ev := TagEvent{ + User: msg.Prefix.Copy(), // TODO correctly casemap + Target: msg.Params[0], // TODO correctly casemap + Typing: typing, + Time: msg.TimeOrNow(), + } + if c, ok := s.channels[targetCf]; ok { + ev.Target = c.Name + ev.TargetIsChannel = true } + s.evts <- ev case "BATCH": batchStart := msg.Params[0][0] == '+' id := msg.Params[0][1:] @@ -793,35 +805,38 @@ func (s *Session) handle(msg Message) (err error) { delete(s.chBatches, id) } case "NICK": - nick, _, _ := FullMask(msg.Prefix) - nickCf := strings.ToLower(nick) + nickCf := s.Casemap(msg.Prefix.Name) newNick := msg.Params[0] - newNickCf := strings.ToLower(newNick) + newNickCf := s.Casemap(newNick) t := msg.TimeOrNow() + var u *Prefix if formerUser, ok := s.users[nickCf]; ok { - formerUser.Nick = newNick + formerUser.Name.Name = newNick delete(s.users, nickCf) s.users[newNickCf] = formerUser + u = formerUser.Name.Copy() } if nickCf == s.nickCf { s.evts <- SelfNickEvent{ FormerNick: s.nick, - NewNick: newNick, Time: t, } s.nick = newNick s.nickCf = newNickCf } else { s.evts <- UserNickEvent{ - FormerNick: nick, - NewNick: newNick, + User: u, + FormerNick: msg.Prefix.Name, Time: t, } } case "FAIL": - fmt.Println("FAIL", msg.Params) + s.evts <- RawMessageEvent{ + Message: msg.String(), + IsValid: true, + } case "PING": err = s.send("PONG :%s\r\n", msg.Params[0]) if err != nil { @@ -839,29 +854,19 @@ func (s *Session) handle(msg Message) (err error) { return } -func (s *Session) privmsgToEvent(msg Message) (ev Event) { - nick, _, _ := FullMask(msg.Prefix) - targetCf := strings.ToLower(msg.Params[0]) - t := msg.TimeOrNow() - - if !s.IsChannel(targetCf) { - // PRIVMSG to self - ev = QueryMessageEvent{ - Nick: nick, - Target: msg.Params[0], - Command: msg.Command, - Content: msg.Params[1], - Time: t, - } - } else if c, ok := s.channels[targetCf]; ok { - // PRIVMSG to channel - ev = ChannelMessageEvent{ - Nick: nick, - Channel: c.Name, - Command: msg.Command, - Content: msg.Params[1], - Time: t, - } +func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) { + targetCf := s.Casemap(msg.Params[0]) + + ev = MessageEvent{ + User: msg.Prefix.Copy(), // TODO correctly casemap + Target: msg.Params[0], // TODO correctly casemap + Command: msg.Command, + Content: msg.Params[1], + Time: msg.TimeOrNow(), + } + if c, ok := s.channels[targetCf]; ok { + ev.Target = c.Name + ev.TargetIsChannel = true } return @@ -873,7 +878,7 @@ func (s *Session) cleanUser(parted *User) { return } } - delete(s.users, strings.ToLower(parted.Nick)) + delete(s.users, s.Casemap(parted.Name.Name)) } func (s *Session) updateFeatures(features []string) { diff --git a/irc/tokens.go b/irc/tokens.go index 57fd266..cee2f5e 100644 --- a/irc/tokens.go +++ b/irc/tokens.go @@ -8,6 +8,18 @@ import ( "time" ) +func CasemapASCII(name string) string { + var sb strings.Builder + sb.Grow(len(name)) + for _, r := range name { + if 'A' <= r && r <= 'Z' { + r += 'a' - 'A' + } + sb.WriteRune(r) + } + return sb.String() +} + func word(s string) (w, rest string) { split := strings.SplitN(s, " ", 2) @@ -125,14 +137,67 @@ var ( errUnknownCommand = errors.New("unknown command") ) +type Prefix struct { + Name string + User string + Host string +} + +func ParsePrefix(s string) (p *Prefix) { + if s == "" { + return + } + + p = &Prefix{} + + spl0 := strings.Split(s, "@") + if 1 < len(spl0) { + p.Host = spl0[1] + } + + spl1 := strings.Split(spl0[0], "!") + if 1 < len(spl1) { + p.User = spl1[1] + } + + p.Name = spl1[0] + + return +} + +func (p *Prefix) Copy() *Prefix { + if p == nil { + return nil + } + res := &Prefix{} + *res = *p + return res +} + +func (p *Prefix) String() string { + if p == nil { + return "" + } + + if p.User != "" && p.Host != "" { + return p.Name + "!" + p.User + "@" + p.Host + } else if p.User != "" { + return p.Name + "!" + p.User + } else if p.Host != "" { + return p.Name + "@" + p.Host + } else { + return p.Name + } +} + type Message struct { Tags map[string]string - Prefix string + Prefix *Prefix Command string Params []string } -func Tokenize(line string) (msg Message, err error) { +func ParseMessage(line string) (msg Message, err error) { line = strings.TrimLeft(line, " ") if line == "" { err = errEmptyMessage @@ -156,7 +221,7 @@ func Tokenize(line string) (msg Message, err error) { var prefix string prefix, line = word(line) - msg.Prefix = prefix[1:] + msg.Prefix = ParsePrefix(prefix[1:]) } line = strings.TrimLeft(line, " ") @@ -211,9 +276,9 @@ func (msg *Message) String() string { sb.WriteRune(' ') } - if msg.Prefix != "" { + if msg.Prefix != nil { sb.WriteRune(':') - sb.WriteString(msg.Prefix) + sb.WriteString(msg.Prefix.String()) sb.WriteRune(' ') } @@ -245,11 +310,11 @@ func (msg *Message) IsValid() bool { case rplWhoreply: return 8 <= len(msg.Params) case "JOIN", "NICK", "PART", "TAGMSG": - return 1 <= len(msg.Params) && msg.Prefix != "" + return 1 <= len(msg.Params) && msg.Prefix != nil case "PRIVMSG", "NOTICE", "TOPIC": - return 2 <= len(msg.Params) && msg.Prefix != "" + return 2 <= len(msg.Params) && msg.Prefix != nil case "QUIT": - return msg.Prefix != "" + return msg.Prefix != nil case "CAP": return 3 <= len(msg.Params) && (msg.Params[1] == "LS" || @@ -317,33 +382,13 @@ func (msg *Message) TimeOrNow() time.Time { return time.Now() } -func FullMask(s string) (nick, user, host string) { - if s == "" { - return - } - - spl0 := strings.Split(s, "@") - if 1 < len(spl0) { - host = spl0[1] - } - - spl1 := strings.Split(spl0[0], "!") - if 1 < len(spl1) { - user = spl1[1] - } - - nick = spl1[0] - - return -} - type Cap struct { Name string Value string Enable bool } -func TokenizeCaps(caps string) (diff []Cap) { +func ParseCaps(caps string) (diff []Cap) { for _, c := range strings.Split(caps, " ") { if c == "" || c == "-" || c == "=" || c == "-=" { continue @@ -370,26 +415,22 @@ func TokenizeCaps(caps string) (diff []Cap) { return } -type Name struct { +type Member struct { PowerLevel string - Nick string - User string - Host string + Name *Prefix } -func TokenizeNames(trailing string, prefixes string) (names []Name) { - for _, name := range strings.Split(trailing, " ") { - if name == "" { +func ParseNameReply(trailing string, prefixes string) (names []Member) { + for _, word := range strings.Split(trailing, " ") { + if word == "" { continue } - var item Name - - mask := strings.TrimLeft(name, prefixes) - item.Nick, item.User, item.Host = FullMask(mask) - item.PowerLevel = name[:len(name)-len(mask)] - - names = append(names, item) + name := strings.TrimLeft(word, prefixes) + names = append(names, Member{ + PowerLevel: word[:len(word)-len(name)], + Name: ParsePrefix(name), + }) } return diff --git a/ui/buffers.go b/ui/buffers.go index c1426f9..606a7ee 100644 --- a/ui/buffers.go +++ b/ui/buffers.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "hash/fnv" "math" "strings" "time" @@ -31,55 +30,28 @@ type point struct { } type Line struct { - at time.Time - head string - body string - - isStatus bool - isHighlight bool + At time.Time + Head string + Body string + HeadColor int + Highlight bool + Mergeable bool splitPoints []point width int newLines []int } -func NewLine(at time.Time, head string, body string, isStatus bool, isHighlight bool) Line { - l := Line{ - at: at, - head: head, - body: body, - isStatus: isStatus, - isHighlight: isHighlight, - splitPoints: []point{}, - newLines: []int{}, - } - l.computeSplitPoints() - return l -} - -func NewLineNow(head, body string) Line { - return NewLine(time.Now(), head, body, false, false) -} - -func LineFromIRCMessage(at time.Time, nick string, content string, isNotice bool, isHighlight bool) Line { - if strings.HasPrefix(content, "\x01ACTION") { - c := ircColorCode(identColor(nick)) - content = fmt.Sprintf("%s%s\x0F%s", c, nick, content[7:]) - nick = "*" - } else if isNotice { - c := ircColorCode(identColor(nick)) - content = fmt.Sprintf("(%s%s\x0F: %s)", c, nick, content) - nick = "*" +func (l *Line) computeSplitPoints() { + if l.splitPoints == nil { + l.splitPoints = []point{} } - return NewLine(at, nick, content, false, isHighlight) -} -func (l *Line) computeSplitPoints() { var wb widthBuffer lastWasSplit := false l.splitPoints = l.splitPoints[:0] - for i, r := range l.body { + for i, r := range l.Body { curIsSplit := IsSplitRune(r) if i == 0 || lastWasSplit != curIsSplit { @@ -97,7 +69,7 @@ func (l *Line) computeSplitPoints() { if !lastWasSplit { l.splitPoints = append(l.splitPoints, point{ X: wb.Width(), - I: len(l.body), + I: len(l.Body), Split: true, }) } @@ -113,6 +85,9 @@ func (l *Line) NewLines(width int) []int { if l.width == width { return l.newLines } + if l.newLines == nil { + l.newLines = []int{} + } l.newLines = l.newLines[:0] l.width = width @@ -158,7 +133,7 @@ func (l *Line) NewLines(width int) []int { // the word. var wb widthBuffer h := 1 - for j, r := range l.body[sp1.I:sp2.I] { + for j, r := range l.Body[sp1.I:sp2.I] { wb.WriteRune(r) if h*width < x+wb.Width() { l.newLines = append(l.newLines, sp1.I+j) @@ -185,7 +160,7 @@ func (l *Line) NewLines(width int) []int { } } - if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.body) { + if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body) { // DROP any newline that is placed at the end of the string because we // don't care about those. l.newLines = l.newLines[:len(l.newLines)-1] @@ -229,20 +204,19 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int) continue } - if i == 0 || b.lines[i-1].at.Truncate(time.Minute) != line.at.Truncate(time.Minute) { - printTime(screen, 0, y0, st.Bold(true), line.at.Local()) + if i == 0 || b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) { + printTime(screen, 0, y0, st.Bold(true), line.At.Local()) } - head := truncate(line.head, nickColWidth, "\u2026") + head := truncate(line.Head, nickColWidth, "\u2026") x := 7 + nickColWidth - StringWidth(head) - c := identColor(line.head) - st = st.Foreground(colorFromCode(c)) - if line.isHighlight { + st = st.Foreground(colorFromCode(line.HeadColor)) + if line.Highlight { st = st.Reverse(true) } screen.SetContent(x-1, y0, ' ', nil, st) screen.SetContent(7+nickColWidth, y0, ' ', nil, st) - printString(screen, &x, y0, st.Foreground(colorFromCode(c)), head) + printString(screen, &x, y0, st, head) st = st.Reverse(false).Foreground(tcell.ColorDefault) x = x0 @@ -250,7 +224,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int) var sb StyleBuffer sb.Reset() - for i, r := range line.body { + for i, r := range line.Body { if 0 < len(nls) && i == nls[0] { x = x0 y++ @@ -280,7 +254,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int) b.isAtTop = 0 <= y0 } -type bufferList struct { +type BufferList struct { list []buffer current int @@ -289,8 +263,8 @@ type bufferList struct { nickColWidth int } -func newBufferList(width, height, nickColWidth int) bufferList { - return bufferList{ +func NewBufferList(width, height, nickColWidth int) BufferList { + return BufferList{ list: []buffer{}, width: width, height: height, @@ -298,24 +272,24 @@ func newBufferList(width, height, nickColWidth int) bufferList { } } -func (bs *bufferList) Resize(width, height int) { +func (bs *BufferList) Resize(width, height int) { bs.width = width bs.height = height } -func (bs *bufferList) Next() { +func (bs *BufferList) Next() { bs.current = (bs.current + 1) % len(bs.list) bs.list[bs.current].highlights = 0 bs.list[bs.current].unread = false } -func (bs *bufferList) Previous() { +func (bs *BufferList) Previous() { bs.current = (bs.current - 1 + len(bs.list)) % len(bs.list) bs.list[bs.current].highlights = 0 bs.list[bs.current].unread = false } -func (bs *bufferList) Add(title string) (ok bool) { +func (bs *BufferList) Add(title string) (ok bool) { lTitle := strings.ToLower(title) for _, b := range bs.list { if strings.ToLower(b.title) == lTitle { @@ -328,7 +302,7 @@ func (bs *bufferList) Add(title string) (ok bool) { return } -func (bs *bufferList) Remove(title string) (ok bool) { +func (bs *BufferList) Remove(title string) (ok bool) { lTitle := strings.ToLower(title) for i, b := range bs.list { if strings.ToLower(b.title) == lTitle { @@ -343,7 +317,7 @@ func (bs *bufferList) Remove(title string) (ok bool) { return } -func (bs *bufferList) AddLine(title string, line Line) { +func (bs *BufferList) AddLine(title string, highlight bool, line Line) { idx := bs.idx(title) if idx < 0 { return @@ -351,29 +325,30 @@ func (bs *bufferList) AddLine(title string, line Line) { b := &bs.list[idx] n := len(b.lines) - line.body = strings.TrimRight(line.body, "\t ") + line.Body = strings.TrimRight(line.Body, "\t ") - if line.isStatus && n != 0 && b.lines[n-1].isStatus { + if line.Mergeable && n != 0 && b.lines[n-1].Mergeable { l := &b.lines[n-1] - l.body += " " + line.body + l.Body += " " + line.Body l.computeSplitPoints() l.width = 0 } else { + line.computeSplitPoints() b.lines = append(b.lines, line) if idx == bs.current && 0 < b.scrollAmt { b.scrollAmt += len(line.NewLines(bs.width-9-bs.nickColWidth)) + 1 } } - if !line.isStatus && idx != bs.current { + if !line.Mergeable && idx != bs.current { b.unread = true } - if line.isHighlight && idx != bs.current { + if highlight && idx != bs.current { b.highlights++ } } -func (bs *bufferList) AddLines(title string, lines []Line) { +func (bs *BufferList) AddLines(title string, lines []Line) { idx := bs.idx(title) if idx < 0 { return @@ -383,19 +358,22 @@ func (bs *bufferList) AddLines(title string, lines []Line) { limit := len(lines) if 0 < len(b.lines) { - firstLineTime := b.lines[0].at.Unix() + firstLineTime := b.lines[0].At.Unix() for i, l := range lines { - if firstLineTime < l.at.Unix() { + if firstLineTime < l.At.Unix() { limit = i break } } } + for i := 0; i < limit; i++ { + lines[i].computeSplitPoints() + } b.lines = append(lines[:limit], b.lines...) } -func (bs *bufferList) TypingStart(title, nick string) { +func (bs *BufferList) TypingStart(title, nick string) { idx := bs.idx(title) if idx < 0 { return @@ -411,7 +389,7 @@ func (bs *bufferList) TypingStart(title, nick string) { b.typings = append(b.typings, nick) } -func (bs *bufferList) TypingStop(title, nick string) { +func (bs *BufferList) TypingStop(title, nick string) { idx := bs.idx(title) if idx < 0 { return @@ -427,19 +405,19 @@ func (bs *bufferList) TypingStop(title, nick string) { } } -func (bs *bufferList) Current() (title string) { +func (bs *BufferList) Current() (title string) { return bs.list[bs.current].title } -func (bs *bufferList) CurrentOldestTime() (t *time.Time) { +func (bs *BufferList) CurrentOldestTime() (t *time.Time) { ls := bs.list[bs.current].lines if 0 < len(ls) { - t = &ls[0].at + t = &ls[0].At } return } -func (bs *bufferList) ScrollUp() { +func (bs *BufferList) ScrollUp() { b := &bs.list[bs.current] if b.isAtTop { return @@ -447,7 +425,7 @@ func (bs *bufferList) ScrollUp() { b.scrollAmt += bs.height / 2 } -func (bs *bufferList) ScrollDown() { +func (bs *BufferList) ScrollDown() { b := &bs.list[bs.current] b.scrollAmt -= bs.height / 2 @@ -456,12 +434,12 @@ func (bs *bufferList) ScrollDown() { } } -func (bs *bufferList) IsAtTop() bool { +func (bs *BufferList) IsAtTop() bool { b := &bs.list[bs.current] return b.isAtTop } -func (bs *bufferList) idx(title string) int { +func (bs *BufferList) idx(title string) int { if title == "" { return bs.current } @@ -475,13 +453,13 @@ func (bs *bufferList) idx(title string) int { return -1 } -func (bs *bufferList) Draw(screen tcell.Screen) { +func (bs *BufferList) Draw(screen tcell.Screen) { bs.list[bs.current].DrawLines(screen, bs.width, bs.height-3, bs.nickColWidth) bs.drawStatusBar(screen, bs.height-3) bs.drawTitleList(screen, bs.height-1) } -func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) { +func (bs *BufferList) drawStatusBar(screen tcell.Screen, y int) { st := tcell.StyleDefault.Dim(true) nicks := bs.list[bs.current].typings verb := " is typing..." @@ -517,7 +495,7 @@ func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) { } } -func (bs *bufferList) drawTitleList(screen tcell.Screen, y int) { +func (bs *BufferList) drawTitleList(screen tcell.Screen, y int) { var widths []int for _, b := range bs.list { width := StringWidth(b.title) @@ -604,24 +582,7 @@ func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) { screen.SetContent(x+4, y, mn1, nil, st) } -// see <https://modern.ircdocs.horse/formatting.html> -var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91} - -func identColor(s string) (code int) { - h := fnv.New32() - _, _ = h.Write([]byte(s)) - - code = int(h.Sum32()) % (99 - len(identColorBlacklist)) - for _, c := range identColorBlacklist { - if c <= code { - code++ - } - } - - return -} - -func ircColorCode(code int) string { +func IrcColorCode(code int) string { var c [3]rune c[0] = 0x03 c[1] = rune(code/10) + '0' diff --git a/ui/editor.go b/ui/editor.go index 33cf736..1b30c53 100644 --- a/ui/editor.go +++ b/ui/editor.go @@ -9,8 +9,8 @@ type Completion struct { CursorIdx int } -// editor is the text field where the user writes messages and commands. -type editor struct { +// Editor is the text field where the user writes messages and commands. +type Editor struct { // text contains the written runes. An empty slice means no text is written. text [][]rune @@ -38,8 +38,8 @@ type editor struct { completeIdx int } -func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) editor { - return editor{ +func NewEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) Editor { + return Editor{ text: [][]rune{{}}, textWidth: []int{0}, width: width, @@ -47,7 +47,7 @@ func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Comple } } -func (e *editor) Resize(width int) { +func (e *Editor) Resize(width int) { if width < e.width { e.cursorIdx = 0 e.offsetIdx = 0 @@ -56,21 +56,21 @@ func (e *editor) Resize(width int) { e.width = width } -func (e *editor) IsCommand() bool { +func (e *Editor) IsCommand() bool { return len(e.text[e.lineIdx]) != 0 && e.text[e.lineIdx][0] == '/' } -func (e *editor) TextLen() int { +func (e *Editor) TextLen() int { return len(e.text[e.lineIdx]) } -func (e *editor) PutRune(r rune) { +func (e *Editor) PutRune(r rune) { e.putRune(r) e.Right() e.autoCache = nil } -func (e *editor) putRune(r rune) { +func (e *Editor) putRune(r rune) { e.text[e.lineIdx] = append(e.text[e.lineIdx], ' ') copy(e.text[e.lineIdx][e.cursorIdx+1:], e.text[e.lineIdx][e.cursorIdx:]) e.text[e.lineIdx][e.cursorIdx] = r @@ -83,7 +83,7 @@ func (e *editor) putRune(r rune) { } } -func (e *editor) RemRune() (ok bool) { +func (e *Editor) RemRune() (ok bool) { ok = 0 < e.cursorIdx if !ok { return @@ -94,7 +94,7 @@ func (e *editor) RemRune() (ok bool) { return } -func (e *editor) RemRuneForward() (ok bool) { +func (e *Editor) RemRuneForward() (ok bool) { ok = e.cursorIdx < len(e.text[e.lineIdx]) if !ok { return @@ -104,7 +104,7 @@ func (e *editor) RemRuneForward() (ok bool) { return } -func (e *editor) remRuneAt(idx int) { +func (e *Editor) remRuneAt(idx int) { // TODO avoid looping twice rw := e.textWidth[idx+1] - e.textWidth[idx] for i := idx + 1; i < len(e.textWidth); i++ { @@ -117,7 +117,7 @@ func (e *editor) remRuneAt(idx int) { e.text[e.lineIdx] = e.text[e.lineIdx][:len(e.text[e.lineIdx])-1] } -func (e *editor) Flush() (content string) { +func (e *Editor) Flush() (content string) { content = string(e.text[e.lineIdx]) if len(e.text[len(e.text)-1]) == 0 { e.lineIdx = len(e.text) - 1 @@ -132,12 +132,12 @@ func (e *editor) Flush() (content string) { return } -func (e *editor) Right() { +func (e *Editor) Right() { e.right() e.autoCache = nil } -func (e *editor) right() { +func (e *Editor) right() { if e.cursorIdx == len(e.text[e.lineIdx]) { return } @@ -151,7 +151,7 @@ func (e *editor) right() { } } -func (e *editor) Left() { +func (e *Editor) Left() { if e.cursorIdx == 0 { return } @@ -165,7 +165,7 @@ func (e *editor) Left() { e.autoCache = nil } -func (e *editor) Home() { +func (e *Editor) Home() { if e.cursorIdx == 0 { return } @@ -174,7 +174,7 @@ func (e *editor) Home() { e.autoCache = nil } -func (e *editor) End() { +func (e *Editor) End() { if e.cursorIdx == len(e.text[e.lineIdx]) { return } @@ -185,7 +185,7 @@ func (e *editor) End() { e.autoCache = nil } -func (e *editor) Up() { +func (e *Editor) Up() { if e.lineIdx == 0 { return } @@ -197,7 +197,7 @@ func (e *editor) Up() { e.End() } -func (e *editor) Down() { +func (e *Editor) Down() { if e.lineIdx == len(e.text)-1 { if len(e.text[e.lineIdx]) == 0 { return @@ -213,7 +213,7 @@ func (e *editor) Down() { e.End() } -func (e *editor) AutoComplete() (ok bool) { +func (e *Editor) AutoComplete() (ok bool) { if e.autoCache == nil { e.autoCache = e.autoComplete(e.cursorIdx, e.text[e.lineIdx]) if e.autoCache == nil { @@ -234,7 +234,7 @@ func (e *editor) AutoComplete() (ok bool) { return true } -func (e *editor) computeTextWidth() { +func (e *Editor) computeTextWidth() { e.textWidth = e.textWidth[:1] rw := 0 for _, r := range e.text[e.lineIdx] { @@ -243,7 +243,7 @@ func (e *editor) computeTextWidth() { } } -func (e *editor) Draw(screen tcell.Screen, y int) { +func (e *Editor) Draw(screen tcell.Screen, y int) { st := tcell.StyleDefault x := 0 diff --git a/ui/style.go b/ui/style.go index f6e8c39..8672535 100644 --- a/ui/style.go +++ b/ui/style.go @@ -169,6 +169,14 @@ func (cb *colorBuffer) WriteRune(r rune) (ok int) { return } +const ( + ColorWhite = iota + ColorBlack + ColorBlue + ColorGreen + ColorRed +) + var ansiCodes = []tcell.Color{ // Taken from <https://modern.ircdocs.horse/formatting.html> tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen, @@ -19,8 +19,8 @@ type UI struct { exit atomic.Value // bool config Config - bs bufferList - e editor + bs BufferList + e Editor } func New(config Config) (ui *UI, err error) { @@ -52,11 +52,15 @@ func New(config Config) (ui *UI, err error) { ui.exit.Store(false) hmIdx := rand.Intn(len(homeMessages)) - ui.bs = newBufferList(w, h, ui.config.NickColWidth) + ui.bs = NewBufferList(w, h, ui.config.NickColWidth) ui.bs.Add(Home) - ui.bs.AddLine("", NewLineNow("--", homeMessages[hmIdx])) + ui.bs.AddLine("", false, Line{ + At: time.Now(), + Head: "--", + Body: homeMessages[hmIdx], + }) - ui.e = newEditor(w, ui.config.AutoComplete) + ui.e = NewEditor(w, ui.config.AutoComplete) ui.Resize() @@ -85,22 +89,18 @@ func (ui *UI) CurrentBufferOldestTime() (t *time.Time) { func (ui *UI) NextBuffer() { ui.bs.Next() - ui.draw() } func (ui *UI) PreviousBuffer() { ui.bs.Previous() - ui.draw() } func (ui *UI) ScrollUp() { ui.bs.ScrollUp() - ui.draw() } func (ui *UI) ScrollDown() { ui.bs.ScrollDown() - ui.draw() } func (ui *UI) IsAtTop() bool { @@ -108,37 +108,27 @@ func (ui *UI) IsAtTop() bool { } func (ui *UI) AddBuffer(title string) { - ok := ui.bs.Add(title) - if ok { - ui.draw() - } + _ = ui.bs.Add(title) } func (ui *UI) RemoveBuffer(title string) { - ok := ui.bs.Remove(title) - if ok { - ui.draw() - } + _ = ui.bs.Remove(title) } -func (ui *UI) AddLine(buffer string, line Line) { - ui.bs.AddLine(buffer, line) - ui.draw() +func (ui *UI) AddLine(buffer string, highlight bool, line Line) { + ui.bs.AddLine(buffer, highlight, line) } func (ui *UI) AddLines(buffer string, lines []Line) { ui.bs.AddLines(buffer, lines) - ui.draw() } func (ui *UI) TypingStart(buffer, nick string) { ui.bs.TypingStart(buffer, nick) - ui.draw() } func (ui *UI) TypingStop(buffer, nick string) { ui.bs.TypingStop(buffer, nick) - ui.draw() } func (ui *UI) InputIsCommand() bool { @@ -151,77 +141,56 @@ func (ui *UI) InputLen() int { func (ui *UI) InputRune(r rune) { ui.e.PutRune(r) - ui.draw() } func (ui *UI) InputRight() { ui.e.Right() - ui.draw() } func (ui *UI) InputLeft() { ui.e.Left() - ui.draw() } func (ui *UI) InputHome() { ui.e.Home() - ui.draw() } func (ui *UI) InputEnd() { ui.e.End() - ui.draw() } func (ui *UI) InputUp() { ui.e.Up() - ui.draw() } func (ui *UI) InputDown() { ui.e.Down() - ui.draw() } func (ui *UI) InputBackspace() (ok bool) { - ok = ui.e.RemRune() - if ok { - ui.draw() - } - return + return ui.e.RemRune() } func (ui *UI) InputDelete() (ok bool) { - ok = ui.e.RemRuneForward() - if ok { - ui.draw() - } - return + return ui.e.RemRuneForward() } func (ui *UI) InputAutoComplete() (ok bool) { - ok = ui.e.AutoComplete() - if ok { - ui.draw() - } - return + return ui.e.AutoComplete() } func (ui *UI) InputEnter() (content string) { - content = ui.e.Flush() - ui.draw() - return + return ui.e.Flush() } func (ui *UI) Resize() { w, h := ui.screen.Size() ui.e.Resize(w) ui.bs.Resize(w, h) - ui.draw() + ui.Draw() } -func (ui *UI) draw() { +func (ui *UI) Draw() { _, h := ui.screen.Size() ui.e.Draw(ui.screen, h-2) ui.bs.Draw(ui.screen) diff --git a/window.go b/window.go new file mode 100644 index 0000000..77719f2 --- /dev/null +++ b/window.go @@ -0,0 +1,19 @@ +package senpai + +import ( + "time" + + "git.sr.ht/~taiite/senpai/ui" +) + +func (app *App) addLineNow(buffer string, line ui.Line) { + if line.At.IsZero() { + line.At = time.Now() + } + app.win.AddLine(buffer, false, line) + app.draw() +} + +func (app *App) draw() { + app.win.Draw() +} |