commit e91396f
Hubert Hirtz
·
2020-08-25 21:16:54 +0000 UTC
parent 7814ff1
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
M
app.go
+200,
-207
1@@ -3,7 +3,7 @@ package senpai
2 import (
3 "crypto/tls"
4 "fmt"
5- "log"
6+ "hash/fnv"
7 "os/exec"
8 "strings"
9 "time"
10@@ -46,10 +46,17 @@ func NewApp(cfg Config) (app *App, err error) {
11 }
12
13 var conn *tls.Conn
14- app.win.AddLine(ui.Home, ui.NewLineNow("--", fmt.Sprintf("Connecting to %s...", cfg.Addr)))
15+ app.addLineNow(ui.Home, ui.Line{
16+ Head: "--",
17+ Body: fmt.Sprintf("Connecting to %s...", cfg.Addr),
18+ })
19 conn, err = tls.Dial("tcp", cfg.Addr, nil)
20 if err != nil {
21- app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Connection failed"))
22+ app.addLineNow(ui.Home, ui.Line{
23+ Head: "!!",
24+ HeadColor: ui.ColorRed,
25+ Body: "Connection failed",
26+ })
27 err = nil
28 return
29 }
30@@ -66,7 +73,11 @@ func NewApp(cfg Config) (app *App, err error) {
31 Debug: cfg.Debug,
32 })
33 if err != nil {
34- app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Registration failed"))
35+ app.addLineNow(ui.Home, ui.Line{
36+ Head: "!!",
37+ HeadColor: ui.ColorRed,
38+ Body: "Registration failed",
39+ })
40 }
41
42 return
43@@ -84,7 +95,17 @@ func (app *App) Run() {
44 if app.s != nil {
45 select {
46 case ev := <-app.s.Poll():
47- app.handleIRCEvent(ev)
48+ evs := []irc.Event{ev}
49+ Batch:
50+ for i := 0; i < 64; i++ {
51+ select {
52+ case ev := <-app.s.Poll():
53+ evs = append(evs, ev)
54+ default:
55+ break Batch
56+ }
57+ }
58+ app.handleIRCEvents(evs)
59 case ev := <-app.win.Events:
60 app.handleUIEvent(ev)
61 }
62@@ -95,99 +116,114 @@ func (app *App) Run() {
63 }
64 }
65
66+func (app *App) handleIRCEvents(evs []irc.Event) {
67+ for _, ev := range evs {
68+ app.handleIRCEvent(ev)
69+ }
70+ app.draw()
71+}
72+
73 func (app *App) handleIRCEvent(ev irc.Event) {
74 switch ev := ev.(type) {
75 case irc.RawMessageEvent:
76- head := "DEBUG IN --"
77+ head := "IN --"
78 if ev.Outgoing {
79- head = "DEBUG OUT --"
80+ head = "OUT --"
81 } else if !ev.IsValid {
82- head = "DEBUG IN ??"
83+ head = "IN ??"
84 }
85- app.win.AddLine(ui.Home, ui.NewLineNow(head, ev.Message))
86+ app.win.AddLine(ui.Home, false, ui.Line{
87+ At: time.Now(),
88+ Head: head,
89+ Body: ev.Message,
90+ })
91 case irc.RegisteredEvent:
92- line := "Connected to the server"
93+ body := "Connected to the server"
94 if app.s.Nick() != app.cfg.Nick {
95- line += " as " + app.s.Nick()
96+ body += " as " + app.s.Nick()
97 }
98- app.win.AddLine(ui.Home, ui.NewLineNow("--", line))
99+ app.win.AddLine(ui.Home, false, ui.Line{
100+ At: time.Now(),
101+ Head: "--",
102+ Body: body,
103+ })
104 case irc.SelfNickEvent:
105- line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick)
106- app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, true))
107+ app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
108+ At: ev.Time,
109+ Head: "--",
110+ Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
111+ Highlight: true,
112+ })
113 case irc.UserNickEvent:
114- line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick)
115- app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, false))
116+ for _, c := range app.s.ChannelsSharedWith(ev.User.Name) {
117+ app.win.AddLine(c, false, ui.Line{
118+ At: ev.Time,
119+ Head: "--",
120+ Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User.Name),
121+ Mergeable: true,
122+ })
123+ }
124 case irc.SelfJoinEvent:
125 app.win.AddBuffer(ev.Channel)
126 app.s.RequestHistory(ev.Channel, time.Now())
127 case irc.UserJoinEvent:
128- line := fmt.Sprintf("\x033+\x0314%s\x03", ev.Nick)
129- app.win.AddLine(ev.Channel, ui.NewLine(ev.Time, "--", line, true, false))
130+ app.win.AddLine(ev.Channel, false, ui.Line{
131+ At: time.Now(),
132+ Head: "--",
133+ Body: fmt.Sprintf("\x033+\x0314%s\x03", ev.User.Name),
134+ Mergeable: true,
135+ })
136 case irc.SelfPartEvent:
137 app.win.RemoveBuffer(ev.Channel)
138 case irc.UserPartEvent:
139- line := fmt.Sprintf("\x034-\x0314%s\x03", ev.Nick)
140- for _, channel := range ev.Channels {
141- app.win.AddLine(channel, ui.NewLine(ev.Time, "--", line, true, false))
142+ app.win.AddLine(ev.Channel, false, ui.Line{
143+ At: ev.Time,
144+ Head: "--",
145+ Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
146+ Mergeable: true,
147+ })
148+ case irc.UserQuitEvent:
149+ for _, c := range ev.Channels {
150+ app.win.AddLine(c, false, ui.Line{
151+ At: ev.Time,
152+ Head: "--",
153+ Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
154+ Mergeable: true,
155+ })
156 }
157- case irc.QueryMessageEvent:
158- if ev.Command == "PRIVMSG" {
159- isHighlight := true
160- head := ev.Nick
161- if app.s.NickCf() == strings.ToLower(ev.Nick) {
162- isHighlight = false
163- head = "\u2192 " + ev.Target
164- } else {
165- app.lastQuery = ev.Nick
166- }
167- l := ui.LineFromIRCMessage(ev.Time, head, ev.Content, false, false)
168- app.win.AddLine(ui.Home, l)
169- app.win.TypingStop(ui.Home, ev.Nick)
170- if isHighlight {
171- app.notifyHighlight(ui.Home, ev.Nick, ev.Content)
172- }
173- } else if ev.Command == "NOTICE" {
174- l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, true, false)
175- app.win.AddLine("", l)
176- app.win.TypingStop("", ev.Nick)
177- } else {
178- log.Panicf("received unknown command for query event: %q\n", ev.Command)
179+ case irc.MessageEvent:
180+ buffer, line, hlNotification := app.formatMessage(ev)
181+ app.win.AddLine(buffer, hlNotification, line)
182+ if hlNotification {
183+ app.notifyHighlight(buffer, ev.User.Name, ev.Content)
184 }
185- case irc.ChannelMessageEvent:
186- isHighlight := app.isHighlight(ev.Nick, ev.Content)
187- l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, ev.Command == "NOTICE", isHighlight)
188- app.win.AddLine(ev.Channel, l)
189- app.win.TypingStop(ev.Channel, ev.Nick)
190- if isHighlight {
191- app.notifyHighlight(ev.Channel, ev.Nick, ev.Content)
192+ app.win.TypingStop(buffer, ev.User.Name)
193+ if !ev.TargetIsChannel && app.s.NickCf() != app.s.Casemap(ev.User.Name) {
194+ app.lastQuery = ev.User.Name
195 }
196- case irc.QueryTagEvent:
197- if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused {
198- app.win.TypingStart(ui.Home, ev.Nick)
199- } else if ev.Typing == irc.TypingDone {
200- app.win.TypingStop(ui.Home, ev.Nick)
201+ case irc.TagEvent:
202+ buffer := ev.Target
203+ if !ev.TargetIsChannel {
204+ buffer = ui.Home
205 }
206- case irc.ChannelTagEvent:
207 if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused {
208- app.win.TypingStart(ev.Channel, ev.Nick)
209+ app.win.TypingStart(buffer, ev.User.Name)
210 } else if ev.Typing == irc.TypingDone {
211- app.win.TypingStop(ev.Channel, ev.Nick)
212+ app.win.TypingStop(buffer, ev.User.Name)
213 }
214 case irc.HistoryEvent:
215 var lines []ui.Line
216 for _, m := range ev.Messages {
217 switch m := m.(type) {
218- case irc.ChannelMessageEvent:
219- isHighlight := app.isHighlight(m.Nick, m.Content)
220- l := ui.LineFromIRCMessage(m.Time, m.Nick, m.Content, m.Command == "NOTICE", isHighlight)
221- lines = append(lines, l)
222+ case irc.MessageEvent:
223+ _, line, _ := app.formatMessage(m)
224+ lines = append(lines, line)
225 default:
226- panic("TODO")
227 }
228 }
229 app.win.AddLines(ev.Target, lines)
230 case error:
231- log.Panicln(ev)
232+ panic(ev)
233 }
234 }
235
236@@ -258,18 +294,28 @@ func (app *App) handleUIEvent(ev tcell.Event) {
237 case tcell.KeyCR, tcell.KeyLF:
238 buffer := app.win.CurrentBuffer()
239 input := app.win.InputEnter()
240- app.handleInput(buffer, input)
241+ err := app.handleInput(buffer, input)
242+ if err != nil {
243+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
244+ At: time.Now(),
245+ Head: "!!",
246+ HeadColor: ui.ColorRed,
247+ Body: fmt.Sprintf("%q: %s", input, err),
248+ })
249+ }
250 case tcell.KeyRune:
251 app.win.InputRune(ev.Rune())
252 app.typing()
253+ default:
254+ return
255 }
256+ default:
257+ return
258 }
259+ app.draw()
260 }
261
262-func (app *App) isHighlight(nick, content string) bool {
263- if app.s.NickCf() == strings.ToLower(nick) {
264- return false
265- }
266+func (app *App) isHighlight(content string) bool {
267 contentCf := strings.ToLower(content)
268 if app.highlights == nil {
269 return strings.Contains(contentCf, app.s.NickCf())
270@@ -300,8 +346,12 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
271 command := r.Replace(app.cfg.OnHighlight)
272 err = exec.Command(sh, "-c", command).Run()
273 if err != nil {
274- line := fmt.Sprintf("Failed to invoke on-highlight command: %v", err)
275- app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", line))
276+ app.win.AddLine(ui.Home, false, ui.Line{
277+ At: time.Now(),
278+ Head: "ERROR --",
279+ HeadColor: ui.ColorRed,
280+ Body: fmt.Sprintf("Failed to invoke on-highlight command: %v", err),
281+ })
282 }
283 }
284
285@@ -320,139 +370,6 @@ func (app *App) typing() {
286 }
287 }
288
289-func parseCommand(s string) (command, args string) {
290- if s == "" {
291- return
292- }
293-
294- if s[0] != '/' {
295- args = s
296- return
297- }
298-
299- i := strings.IndexByte(s, ' ')
300- if i < 0 {
301- i = len(s)
302- }
303-
304- command = strings.ToUpper(s[1:i])
305- args = strings.TrimLeft(s[i:], " ")
306-
307- return
308-}
309-
310-func (app *App) handleInput(buffer, content string) {
311- cmd, args := parseCommand(content)
312-
313- switch cmd {
314- case "":
315- if buffer == ui.Home || len(strings.TrimSpace(args)) == 0 {
316- return
317- }
318-
319- app.s.PrivMsg(buffer, args)
320- if !app.s.HasCapability("echo-message") {
321- app.win.AddLine(buffer, ui.NewLineNow(app.s.Nick(), args))
322- }
323- case "QUOTE":
324- app.s.SendRaw(args)
325- case "J", "JOIN":
326- app.s.Join(args)
327- case "PART":
328- channel := buffer
329- reason := args
330- spl := strings.SplitN(args, " ", 2)
331- if 0 < len(spl) && app.s.IsChannel(spl[0]) {
332- channel = spl[0]
333- if 1 < len(spl) {
334- reason = spl[1]
335- } else {
336- reason = ""
337- }
338- }
339-
340- if channel != ui.Home {
341- app.s.Part(channel, reason)
342- }
343- case "NAMES":
344- if buffer == ui.Home {
345- return
346- }
347-
348- var sb strings.Builder
349- sb.WriteString("\x0314Names: ")
350- for _, name := range app.s.Names(buffer) {
351- if name.PowerLevel != "" {
352- sb.WriteString("\x033")
353- sb.WriteString(name.PowerLevel)
354- sb.WriteString("\x0314")
355- }
356- sb.WriteString(name.Nick)
357- sb.WriteRune(' ')
358- }
359- line := sb.String()
360- app.win.AddLine(buffer, ui.NewLineNow("--", line[:len(line)-1]))
361- case "TOPIC":
362- if buffer == ui.Home {
363- return
364- }
365-
366- if args == "" {
367- var line string
368-
369- topic, who, at := app.s.Topic(buffer)
370- if who == "" {
371- line = fmt.Sprintf("\x0314Topic: %s", topic)
372- } else {
373- line = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
374- }
375- app.win.AddLine(buffer, ui.NewLineNow("--", line))
376- } else {
377- app.s.SetTopic(buffer, args)
378- }
379- case "ME":
380- if buffer == ui.Home {
381- return
382- }
383-
384- args := fmt.Sprintf("\x01ACTION %s\x01", args)
385- app.s.PrivMsg(buffer, args)
386- if !app.s.HasCapability("echo-message") {
387- line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), args, false, false)
388- app.win.AddLine(buffer, line)
389- }
390- case "MSG":
391- split := strings.SplitN(args, " ", 2)
392- if len(split) < 2 {
393- return
394- }
395-
396- target := split[0]
397- content := split[1]
398- app.s.PrivMsg(target, content)
399- if !app.s.HasCapability("echo-message") {
400- if app.s.IsChannel(target) {
401- buffer = ui.Home
402- } else {
403- buffer = target
404- }
405- line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), content, false, false)
406- app.win.AddLine(buffer, line)
407- }
408- case "R":
409- if buffer != ui.Home {
410- return
411- }
412-
413- app.s.PrivMsg(app.lastQuery, args)
414- if !app.s.HasCapability("echo-message") {
415- head := "\u2192 " + app.lastQuery
416- line := ui.LineFromIRCMessage(time.Now(), head, args, false, false)
417- app.win.AddLine(ui.Home, line)
418- }
419- }
420-}
421-
422 func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
423 var cs []ui.Completion
424
425@@ -468,10 +385,10 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
426 }
427 start++
428 word := string(text[start:cursorIdx])
429- wordCf := strings.ToLower(word)
430+ wordCf := app.s.Casemap(word)
431 for _, name := range app.s.Names(app.win.CurrentBuffer()) {
432- if strings.HasPrefix(strings.ToLower(name.Nick), wordCf) {
433- nickComp := []rune(name.Nick)
434+ if strings.HasPrefix(app.s.Casemap(name.Name.Name), wordCf) {
435+ nickComp := []rune(name.Name.Name)
436 if start == 0 {
437 nickComp = append(nickComp, ':')
438 }
439@@ -499,6 +416,82 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
440 return cs
441 }
442
443+func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
444+ isFromSelf := app.s.NickCf() == app.s.Casemap(ev.User.Name)
445+ isHighlight := app.isHighlight(ev.Content)
446+ isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
447+ isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
448+ isNotice := ev.Command == "NOTICE"
449+
450+ if !ev.TargetIsChannel && isNotice {
451+ buffer = app.win.CurrentBuffer()
452+ } else if !ev.TargetIsChannel {
453+ buffer = ui.Home
454+ } else {
455+ buffer = ev.Target
456+ }
457+
458+ hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf
459+ hlNotification = (isHighlight || isQuery) && !isFromSelf
460+
461+ head := ev.User.Name
462+ headColor := ui.ColorWhite
463+ if isFromSelf && isQuery {
464+ head = "\u2192 " + ev.Target
465+ headColor = app.identColor(ev.Target)
466+ } else if isAction || isNotice {
467+ head = "*"
468+ } else {
469+ headColor = app.identColor(head)
470+ }
471+
472+ body := strings.TrimSuffix(ev.Content, "\x01")
473+ if isNotice && isAction {
474+ c := ircColorSequence(app.identColor(ev.User.Name))
475+ body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User.Name, body[7:])
476+ } else if isAction {
477+ c := ircColorSequence(app.identColor(ev.User.Name))
478+ body = fmt.Sprintf("%s%s\x0F%s", c, ev.User.Name, body[7:])
479+ } else if isNotice {
480+ c := ircColorSequence(app.identColor(ev.User.Name))
481+ body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User.Name, body)
482+ }
483+
484+ line = ui.Line{
485+ At: ev.Time,
486+ Head: head,
487+ Body: body,
488+ HeadColor: headColor,
489+ Highlight: hlLine,
490+ }
491+ return
492+}
493+
494+func ircColorSequence(code int) string {
495+ var c [3]rune
496+ c[0] = 0x03
497+ c[1] = rune(code/10) + '0'
498+ c[2] = rune(code%10) + '0'
499+ return string(c[:])
500+}
501+
502+// see <https://modern.ircdocs.horse/formatting.html>
503+var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91}
504+
505+func (app *App) identColor(s string) (code int) {
506+ h := fnv.New32()
507+ _, _ = h.Write([]byte(s))
508+
509+ code = int(h.Sum32()) % (99 - len(identColorBlacklist))
510+ for _, c := range identColorBlacklist {
511+ if c <= code {
512+ code++
513+ }
514+ }
515+
516+ return
517+}
518+
519 func cleanMessage(s string) string {
520 var res strings.Builder
521 var sb ui.StyleBuffer
+339,
-0
1@@ -0,0 +1,339 @@
2+package senpai
3+
4+import (
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "git.sr.ht/~taiite/senpai/irc"
10+ "git.sr.ht/~taiite/senpai/ui"
11+)
12+
13+type command struct {
14+ MinArgs int
15+ AllowHome bool
16+ Usage string
17+ Desc string
18+ Handle func(app *App, buffer string, args []string) error
19+}
20+
21+type commandSet map[string]*command
22+
23+var commands commandSet
24+
25+func init() {
26+ commands = commandSet{
27+ "": {
28+ MinArgs: 1,
29+ Handle: commandDo,
30+ },
31+ "HELP": {
32+ AllowHome: true,
33+ Usage: "[command]",
34+ Desc: "show the list of commands, or how to use the given one",
35+ Handle: commandDoHelp,
36+ },
37+ "JOIN": {
38+ MinArgs: 1,
39+ AllowHome: true,
40+ Usage: "<channels> [keys]",
41+ Desc: "join a channel",
42+ Handle: commandDoJoin,
43+ },
44+ "ME": {
45+ AllowHome: true,
46+ MinArgs: 1,
47+ Usage: "<message>",
48+ Desc: "send an action (reply to last query if sent from home)",
49+ Handle: commandDoMe,
50+ },
51+ "MSG": {
52+ AllowHome: true,
53+ MinArgs: 2,
54+ Usage: "<target> <message>",
55+ Desc: "send a message to the given target",
56+ Handle: commandDoMsg,
57+ },
58+ "NAMES": {
59+ Desc: "show the member list of the current channel",
60+ Handle: commandDoNames,
61+ },
62+ "PART": {
63+ AllowHome: true,
64+ Usage: "[channel] [reason]",
65+ Desc: "part a channel",
66+ Handle: commandDoPart,
67+ },
68+ "QUOTE": {
69+ MinArgs: 1,
70+ AllowHome: true,
71+ Usage: "<raw message>",
72+ Desc: "send raw protocol data",
73+ Handle: commandDoQuote,
74+ },
75+ "R": {
76+ AllowHome: true,
77+ MinArgs: 1,
78+ Usage: "<message>",
79+ Desc: "reply to the last query",
80+ Handle: commandDoR,
81+ },
82+ "TOPIC": {
83+ Usage: "[topic]",
84+ Desc: "show or set the topic of the current channel",
85+ Handle: commandDoTopic,
86+ },
87+ }
88+}
89+
90+func commandDo(app *App, buffer string, args []string) (err error) {
91+ app.s.PrivMsg(buffer, args[0])
92+ if !app.s.HasCapability("echo-message") {
93+ buffer, line, _ := app.formatMessage(irc.MessageEvent{
94+ User: &irc.Prefix{Name: app.s.Nick()},
95+ Target: buffer,
96+ TargetIsChannel: true,
97+ Command: "PRIVMSG",
98+ Content: args[0],
99+ Time: time.Now(),
100+ })
101+ app.win.AddLine(buffer, false, line)
102+ }
103+ return
104+}
105+
106+func commandDoHelp(app *App, buffer string, args []string) (err error) {
107+ // TODO
108+ t := time.Now()
109+ if len(args) == 0 {
110+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
111+ At: t,
112+ Head: "--",
113+ Body: "Available commands:",
114+ })
115+ for cmdName, cmd := range commands {
116+ if cmd.Desc == "" {
117+ continue
118+ }
119+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
120+ At: t,
121+ Body: fmt.Sprintf(" \x02%s\x02 %s", cmdName, cmd.Usage),
122+ })
123+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
124+ At: t,
125+ Body: fmt.Sprintf(" %s", cmd.Desc),
126+ })
127+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
128+ At: t,
129+ })
130+ }
131+ } else {
132+ search := strings.ToUpper(args[0])
133+ found := false
134+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
135+ At: t,
136+ Head: "--",
137+ Body: fmt.Sprintf("Commands that match \"%s\":", search),
138+ })
139+ for cmdName, cmd := range commands {
140+ if !strings.Contains(cmdName, search) {
141+ continue
142+ }
143+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
144+ At: t,
145+ Body: fmt.Sprintf("\x02%s\x02 %s", cmdName, cmd.Usage),
146+ })
147+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
148+ At: t,
149+ Body: fmt.Sprintf(" %s", cmd.Desc),
150+ })
151+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
152+ At: t,
153+ })
154+ found = true
155+ }
156+ if !found {
157+ app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
158+ At: t,
159+ Body: fmt.Sprintf(" no command matches %q", args[0]),
160+ })
161+ }
162+ }
163+ return
164+}
165+
166+func commandDoJoin(app *App, buffer string, args []string) (err error) {
167+ app.s.Join(args[0])
168+ return
169+}
170+
171+func commandDoMe(app *App, buffer string, args []string) (err error) {
172+ if buffer == ui.Home {
173+ buffer = app.lastQuery
174+ }
175+ content := fmt.Sprintf("\x01ACTION %s\x01", args[0])
176+ app.s.PrivMsg(buffer, content)
177+ if !app.s.HasCapability("echo-message") {
178+ buffer, line, _ := app.formatMessage(irc.MessageEvent{
179+ User: &irc.Prefix{Name: app.s.Nick()},
180+ Target: buffer,
181+ TargetIsChannel: true,
182+ Command: "PRIVMSG",
183+ Content: content,
184+ Time: time.Now(),
185+ })
186+ app.win.AddLine(buffer, false, line)
187+ }
188+ return
189+}
190+
191+func commandDoMsg(app *App, buffer string, args []string) (err error) {
192+ target := args[0]
193+ content := args[1]
194+ app.s.PrivMsg(target, content)
195+ if !app.s.HasCapability("echo-message") {
196+ buffer, line, _ := app.formatMessage(irc.MessageEvent{
197+ User: &irc.Prefix{Name: app.s.Nick()},
198+ Target: target,
199+ TargetIsChannel: true,
200+ Command: "PRIVMSG",
201+ Content: content,
202+ Time: time.Now(),
203+ })
204+ app.win.AddLine(buffer, false, line)
205+ }
206+ return
207+}
208+
209+func commandDoNames(app *App, buffer string, args []string) (err error) {
210+ var sb strings.Builder
211+ sb.WriteString("\x0314Names: ")
212+ for _, name := range app.s.Names(buffer) {
213+ if name.PowerLevel != "" {
214+ sb.WriteString("\x033")
215+ sb.WriteString(name.PowerLevel)
216+ sb.WriteString("\x0314")
217+ }
218+ sb.WriteString(name.Name.Name)
219+ sb.WriteRune(' ')
220+ }
221+ body := sb.String()
222+ app.win.AddLine(buffer, false, ui.Line{
223+ At: time.Now(),
224+ Head: "--",
225+ Body: body[:len(body)-1],
226+ })
227+ return
228+}
229+
230+func commandDoPart(app *App, buffer string, args []string) (err error) {
231+ channel := buffer
232+ reason := ""
233+ if 0 < len(args) {
234+ if app.s.IsChannel(args[0]) {
235+ channel = args[0]
236+ if 1 < len(args) {
237+ reason = args[1]
238+ }
239+ } else {
240+ reason = args[0]
241+ }
242+ }
243+
244+ if channel != ui.Home {
245+ app.s.Part(channel, reason)
246+ } else {
247+ err = fmt.Errorf("cannot part home!")
248+ }
249+ return
250+}
251+
252+func commandDoQuote(app *App, buffer string, args []string) (err error) {
253+ app.s.SendRaw(args[0])
254+ return
255+}
256+
257+func commandDoR(app *App, buffer string, args []string) (err error) {
258+ app.s.PrivMsg(app.lastQuery, args[0])
259+ if !app.s.HasCapability("echo-message") {
260+ buffer, line, _ := app.formatMessage(irc.MessageEvent{
261+ User: &irc.Prefix{Name: app.s.Nick()},
262+ Target: app.lastQuery,
263+ TargetIsChannel: true,
264+ Command: "PRIVMSG",
265+ Content: args[0],
266+ Time: time.Now(),
267+ })
268+ app.win.AddLine(buffer, false, line)
269+ }
270+ return
271+}
272+
273+func commandDoTopic(app *App, buffer string, args []string) (err error) {
274+ if len(args) == 0 {
275+ var body string
276+
277+ topic, who, at := app.s.Topic(buffer)
278+ if who == nil {
279+ body = fmt.Sprintf("\x0314Topic: %s", topic)
280+ } else {
281+ body = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
282+ }
283+ app.win.AddLine(buffer, false, ui.Line{
284+ At: time.Now(),
285+ Head: "--",
286+ Body: body,
287+ })
288+ } else {
289+ app.s.SetTopic(buffer, args[0])
290+ }
291+ return
292+}
293+
294+func parseCommand(s string) (command, args string) {
295+ if s == "" {
296+ return
297+ }
298+
299+ if s[0] != '/' {
300+ args = s
301+ return
302+ }
303+
304+ i := strings.IndexByte(s, ' ')
305+ if i < 0 {
306+ i = len(s)
307+ }
308+
309+ command = strings.ToUpper(s[1:i])
310+ args = strings.TrimLeft(s[i:], " ")
311+
312+ return
313+}
314+
315+func (app *App) handleInput(buffer, content string) error {
316+ cmdName, rawArgs := parseCommand(content)
317+
318+ cmd, ok := commands[cmdName]
319+ if !ok {
320+ return fmt.Errorf("command %q doesn't exist", cmdName)
321+ }
322+
323+ var args []string
324+ if rawArgs == "" {
325+ args = nil
326+ } else if cmd.MinArgs == 0 {
327+ args = []string{rawArgs}
328+ } else {
329+ args = strings.SplitN(rawArgs, " ", cmd.MinArgs)
330+ }
331+
332+ if len(args) < cmd.MinArgs {
333+ return fmt.Errorf("usage: %s %s", cmdName, cmd.Usage)
334+ }
335+ if buffer == ui.Home && !cmd.AllowHome {
336+ return fmt.Errorf("command %q cannot be executed from home", cmdName)
337+ }
338+
339+ return cmd.Handle(app, buffer, args)
340+}
+3,
-0
1@@ -100,6 +100,9 @@ be interpreted as a command:
2
3 _name_ is matched case-insensitively. It can be one of the following:
4
5+*HELP* [search]
6+ Show the list of command (or a commands that match the given search terms).
7+
8 *J*, *JOIN* <channel>
9 Join the given channel.
10
+21,
-28
1@@ -16,13 +16,12 @@ type RegisteredEvent struct{}
2
3 type SelfNickEvent struct {
4 FormerNick string
5- NewNick string
6 Time time.Time
7 }
8
9 type UserNickEvent struct {
10+ User *Prefix
11 FormerNick string
12- NewNick string
13 Time time.Time
14 }
15
16@@ -31,7 +30,7 @@ type SelfJoinEvent struct {
17 }
18
19 type UserJoinEvent struct {
20- Nick string
21+ User *Prefix
22 Channel string
23 Time time.Time
24 }
25@@ -41,38 +40,32 @@ type SelfPartEvent struct {
26 }
27
28 type UserPartEvent struct {
29- Nick string
30- Channels []string
31- Time time.Time
32-}
33-
34-type QueryMessageEvent struct {
35- Nick string
36- Target string
37- Command string
38- Content string
39+ User *Prefix
40+ Channel string
41 Time time.Time
42 }
43
44-type ChannelMessageEvent struct {
45- Nick string
46- Channel string
47- Command string
48- Content string
49- Time time.Time
50+type UserQuitEvent struct {
51+ User *Prefix
52+ Channels []string
53+ Time time.Time
54 }
55
56-type QueryTagEvent struct {
57- Nick string
58- Typing int
59- Time time.Time
60+type MessageEvent struct {
61+ User *Prefix
62+ Target string
63+ TargetIsChannel bool
64+ Command string
65+ Content string
66+ Time time.Time
67 }
68
69-type ChannelTagEvent struct {
70- Nick string
71- Channel string
72- Typing int
73- Time time.Time
74+type TagEvent struct {
75+ User *Prefix
76+ Target string
77+ TargetIsChannel bool
78+ Typing int
79+ Time time.Time
80 }
81
82 type HistoryEvent struct {
+106,
-101
1@@ -106,7 +106,7 @@ type (
2 )
3
4 type User struct {
5- Nick string
6+ Name *Prefix
7 AwayMsg string
8 }
9
10@@ -114,7 +114,7 @@ type Channel struct {
11 Name string
12 Members map[*User]string
13 Topic string
14- TopicWho string
15+ TopicWho *Prefix
16 TopicTime time.Time
17 Secret bool
18 }
19@@ -161,13 +161,13 @@ type Session struct {
20 func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error) {
21 s := &Session{
22 conn: conn,
23- msgs: make(chan Message, 16),
24- acts: make(chan action, 16),
25- evts: make(chan Event, 16),
26+ msgs: make(chan Message, 64),
27+ acts: make(chan action, 64),
28+ evts: make(chan Event, 64),
29 debug: params.Debug,
30 typingStamps: map[string]time.Time{},
31 nick: params.Nickname,
32- nickCf: strings.ToLower(params.Nickname),
33+ nickCf: CasemapASCII(params.Nickname),
34 user: params.Username,
35 real: params.RealName,
36 auth: params.Auth,
37@@ -191,7 +191,7 @@ func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error)
38
39 for r.Scan() {
40 line := r.Text()
41- msg, err := Tokenize(line)
42+ msg, err := ParseMessage(line)
43 if err != nil {
44 continue
45 }
46@@ -248,22 +248,43 @@ func (s *Session) IsChannel(name string) bool {
47 return strings.IndexAny(name, "#&") == 0 // TODO compute CHANTYPES
48 }
49
50-func (s *Session) Names(channel string) []Name {
51- var names []Name
52- if c, ok := s.channels[strings.ToLower(channel)]; ok {
53- names = make([]Name, 0, len(c.Members))
54+func (s *Session) Casemap(name string) string {
55+ // TODO use CASEMAPPING
56+ return CasemapASCII(name)
57+}
58+
59+func (s *Session) Names(channel string) []Member {
60+ var names []Member
61+ if c, ok := s.channels[s.Casemap(channel)]; ok {
62+ names = make([]Member, 0, len(c.Members))
63 for u, pl := range c.Members {
64- names = append(names, Name{
65+ names = append(names, Member{
66 PowerLevel: pl,
67- Nick: u.Nick,
68+ Name: u.Name.Copy(),
69 })
70 }
71 }
72 return names
73 }
74
75-func (s *Session) Topic(channel string) (topic string, who string, at time.Time) {
76- channelCf := strings.ToLower(channel)
77+func (s *Session) ChannelsSharedWith(name string) []string {
78+ var user *User
79+ if u, ok := s.users[s.Casemap(name)]; ok {
80+ user = u
81+ } else {
82+ return nil
83+ }
84+ var channels []string
85+ for _, c := range s.channels {
86+ if _, ok := c.Members[user]; ok {
87+ channels = append(channels, c.Name)
88+ }
89+ }
90+ return channels
91+}
92+
93+func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) {
94+ channelCf := s.Casemap(channel)
95 if c, ok := s.channels[channelCf]; ok {
96 topic = c.Topic
97 who = c.TopicWho
98@@ -326,7 +347,7 @@ func (s *Session) typing(act actionTyping) (err error) {
99 return
100 }
101
102- to := strings.ToLower(act.Channel)
103+ to := s.Casemap(act.Channel)
104 now := time.Now()
105
106 if t, ok := s.typingStamps[to]; ok && now.Sub(t).Seconds() < 3.0 {
107@@ -429,7 +450,7 @@ func (s *Session) handleStart(msg Message) (err error) {
108 }
109
110 s.acct = msg.Params[2]
111- _, _, s.host = FullMask(msg.Params[1])
112+ s.host = ParsePrefix(msg.Params[1]).Host
113 case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
114 err = s.send("CAP END\r\n")
115 if err != nil {
116@@ -449,7 +470,7 @@ func (s *Session) handleStart(msg Message) (err error) {
117 ls = msg.Params[2]
118 }
119
120- for _, c := range TokenizeCaps(ls) {
121+ for _, c := range ParseCaps(ls) {
122 if c.Enable {
123 s.availableCaps[c.Name] = c.Value
124 } else {
125@@ -507,9 +528,11 @@ func (s *Session) handle(msg Message) (err error) {
126 switch msg.Command {
127 case rplWelcome:
128 s.nick = msg.Params[0]
129- s.nickCf = strings.ToLower(s.nick)
130+ s.nickCf = s.Casemap(s.nick)
131 s.registered = true
132- s.users[s.nickCf] = &User{Nick: s.nick}
133+ s.users[s.nickCf] = &User{Name: &Prefix{
134+ Name: s.nick, User: s.user, Host: s.host,
135+ }}
136 s.evts <- RegisteredEvent{}
137
138 if s.host == "" {
139@@ -521,7 +544,7 @@ func (s *Session) handle(msg Message) (err error) {
140 case rplIsupport:
141 s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
142 case rplWhoreply:
143- if s.nickCf == strings.ToLower(msg.Params[5]) {
144+ if s.nickCf == s.Casemap(msg.Params[5]) {
145 s.host = msg.Params[3]
146 }
147 case "CAP":
148@@ -556,7 +579,7 @@ func (s *Session) handle(msg Message) (err error) {
149 delete(s.enabledCaps, c)
150 }
151 case "NEW":
152- diff := TokenizeCaps(msg.Params[2])
153+ diff := ParseCaps(msg.Params[2])
154
155 for _, c := range diff {
156 if c.Enable {
157@@ -587,7 +610,7 @@ func (s *Session) handle(msg Message) (err error) {
158 return
159 }
160 case "DEL":
161- diff := TokenizeCaps(msg.Params[2])
162+ diff := ParseCaps(msg.Params[2])
163
164 for i := range diff {
165 diff[i].Enable = !diff[i].Enable
166@@ -623,9 +646,8 @@ func (s *Session) handle(msg Message) (err error) {
167 }
168 }
169 case "JOIN":
170- nick, _, _ := FullMask(msg.Prefix)
171- nickCf := strings.ToLower(nick)
172- channelCf := strings.ToLower(msg.Params[0])
173+ nickCf := s.Casemap(msg.Prefix.Name)
174+ channelCf := s.Casemap(msg.Params[0])
175
176 if nickCf == s.nickCf {
177 s.channels[channelCf] = Channel{
178@@ -635,21 +657,20 @@ func (s *Session) handle(msg Message) (err error) {
179 s.evts <- SelfJoinEvent{Channel: msg.Params[0]}
180 } else if c, ok := s.channels[channelCf]; ok {
181 if _, ok := s.users[nickCf]; !ok {
182- s.users[nickCf] = &User{Nick: nick}
183+ s.users[nickCf] = &User{Name: msg.Prefix.Copy()}
184 }
185 c.Members[s.users[nickCf]] = ""
186 t := msg.TimeOrNow()
187
188 s.evts <- UserJoinEvent{
189+ User: msg.Prefix.Copy(),
190 Channel: c.Name,
191- Nick: nick,
192 Time: t,
193 }
194 }
195 case "PART":
196- nick, _, _ := FullMask(msg.Prefix)
197- nickCf := strings.ToLower(nick)
198- channelCf := strings.ToLower(msg.Params[0])
199+ nickCf := s.Casemap(msg.Prefix.Name)
200+ channelCf := s.Casemap(msg.Params[0])
201
202 if nickCf == s.nickCf {
203 if c, ok := s.channels[channelCf]; ok {
204@@ -666,15 +687,14 @@ func (s *Session) handle(msg Message) (err error) {
205 t := msg.TimeOrNow()
206
207 s.evts <- UserPartEvent{
208- Channels: []string{c.Name},
209- Nick: nick,
210- Time: t,
211+ User: msg.Prefix.Copy(),
212+ Channel: c.Name,
213+ Time: t,
214 }
215 }
216 }
217 case "QUIT":
218- nick, _, _ := FullMask(msg.Prefix)
219- nickCf := strings.ToLower(nick)
220+ nickCf := s.Casemap(msg.Prefix.Name)
221
222 if u, ok := s.users[nickCf]; ok {
223 t := msg.TimeOrNow()
224@@ -687,24 +707,24 @@ func (s *Session) handle(msg Message) (err error) {
225 }
226 }
227
228- s.evts <- UserPartEvent{
229+ s.evts <- UserQuitEvent{
230+ User: msg.Prefix.Copy(),
231 Channels: channels,
232- Nick: nick,
233 Time: t,
234 }
235 }
236 case rplNamreply:
237- channelCf := strings.ToLower(msg.Params[2])
238+ channelCf := s.Casemap(msg.Params[2])
239
240 if c, ok := s.channels[channelCf]; ok {
241 c.Secret = msg.Params[1] == "@"
242
243- for _, name := range TokenizeNames(msg.Params[3], "~&@%+") {
244- nick := name.Nick
245- nickCf := strings.ToLower(nick)
246+ // TODO compute CHANTYPES
247+ for _, name := range ParseNameReply(msg.Params[3], "~&@%+") {
248+ nickCf := s.Casemap(name.Name.Name)
249
250 if _, ok := s.users[nickCf]; !ok {
251- s.users[nickCf] = &User{Nick: nick}
252+ s.users[nickCf] = &User{Name: name.Name.Copy()}
253 }
254 c.Members[s.users[nickCf]] = name.PowerLevel
255 }
256@@ -712,40 +732,38 @@ func (s *Session) handle(msg Message) (err error) {
257 s.channels[channelCf] = c
258 }
259 case rplTopic:
260- channelCf := strings.ToLower(msg.Params[1])
261+ channelCf := s.Casemap(msg.Params[1])
262 if c, ok := s.channels[channelCf]; ok {
263 c.Topic = msg.Params[2]
264 s.channels[channelCf] = c
265 }
266 case rplTopicwhotime:
267- channelCf := strings.ToLower(msg.Params[1])
268+ channelCf := s.Casemap(msg.Params[1])
269 t, _ := strconv.ParseInt(msg.Params[3], 10, 64)
270 if c, ok := s.channels[channelCf]; ok {
271- c.TopicWho, _, _ = FullMask(msg.Params[2])
272+ c.TopicWho = ParsePrefix(msg.Params[2])
273 c.TopicTime = time.Unix(t, 0)
274 s.channels[channelCf] = c
275 }
276 case rplNotopic:
277- channelCf := strings.ToLower(msg.Params[1])
278+ channelCf := s.Casemap(msg.Params[1])
279 if c, ok := s.channels[channelCf]; ok {
280 c.Topic = ""
281 s.channels[channelCf] = c
282 }
283 case "TOPIC":
284- nick, _, _ := FullMask(msg.Prefix)
285- channelCf := strings.ToLower(msg.Params[0])
286+ channelCf := s.Casemap(msg.Params[0])
287 if c, ok := s.channels[channelCf]; ok {
288 c.Topic = msg.Params[1]
289- c.TopicWho = nick
290+ c.TopicWho = msg.Prefix.Copy()
291 c.TopicTime = msg.TimeOrNow()
292 s.channels[channelCf] = c
293 }
294 case "PRIVMSG", "NOTICE":
295 s.evts <- s.privmsgToEvent(msg)
296 case "TAGMSG":
297- nick, _, _ := FullMask(msg.Prefix)
298- nickCf := strings.ToLower(nick)
299- targetCf := strings.ToLower(msg.Params[0])
300+ nickCf := s.Casemap(msg.Prefix.Name)
301+ targetCf := s.Casemap(msg.Params[0])
302
303 if nickCf == s.nickCf {
304 // TAGMSG from self
305@@ -765,23 +783,17 @@ func (s *Session) handle(msg Message) (err error) {
306 break
307 }
308
309- t := msg.TimeOrNow()
310- if targetCf == s.nickCf {
311- // TAGMSG to self
312- s.evts <- QueryTagEvent{
313- Nick: nick,
314- Typing: typing,
315- Time: t,
316- }
317- } else if c, ok := s.channels[targetCf]; ok {
318- // TAGMSG to channelCf
319- s.evts <- ChannelTagEvent{
320- Nick: nick,
321- Channel: c.Name,
322- Typing: typing,
323- Time: t,
324- }
325+ ev := TagEvent{
326+ User: msg.Prefix.Copy(), // TODO correctly casemap
327+ Target: msg.Params[0], // TODO correctly casemap
328+ Typing: typing,
329+ Time: msg.TimeOrNow(),
330+ }
331+ if c, ok := s.channels[targetCf]; ok {
332+ ev.Target = c.Name
333+ ev.TargetIsChannel = true
334 }
335+ s.evts <- ev
336 case "BATCH":
337 batchStart := msg.Params[0][0] == '+'
338 id := msg.Params[0][1:]
339@@ -793,35 +805,38 @@ func (s *Session) handle(msg Message) (err error) {
340 delete(s.chBatches, id)
341 }
342 case "NICK":
343- nick, _, _ := FullMask(msg.Prefix)
344- nickCf := strings.ToLower(nick)
345+ nickCf := s.Casemap(msg.Prefix.Name)
346 newNick := msg.Params[0]
347- newNickCf := strings.ToLower(newNick)
348+ newNickCf := s.Casemap(newNick)
349 t := msg.TimeOrNow()
350
351+ var u *Prefix
352 if formerUser, ok := s.users[nickCf]; ok {
353- formerUser.Nick = newNick
354+ formerUser.Name.Name = newNick
355 delete(s.users, nickCf)
356 s.users[newNickCf] = formerUser
357+ u = formerUser.Name.Copy()
358 }
359
360 if nickCf == s.nickCf {
361 s.evts <- SelfNickEvent{
362 FormerNick: s.nick,
363- NewNick: newNick,
364 Time: t,
365 }
366 s.nick = newNick
367 s.nickCf = newNickCf
368 } else {
369 s.evts <- UserNickEvent{
370- FormerNick: nick,
371- NewNick: newNick,
372+ User: u,
373+ FormerNick: msg.Prefix.Name,
374 Time: t,
375 }
376 }
377 case "FAIL":
378- fmt.Println("FAIL", msg.Params)
379+ s.evts <- RawMessageEvent{
380+ Message: msg.String(),
381+ IsValid: true,
382+ }
383 case "PING":
384 err = s.send("PONG :%s\r\n", msg.Params[0])
385 if err != nil {
386@@ -839,29 +854,19 @@ func (s *Session) handle(msg Message) (err error) {
387 return
388 }
389
390-func (s *Session) privmsgToEvent(msg Message) (ev Event) {
391- nick, _, _ := FullMask(msg.Prefix)
392- targetCf := strings.ToLower(msg.Params[0])
393- t := msg.TimeOrNow()
394-
395- if !s.IsChannel(targetCf) {
396- // PRIVMSG to self
397- ev = QueryMessageEvent{
398- Nick: nick,
399- Target: msg.Params[0],
400- Command: msg.Command,
401- Content: msg.Params[1],
402- Time: t,
403- }
404- } else if c, ok := s.channels[targetCf]; ok {
405- // PRIVMSG to channel
406- ev = ChannelMessageEvent{
407- Nick: nick,
408- Channel: c.Name,
409- Command: msg.Command,
410- Content: msg.Params[1],
411- Time: t,
412- }
413+func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
414+ targetCf := s.Casemap(msg.Params[0])
415+
416+ ev = MessageEvent{
417+ User: msg.Prefix.Copy(), // TODO correctly casemap
418+ Target: msg.Params[0], // TODO correctly casemap
419+ Command: msg.Command,
420+ Content: msg.Params[1],
421+ Time: msg.TimeOrNow(),
422+ }
423+ if c, ok := s.channels[targetCf]; ok {
424+ ev.Target = c.Name
425+ ev.TargetIsChannel = true
426 }
427
428 return
429@@ -873,7 +878,7 @@ func (s *Session) cleanUser(parted *User) {
430 return
431 }
432 }
433- delete(s.users, strings.ToLower(parted.Nick))
434+ delete(s.users, s.Casemap(parted.Name.Name))
435 }
436
437 func (s *Session) updateFeatures(features []string) {
+84,
-43
1@@ -8,6 +8,18 @@ import (
2 "time"
3 )
4
5+func CasemapASCII(name string) string {
6+ var sb strings.Builder
7+ sb.Grow(len(name))
8+ for _, r := range name {
9+ if 'A' <= r && r <= 'Z' {
10+ r += 'a' - 'A'
11+ }
12+ sb.WriteRune(r)
13+ }
14+ return sb.String()
15+}
16+
17 func word(s string) (w, rest string) {
18 split := strings.SplitN(s, " ", 2)
19
20@@ -125,14 +137,67 @@ var (
21 errUnknownCommand = errors.New("unknown command")
22 )
23
24+type Prefix struct {
25+ Name string
26+ User string
27+ Host string
28+}
29+
30+func ParsePrefix(s string) (p *Prefix) {
31+ if s == "" {
32+ return
33+ }
34+
35+ p = &Prefix{}
36+
37+ spl0 := strings.Split(s, "@")
38+ if 1 < len(spl0) {
39+ p.Host = spl0[1]
40+ }
41+
42+ spl1 := strings.Split(spl0[0], "!")
43+ if 1 < len(spl1) {
44+ p.User = spl1[1]
45+ }
46+
47+ p.Name = spl1[0]
48+
49+ return
50+}
51+
52+func (p *Prefix) Copy() *Prefix {
53+ if p == nil {
54+ return nil
55+ }
56+ res := &Prefix{}
57+ *res = *p
58+ return res
59+}
60+
61+func (p *Prefix) String() string {
62+ if p == nil {
63+ return ""
64+ }
65+
66+ if p.User != "" && p.Host != "" {
67+ return p.Name + "!" + p.User + "@" + p.Host
68+ } else if p.User != "" {
69+ return p.Name + "!" + p.User
70+ } else if p.Host != "" {
71+ return p.Name + "@" + p.Host
72+ } else {
73+ return p.Name
74+ }
75+}
76+
77 type Message struct {
78 Tags map[string]string
79- Prefix string
80+ Prefix *Prefix
81 Command string
82 Params []string
83 }
84
85-func Tokenize(line string) (msg Message, err error) {
86+func ParseMessage(line string) (msg Message, err error) {
87 line = strings.TrimLeft(line, " ")
88 if line == "" {
89 err = errEmptyMessage
90@@ -156,7 +221,7 @@ func Tokenize(line string) (msg Message, err error) {
91 var prefix string
92
93 prefix, line = word(line)
94- msg.Prefix = prefix[1:]
95+ msg.Prefix = ParsePrefix(prefix[1:])
96 }
97
98 line = strings.TrimLeft(line, " ")
99@@ -211,9 +276,9 @@ func (msg *Message) String() string {
100 sb.WriteRune(' ')
101 }
102
103- if msg.Prefix != "" {
104+ if msg.Prefix != nil {
105 sb.WriteRune(':')
106- sb.WriteString(msg.Prefix)
107+ sb.WriteString(msg.Prefix.String())
108 sb.WriteRune(' ')
109 }
110
111@@ -245,11 +310,11 @@ func (msg *Message) IsValid() bool {
112 case rplWhoreply:
113 return 8 <= len(msg.Params)
114 case "JOIN", "NICK", "PART", "TAGMSG":
115- return 1 <= len(msg.Params) && msg.Prefix != ""
116+ return 1 <= len(msg.Params) && msg.Prefix != nil
117 case "PRIVMSG", "NOTICE", "TOPIC":
118- return 2 <= len(msg.Params) && msg.Prefix != ""
119+ return 2 <= len(msg.Params) && msg.Prefix != nil
120 case "QUIT":
121- return msg.Prefix != ""
122+ return msg.Prefix != nil
123 case "CAP":
124 return 3 <= len(msg.Params) &&
125 (msg.Params[1] == "LS" ||
126@@ -317,33 +382,13 @@ func (msg *Message) TimeOrNow() time.Time {
127 return time.Now()
128 }
129
130-func FullMask(s string) (nick, user, host string) {
131- if s == "" {
132- return
133- }
134-
135- spl0 := strings.Split(s, "@")
136- if 1 < len(spl0) {
137- host = spl0[1]
138- }
139-
140- spl1 := strings.Split(spl0[0], "!")
141- if 1 < len(spl1) {
142- user = spl1[1]
143- }
144-
145- nick = spl1[0]
146-
147- return
148-}
149-
150 type Cap struct {
151 Name string
152 Value string
153 Enable bool
154 }
155
156-func TokenizeCaps(caps string) (diff []Cap) {
157+func ParseCaps(caps string) (diff []Cap) {
158 for _, c := range strings.Split(caps, " ") {
159 if c == "" || c == "-" || c == "=" || c == "-=" {
160 continue
161@@ -370,26 +415,22 @@ func TokenizeCaps(caps string) (diff []Cap) {
162 return
163 }
164
165-type Name struct {
166+type Member struct {
167 PowerLevel string
168- Nick string
169- User string
170- Host string
171+ Name *Prefix
172 }
173
174-func TokenizeNames(trailing string, prefixes string) (names []Name) {
175- for _, name := range strings.Split(trailing, " ") {
176- if name == "" {
177+func ParseNameReply(trailing string, prefixes string) (names []Member) {
178+ for _, word := range strings.Split(trailing, " ") {
179+ if word == "" {
180 continue
181 }
182
183- var item Name
184-
185- mask := strings.TrimLeft(name, prefixes)
186- item.Nick, item.User, item.Host = FullMask(mask)
187- item.PowerLevel = name[:len(name)-len(mask)]
188-
189- names = append(names, item)
190+ name := strings.TrimLeft(word, prefixes)
191+ names = append(names, Member{
192+ PowerLevel: word[:len(word)-len(name)],
193+ Name: ParsePrefix(name),
194+ })
195 }
196
197 return
+57,
-96
1@@ -2,7 +2,6 @@ package ui
2
3 import (
4 "fmt"
5- "hash/fnv"
6 "math"
7 "strings"
8 "time"
9@@ -31,55 +30,28 @@ type point struct {
10 }
11
12 type Line struct {
13- at time.Time
14- head string
15- body string
16-
17- isStatus bool
18- isHighlight bool
19+ At time.Time
20+ Head string
21+ Body string
22+ HeadColor int
23+ Highlight bool
24+ Mergeable bool
25
26 splitPoints []point
27 width int
28 newLines []int
29 }
30
31-func NewLine(at time.Time, head string, body string, isStatus bool, isHighlight bool) Line {
32- l := Line{
33- at: at,
34- head: head,
35- body: body,
36- isStatus: isStatus,
37- isHighlight: isHighlight,
38- splitPoints: []point{},
39- newLines: []int{},
40- }
41- l.computeSplitPoints()
42- return l
43-}
44-
45-func NewLineNow(head, body string) Line {
46- return NewLine(time.Now(), head, body, false, false)
47-}
48-
49-func LineFromIRCMessage(at time.Time, nick string, content string, isNotice bool, isHighlight bool) Line {
50- if strings.HasPrefix(content, "\x01ACTION") {
51- c := ircColorCode(identColor(nick))
52- content = fmt.Sprintf("%s%s\x0F%s", c, nick, content[7:])
53- nick = "*"
54- } else if isNotice {
55- c := ircColorCode(identColor(nick))
56- content = fmt.Sprintf("(%s%s\x0F: %s)", c, nick, content)
57- nick = "*"
58+func (l *Line) computeSplitPoints() {
59+ if l.splitPoints == nil {
60+ l.splitPoints = []point{}
61 }
62- return NewLine(at, nick, content, false, isHighlight)
63-}
64
65-func (l *Line) computeSplitPoints() {
66 var wb widthBuffer
67 lastWasSplit := false
68 l.splitPoints = l.splitPoints[:0]
69
70- for i, r := range l.body {
71+ for i, r := range l.Body {
72 curIsSplit := IsSplitRune(r)
73
74 if i == 0 || lastWasSplit != curIsSplit {
75@@ -97,7 +69,7 @@ func (l *Line) computeSplitPoints() {
76 if !lastWasSplit {
77 l.splitPoints = append(l.splitPoints, point{
78 X: wb.Width(),
79- I: len(l.body),
80+ I: len(l.Body),
81 Split: true,
82 })
83 }
84@@ -113,6 +85,9 @@ func (l *Line) NewLines(width int) []int {
85 if l.width == width {
86 return l.newLines
87 }
88+ if l.newLines == nil {
89+ l.newLines = []int{}
90+ }
91 l.newLines = l.newLines[:0]
92 l.width = width
93
94@@ -158,7 +133,7 @@ func (l *Line) NewLines(width int) []int {
95 // the word.
96 var wb widthBuffer
97 h := 1
98- for j, r := range l.body[sp1.I:sp2.I] {
99+ for j, r := range l.Body[sp1.I:sp2.I] {
100 wb.WriteRune(r)
101 if h*width < x+wb.Width() {
102 l.newLines = append(l.newLines, sp1.I+j)
103@@ -185,7 +160,7 @@ func (l *Line) NewLines(width int) []int {
104 }
105 }
106
107- if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.body) {
108+ if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body) {
109 // DROP any newline that is placed at the end of the string because we
110 // don't care about those.
111 l.newLines = l.newLines[:len(l.newLines)-1]
112@@ -229,20 +204,19 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
113 continue
114 }
115
116- if i == 0 || b.lines[i-1].at.Truncate(time.Minute) != line.at.Truncate(time.Minute) {
117- printTime(screen, 0, y0, st.Bold(true), line.at.Local())
118+ if i == 0 || b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) {
119+ printTime(screen, 0, y0, st.Bold(true), line.At.Local())
120 }
121
122- head := truncate(line.head, nickColWidth, "\u2026")
123+ head := truncate(line.Head, nickColWidth, "\u2026")
124 x := 7 + nickColWidth - StringWidth(head)
125- c := identColor(line.head)
126- st = st.Foreground(colorFromCode(c))
127- if line.isHighlight {
128+ st = st.Foreground(colorFromCode(line.HeadColor))
129+ if line.Highlight {
130 st = st.Reverse(true)
131 }
132 screen.SetContent(x-1, y0, ' ', nil, st)
133 screen.SetContent(7+nickColWidth, y0, ' ', nil, st)
134- printString(screen, &x, y0, st.Foreground(colorFromCode(c)), head)
135+ printString(screen, &x, y0, st, head)
136 st = st.Reverse(false).Foreground(tcell.ColorDefault)
137
138 x = x0
139@@ -250,7 +224,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
140
141 var sb StyleBuffer
142 sb.Reset()
143- for i, r := range line.body {
144+ for i, r := range line.Body {
145 if 0 < len(nls) && i == nls[0] {
146 x = x0
147 y++
148@@ -280,7 +254,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
149 b.isAtTop = 0 <= y0
150 }
151
152-type bufferList struct {
153+type BufferList struct {
154 list []buffer
155 current int
156
157@@ -289,8 +263,8 @@ type bufferList struct {
158 nickColWidth int
159 }
160
161-func newBufferList(width, height, nickColWidth int) bufferList {
162- return bufferList{
163+func NewBufferList(width, height, nickColWidth int) BufferList {
164+ return BufferList{
165 list: []buffer{},
166 width: width,
167 height: height,
168@@ -298,24 +272,24 @@ func newBufferList(width, height, nickColWidth int) bufferList {
169 }
170 }
171
172-func (bs *bufferList) Resize(width, height int) {
173+func (bs *BufferList) Resize(width, height int) {
174 bs.width = width
175 bs.height = height
176 }
177
178-func (bs *bufferList) Next() {
179+func (bs *BufferList) Next() {
180 bs.current = (bs.current + 1) % len(bs.list)
181 bs.list[bs.current].highlights = 0
182 bs.list[bs.current].unread = false
183 }
184
185-func (bs *bufferList) Previous() {
186+func (bs *BufferList) Previous() {
187 bs.current = (bs.current - 1 + len(bs.list)) % len(bs.list)
188 bs.list[bs.current].highlights = 0
189 bs.list[bs.current].unread = false
190 }
191
192-func (bs *bufferList) Add(title string) (ok bool) {
193+func (bs *BufferList) Add(title string) (ok bool) {
194 lTitle := strings.ToLower(title)
195 for _, b := range bs.list {
196 if strings.ToLower(b.title) == lTitle {
197@@ -328,7 +302,7 @@ func (bs *bufferList) Add(title string) (ok bool) {
198 return
199 }
200
201-func (bs *bufferList) Remove(title string) (ok bool) {
202+func (bs *BufferList) Remove(title string) (ok bool) {
203 lTitle := strings.ToLower(title)
204 for i, b := range bs.list {
205 if strings.ToLower(b.title) == lTitle {
206@@ -343,7 +317,7 @@ func (bs *bufferList) Remove(title string) (ok bool) {
207 return
208 }
209
210-func (bs *bufferList) AddLine(title string, line Line) {
211+func (bs *BufferList) AddLine(title string, highlight bool, line Line) {
212 idx := bs.idx(title)
213 if idx < 0 {
214 return
215@@ -351,29 +325,30 @@ func (bs *bufferList) AddLine(title string, line Line) {
216
217 b := &bs.list[idx]
218 n := len(b.lines)
219- line.body = strings.TrimRight(line.body, "\t ")
220+ line.Body = strings.TrimRight(line.Body, "\t ")
221
222- if line.isStatus && n != 0 && b.lines[n-1].isStatus {
223+ if line.Mergeable && n != 0 && b.lines[n-1].Mergeable {
224 l := &b.lines[n-1]
225- l.body += " " + line.body
226+ l.Body += " " + line.Body
227 l.computeSplitPoints()
228 l.width = 0
229 } else {
230+ line.computeSplitPoints()
231 b.lines = append(b.lines, line)
232 if idx == bs.current && 0 < b.scrollAmt {
233 b.scrollAmt += len(line.NewLines(bs.width-9-bs.nickColWidth)) + 1
234 }
235 }
236
237- if !line.isStatus && idx != bs.current {
238+ if !line.Mergeable && idx != bs.current {
239 b.unread = true
240 }
241- if line.isHighlight && idx != bs.current {
242+ if highlight && idx != bs.current {
243 b.highlights++
244 }
245 }
246
247-func (bs *bufferList) AddLines(title string, lines []Line) {
248+func (bs *BufferList) AddLines(title string, lines []Line) {
249 idx := bs.idx(title)
250 if idx < 0 {
251 return
252@@ -383,19 +358,22 @@ func (bs *bufferList) AddLines(title string, lines []Line) {
253 limit := len(lines)
254
255 if 0 < len(b.lines) {
256- firstLineTime := b.lines[0].at.Unix()
257+ firstLineTime := b.lines[0].At.Unix()
258 for i, l := range lines {
259- if firstLineTime < l.at.Unix() {
260+ if firstLineTime < l.At.Unix() {
261 limit = i
262 break
263 }
264 }
265 }
266+ for i := 0; i < limit; i++ {
267+ lines[i].computeSplitPoints()
268+ }
269
270 b.lines = append(lines[:limit], b.lines...)
271 }
272
273-func (bs *bufferList) TypingStart(title, nick string) {
274+func (bs *BufferList) TypingStart(title, nick string) {
275 idx := bs.idx(title)
276 if idx < 0 {
277 return
278@@ -411,7 +389,7 @@ func (bs *bufferList) TypingStart(title, nick string) {
279 b.typings = append(b.typings, nick)
280 }
281
282-func (bs *bufferList) TypingStop(title, nick string) {
283+func (bs *BufferList) TypingStop(title, nick string) {
284 idx := bs.idx(title)
285 if idx < 0 {
286 return
287@@ -427,19 +405,19 @@ func (bs *bufferList) TypingStop(title, nick string) {
288 }
289 }
290
291-func (bs *bufferList) Current() (title string) {
292+func (bs *BufferList) Current() (title string) {
293 return bs.list[bs.current].title
294 }
295
296-func (bs *bufferList) CurrentOldestTime() (t *time.Time) {
297+func (bs *BufferList) CurrentOldestTime() (t *time.Time) {
298 ls := bs.list[bs.current].lines
299 if 0 < len(ls) {
300- t = &ls[0].at
301+ t = &ls[0].At
302 }
303 return
304 }
305
306-func (bs *bufferList) ScrollUp() {
307+func (bs *BufferList) ScrollUp() {
308 b := &bs.list[bs.current]
309 if b.isAtTop {
310 return
311@@ -447,7 +425,7 @@ func (bs *bufferList) ScrollUp() {
312 b.scrollAmt += bs.height / 2
313 }
314
315-func (bs *bufferList) ScrollDown() {
316+func (bs *BufferList) ScrollDown() {
317 b := &bs.list[bs.current]
318 b.scrollAmt -= bs.height / 2
319
320@@ -456,12 +434,12 @@ func (bs *bufferList) ScrollDown() {
321 }
322 }
323
324-func (bs *bufferList) IsAtTop() bool {
325+func (bs *BufferList) IsAtTop() bool {
326 b := &bs.list[bs.current]
327 return b.isAtTop
328 }
329
330-func (bs *bufferList) idx(title string) int {
331+func (bs *BufferList) idx(title string) int {
332 if title == "" {
333 return bs.current
334 }
335@@ -475,13 +453,13 @@ func (bs *bufferList) idx(title string) int {
336 return -1
337 }
338
339-func (bs *bufferList) Draw(screen tcell.Screen) {
340+func (bs *BufferList) Draw(screen tcell.Screen) {
341 bs.list[bs.current].DrawLines(screen, bs.width, bs.height-3, bs.nickColWidth)
342 bs.drawStatusBar(screen, bs.height-3)
343 bs.drawTitleList(screen, bs.height-1)
344 }
345
346-func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) {
347+func (bs *BufferList) drawStatusBar(screen tcell.Screen, y int) {
348 st := tcell.StyleDefault.Dim(true)
349 nicks := bs.list[bs.current].typings
350 verb := " is typing..."
351@@ -517,7 +495,7 @@ func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) {
352 }
353 }
354
355-func (bs *bufferList) drawTitleList(screen tcell.Screen, y int) {
356+func (bs *BufferList) drawTitleList(screen tcell.Screen, y int) {
357 var widths []int
358 for _, b := range bs.list {
359 width := StringWidth(b.title)
360@@ -604,24 +582,7 @@ func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
361 screen.SetContent(x+4, y, mn1, nil, st)
362 }
363
364-// see <https://modern.ircdocs.horse/formatting.html>
365-var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91}
366-
367-func identColor(s string) (code int) {
368- h := fnv.New32()
369- _, _ = h.Write([]byte(s))
370-
371- code = int(h.Sum32()) % (99 - len(identColorBlacklist))
372- for _, c := range identColorBlacklist {
373- if c <= code {
374- code++
375- }
376- }
377-
378- return
379-}
380-
381-func ircColorCode(code int) string {
382+func IrcColorCode(code int) string {
383 var c [3]rune
384 c[0] = 0x03
385 c[1] = rune(code/10) + '0'
+23,
-23
1@@ -9,8 +9,8 @@ type Completion struct {
2 CursorIdx int
3 }
4
5-// editor is the text field where the user writes messages and commands.
6-type editor struct {
7+// Editor is the text field where the user writes messages and commands.
8+type Editor struct {
9 // text contains the written runes. An empty slice means no text is written.
10 text [][]rune
11
12@@ -38,8 +38,8 @@ type editor struct {
13 completeIdx int
14 }
15
16-func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) editor {
17- return editor{
18+func NewEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) Editor {
19+ return Editor{
20 text: [][]rune{{}},
21 textWidth: []int{0},
22 width: width,
23@@ -47,7 +47,7 @@ func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Comple
24 }
25 }
26
27-func (e *editor) Resize(width int) {
28+func (e *Editor) Resize(width int) {
29 if width < e.width {
30 e.cursorIdx = 0
31 e.offsetIdx = 0
32@@ -56,21 +56,21 @@ func (e *editor) Resize(width int) {
33 e.width = width
34 }
35
36-func (e *editor) IsCommand() bool {
37+func (e *Editor) IsCommand() bool {
38 return len(e.text[e.lineIdx]) != 0 && e.text[e.lineIdx][0] == '/'
39 }
40
41-func (e *editor) TextLen() int {
42+func (e *Editor) TextLen() int {
43 return len(e.text[e.lineIdx])
44 }
45
46-func (e *editor) PutRune(r rune) {
47+func (e *Editor) PutRune(r rune) {
48 e.putRune(r)
49 e.Right()
50 e.autoCache = nil
51 }
52
53-func (e *editor) putRune(r rune) {
54+func (e *Editor) putRune(r rune) {
55 e.text[e.lineIdx] = append(e.text[e.lineIdx], ' ')
56 copy(e.text[e.lineIdx][e.cursorIdx+1:], e.text[e.lineIdx][e.cursorIdx:])
57 e.text[e.lineIdx][e.cursorIdx] = r
58@@ -83,7 +83,7 @@ func (e *editor) putRune(r rune) {
59 }
60 }
61
62-func (e *editor) RemRune() (ok bool) {
63+func (e *Editor) RemRune() (ok bool) {
64 ok = 0 < e.cursorIdx
65 if !ok {
66 return
67@@ -94,7 +94,7 @@ func (e *editor) RemRune() (ok bool) {
68 return
69 }
70
71-func (e *editor) RemRuneForward() (ok bool) {
72+func (e *Editor) RemRuneForward() (ok bool) {
73 ok = e.cursorIdx < len(e.text[e.lineIdx])
74 if !ok {
75 return
76@@ -104,7 +104,7 @@ func (e *editor) RemRuneForward() (ok bool) {
77 return
78 }
79
80-func (e *editor) remRuneAt(idx int) {
81+func (e *Editor) remRuneAt(idx int) {
82 // TODO avoid looping twice
83 rw := e.textWidth[idx+1] - e.textWidth[idx]
84 for i := idx + 1; i < len(e.textWidth); i++ {
85@@ -117,7 +117,7 @@ func (e *editor) remRuneAt(idx int) {
86 e.text[e.lineIdx] = e.text[e.lineIdx][:len(e.text[e.lineIdx])-1]
87 }
88
89-func (e *editor) Flush() (content string) {
90+func (e *Editor) Flush() (content string) {
91 content = string(e.text[e.lineIdx])
92 if len(e.text[len(e.text)-1]) == 0 {
93 e.lineIdx = len(e.text) - 1
94@@ -132,12 +132,12 @@ func (e *editor) Flush() (content string) {
95 return
96 }
97
98-func (e *editor) Right() {
99+func (e *Editor) Right() {
100 e.right()
101 e.autoCache = nil
102 }
103
104-func (e *editor) right() {
105+func (e *Editor) right() {
106 if e.cursorIdx == len(e.text[e.lineIdx]) {
107 return
108 }
109@@ -151,7 +151,7 @@ func (e *editor) right() {
110 }
111 }
112
113-func (e *editor) Left() {
114+func (e *Editor) Left() {
115 if e.cursorIdx == 0 {
116 return
117 }
118@@ -165,7 +165,7 @@ func (e *editor) Left() {
119 e.autoCache = nil
120 }
121
122-func (e *editor) Home() {
123+func (e *Editor) Home() {
124 if e.cursorIdx == 0 {
125 return
126 }
127@@ -174,7 +174,7 @@ func (e *editor) Home() {
128 e.autoCache = nil
129 }
130
131-func (e *editor) End() {
132+func (e *Editor) End() {
133 if e.cursorIdx == len(e.text[e.lineIdx]) {
134 return
135 }
136@@ -185,7 +185,7 @@ func (e *editor) End() {
137 e.autoCache = nil
138 }
139
140-func (e *editor) Up() {
141+func (e *Editor) Up() {
142 if e.lineIdx == 0 {
143 return
144 }
145@@ -197,7 +197,7 @@ func (e *editor) Up() {
146 e.End()
147 }
148
149-func (e *editor) Down() {
150+func (e *Editor) Down() {
151 if e.lineIdx == len(e.text)-1 {
152 if len(e.text[e.lineIdx]) == 0 {
153 return
154@@ -213,7 +213,7 @@ func (e *editor) Down() {
155 e.End()
156 }
157
158-func (e *editor) AutoComplete() (ok bool) {
159+func (e *Editor) AutoComplete() (ok bool) {
160 if e.autoCache == nil {
161 e.autoCache = e.autoComplete(e.cursorIdx, e.text[e.lineIdx])
162 if e.autoCache == nil {
163@@ -234,7 +234,7 @@ func (e *editor) AutoComplete() (ok bool) {
164 return true
165 }
166
167-func (e *editor) computeTextWidth() {
168+func (e *Editor) computeTextWidth() {
169 e.textWidth = e.textWidth[:1]
170 rw := 0
171 for _, r := range e.text[e.lineIdx] {
172@@ -243,7 +243,7 @@ func (e *editor) computeTextWidth() {
173 }
174 }
175
176-func (e *editor) Draw(screen tcell.Screen, y int) {
177+func (e *Editor) Draw(screen tcell.Screen, y int) {
178 st := tcell.StyleDefault
179
180 x := 0
+8,
-0
1@@ -169,6 +169,14 @@ func (cb *colorBuffer) WriteRune(r rune) (ok int) {
2 return
3 }
4
5+const (
6+ ColorWhite = iota
7+ ColorBlack
8+ ColorBlue
9+ ColorGreen
10+ ColorRed
11+)
12+
13 var ansiCodes = []tcell.Color{
14 // Taken from <https://modern.ircdocs.horse/formatting.html>
15 tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen,
M
ui/ui.go
+19,
-50
1@@ -19,8 +19,8 @@ type UI struct {
2 exit atomic.Value // bool
3 config Config
4
5- bs bufferList
6- e editor
7+ bs BufferList
8+ e Editor
9 }
10
11 func New(config Config) (ui *UI, err error) {
12@@ -52,11 +52,15 @@ func New(config Config) (ui *UI, err error) {
13 ui.exit.Store(false)
14
15 hmIdx := rand.Intn(len(homeMessages))
16- ui.bs = newBufferList(w, h, ui.config.NickColWidth)
17+ ui.bs = NewBufferList(w, h, ui.config.NickColWidth)
18 ui.bs.Add(Home)
19- ui.bs.AddLine("", NewLineNow("--", homeMessages[hmIdx]))
20+ ui.bs.AddLine("", false, Line{
21+ At: time.Now(),
22+ Head: "--",
23+ Body: homeMessages[hmIdx],
24+ })
25
26- ui.e = newEditor(w, ui.config.AutoComplete)
27+ ui.e = NewEditor(w, ui.config.AutoComplete)
28
29 ui.Resize()
30
31@@ -85,22 +89,18 @@ func (ui *UI) CurrentBufferOldestTime() (t *time.Time) {
32
33 func (ui *UI) NextBuffer() {
34 ui.bs.Next()
35- ui.draw()
36 }
37
38 func (ui *UI) PreviousBuffer() {
39 ui.bs.Previous()
40- ui.draw()
41 }
42
43 func (ui *UI) ScrollUp() {
44 ui.bs.ScrollUp()
45- ui.draw()
46 }
47
48 func (ui *UI) ScrollDown() {
49 ui.bs.ScrollDown()
50- ui.draw()
51 }
52
53 func (ui *UI) IsAtTop() bool {
54@@ -108,37 +108,27 @@ func (ui *UI) IsAtTop() bool {
55 }
56
57 func (ui *UI) AddBuffer(title string) {
58- ok := ui.bs.Add(title)
59- if ok {
60- ui.draw()
61- }
62+ _ = ui.bs.Add(title)
63 }
64
65 func (ui *UI) RemoveBuffer(title string) {
66- ok := ui.bs.Remove(title)
67- if ok {
68- ui.draw()
69- }
70+ _ = ui.bs.Remove(title)
71 }
72
73-func (ui *UI) AddLine(buffer string, line Line) {
74- ui.bs.AddLine(buffer, line)
75- ui.draw()
76+func (ui *UI) AddLine(buffer string, highlight bool, line Line) {
77+ ui.bs.AddLine(buffer, highlight, line)
78 }
79
80 func (ui *UI) AddLines(buffer string, lines []Line) {
81 ui.bs.AddLines(buffer, lines)
82- ui.draw()
83 }
84
85 func (ui *UI) TypingStart(buffer, nick string) {
86 ui.bs.TypingStart(buffer, nick)
87- ui.draw()
88 }
89
90 func (ui *UI) TypingStop(buffer, nick string) {
91 ui.bs.TypingStop(buffer, nick)
92- ui.draw()
93 }
94
95 func (ui *UI) InputIsCommand() bool {
96@@ -151,77 +141,56 @@ func (ui *UI) InputLen() int {
97
98 func (ui *UI) InputRune(r rune) {
99 ui.e.PutRune(r)
100- ui.draw()
101 }
102
103 func (ui *UI) InputRight() {
104 ui.e.Right()
105- ui.draw()
106 }
107
108 func (ui *UI) InputLeft() {
109 ui.e.Left()
110- ui.draw()
111 }
112
113 func (ui *UI) InputHome() {
114 ui.e.Home()
115- ui.draw()
116 }
117
118 func (ui *UI) InputEnd() {
119 ui.e.End()
120- ui.draw()
121 }
122
123 func (ui *UI) InputUp() {
124 ui.e.Up()
125- ui.draw()
126 }
127
128 func (ui *UI) InputDown() {
129 ui.e.Down()
130- ui.draw()
131 }
132
133 func (ui *UI) InputBackspace() (ok bool) {
134- ok = ui.e.RemRune()
135- if ok {
136- ui.draw()
137- }
138- return
139+ return ui.e.RemRune()
140 }
141
142 func (ui *UI) InputDelete() (ok bool) {
143- ok = ui.e.RemRuneForward()
144- if ok {
145- ui.draw()
146- }
147- return
148+ return ui.e.RemRuneForward()
149 }
150
151 func (ui *UI) InputAutoComplete() (ok bool) {
152- ok = ui.e.AutoComplete()
153- if ok {
154- ui.draw()
155- }
156- return
157+ return ui.e.AutoComplete()
158 }
159
160 func (ui *UI) InputEnter() (content string) {
161- content = ui.e.Flush()
162- ui.draw()
163- return
164+ return ui.e.Flush()
165 }
166
167 func (ui *UI) Resize() {
168 w, h := ui.screen.Size()
169 ui.e.Resize(w)
170 ui.bs.Resize(w, h)
171- ui.draw()
172+ ui.Draw()
173 }
174
175-func (ui *UI) draw() {
176+func (ui *UI) Draw() {
177 _, h := ui.screen.Size()
178 ui.e.Draw(ui.screen, h-2)
179 ui.bs.Draw(ui.screen)
+19,
-0
1@@ -0,0 +1,19 @@
2+package senpai
3+
4+import (
5+ "time"
6+
7+ "git.sr.ht/~taiite/senpai/ui"
8+)
9+
10+func (app *App) addLineNow(buffer string, line ui.Line) {
11+ if line.At.IsZero() {
12+ line.At = time.Now()
13+ }
14+ app.win.AddLine(buffer, false, line)
15+ app.draw()
16+}
17+
18+func (app *App) draw() {
19+ app.win.Draw()
20+}