summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHubert Hirtz <hubert@hirtzfr.eu>2020-08-25 23:16:54 +0200
committerHubert Hirtz <hubert@hirtzfr.eu>2020-08-26 17:53:40 +0200
commite91396fe94643b2745e6350210d3637d9770d5b4 (patch)
tree23fdbd7a0eb9ae7d6409c0d0aed31f4512d51bc8
parentAllow /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.go407
-rw-r--r--commands.go339
-rw-r--r--doc/senpai.1.scd3
-rw-r--r--irc/events.go49
-rw-r--r--irc/states.go207
-rw-r--r--irc/tokens.go127
-rw-r--r--ui/buffers.go153
-rw-r--r--ui/editor.go46
-rw-r--r--ui/style.go8
-rw-r--r--ui/ui.go69
-rw-r--r--window.go19
11 files changed, 879 insertions, 548 deletions
diff --git a/app.go b/app.go
index 4ccf47a..417f18e 100644
--- a/app.go
+++ b/app.go
@@ -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,
diff --git a/ui/ui.go b/ui/ui.go
index 50967b0..8c8d0ec 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -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()
+}