commit 881d634

Hubert Hirtz  ·  2020-05-31 21:20:18 +0000 UTC
parent 881d634
Initial commit
12 files changed,  +1901, -0
A go.mod
A go.sum
+2, -0
1@@ -0,0 +1,2 @@
2+.idea/
3+*.iml
+22, -0
 1@@ -0,0 +1,22 @@
 2+# NOTICE me :senpai!
 3+
 4+Welcome home, desune~
 5+
 6+Works best with jounce!
 7+
 8+# ... How?
 9+
10+```shell
11+mkdir -p ~/.config/senpai
12+echo '
13+addr: irc.freenode.net:6697
14+nick: senpai
15+real: Senpai is the best senpai
16+user: sasluser
17+password: "my password can't be this cute"' > ~/.config/senpai/senpai.yaml
18+go ./cmd/irc
19+```
20+
21+# License
22+
23+This senpai is open source! Please use it under the ISC license.
+173, -0
  1@@ -0,0 +1,173 @@
  2+package main
  3+
  4+import (
  5+	"crypto/tls"
  6+	"fmt"
  7+	"git.sr.ht/~taiite/senpai"
  8+	"git.sr.ht/~taiite/senpai/irc"
  9+	"git.sr.ht/~taiite/senpai/ui"
 10+	"github.com/gdamore/tcell"
 11+	"log"
 12+	"os/user"
 13+	"strings"
 14+	"time"
 15+)
 16+
 17+func main() {
 18+	tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
 19+
 20+	usr, err := user.Current()
 21+	if err != nil {
 22+		log.Panicln(err)
 23+	}
 24+
 25+	cfg, err := senpai.LoadConfigFile(usr.HomeDir + "/.config/senpai/senpai.yaml")
 26+	if err != nil {
 27+		log.Panicln(err)
 28+	}
 29+
 30+	app, err := ui.New()
 31+	if err != nil {
 32+		log.Panicln(err)
 33+	}
 34+	defer app.Close()
 35+
 36+	addr := cfg.Addr
 37+	app.AddLine("home", fmt.Sprintf("Connecting to %s...", addr), time.Now())
 38+
 39+	conn, err := tls.Dial("tcp", addr, nil)
 40+	if err != nil {
 41+		log.Panicln(err)
 42+	}
 43+
 44+	s, err := irc.NewSession(conn, irc.SessionParams{
 45+		Nickname: "taiite",
 46+		Username: "taiitent",
 47+		RealName: "Taiite Ier",
 48+		Auth:     &irc.SASLPlain{Username: cfg.User, Password: cfg.Password},
 49+	})
 50+	if err != nil {
 51+		log.Panicln(err)
 52+	}
 53+	defer s.Stop()
 54+
 55+	for !app.ShouldExit() {
 56+		select {
 57+		case ev := <-s.Poll():
 58+			switch ev := ev.(type) {
 59+			case irc.RegisteredEvent:
 60+				app.AddLine("home", "Connected to the server", time.Now())
 61+			case irc.SelfJoinEvent:
 62+				app.AddBuffer(ev.Channel)
 63+			case irc.ChannelMessageEvent:
 64+				line := formatIRCMessage(ev.Nick, ev.Content)
 65+				app.AddLine(ev.Channel, line, ev.Time)
 66+			case error:
 67+				log.Panicln(ev)
 68+			}
 69+		case ev := <-app.Events:
 70+			switch ev := ev.(type) {
 71+			case *tcell.EventResize:
 72+				app.Draw()
 73+			case *tcell.EventKey:
 74+				switch ev.Key() {
 75+				case tcell.KeyCtrlC:
 76+					app.Exit()
 77+				case tcell.KeyCtrlL:
 78+					app.Draw()
 79+				case tcell.KeyCtrlN:
 80+					app.NextBuffer()
 81+				case tcell.KeyCtrlP:
 82+					app.PreviousBuffer()
 83+				case tcell.KeyRight:
 84+					if ev.Modifiers() == tcell.ModAlt {
 85+						app.NextBuffer()
 86+					} else {
 87+						app.InputRight()
 88+					}
 89+				case tcell.KeyLeft:
 90+					if ev.Modifiers() == tcell.ModAlt {
 91+						app.PreviousBuffer()
 92+					} else {
 93+						app.InputLeft()
 94+					}
 95+				case tcell.KeyBackspace2:
 96+					app.InputBackspace()
 97+				case tcell.KeyEnter:
 98+					content := app.InputEnter()
 99+					handleInput(app, &s, content)
100+				case tcell.KeyRune:
101+					app.InputRune(ev.Rune())
102+					s.Typing(app.CurrentBuffer())
103+				}
104+			}
105+		}
106+	}
107+}
108+
109+func parseCommand(s string) (command, args string) {
110+	if s == "" {
111+		return
112+	}
113+
114+	if s[0] != '/' {
115+		args = s
116+		return
117+	}
118+
119+	i := strings.IndexByte(s, ' ')
120+	if i < 0 {
121+		i = len(s) - 1
122+	}
123+
124+	command = strings.ToUpper(s[1:i])
125+	args = s[i+1:]
126+
127+	return
128+}
129+
130+func handleInput(app *ui.UI, s *irc.Session, content string) {
131+	cmd, args := parseCommand(content)
132+
133+	switch cmd {
134+	case "":
135+		ch := app.CurrentBuffer()
136+		if ch == "home" {
137+			return
138+		}
139+
140+		s.PrivMsg(ch, args)
141+	case "ME":
142+		ch := app.CurrentBuffer()
143+		if ch == "home" {
144+			return
145+		}
146+
147+		line := fmt.Sprintf("\x01ACTION %s\x01", args)
148+		s.PrivMsg(ch, line)
149+	default:
150+	}
151+}
152+
153+func formatIRCMessage(nick, content string) (line string) {
154+	if content == "" {
155+		line = fmt.Sprintf("\x02%s\x00:", nick)
156+		return
157+	}
158+
159+	if content[0] == 1 {
160+		content = strings.TrimSuffix(content[1:], "\x01")
161+
162+		if strings.HasPrefix(content, "ACTION") {
163+			line = fmt.Sprintf("*\x02%s\x00%s", nick, content[6:])
164+		} else {
165+			line = fmt.Sprintf("\x1dCTCP request from\x1d \x02%s\x00: %s", nick, content)
166+		}
167+
168+		return
169+	}
170+
171+	line = fmt.Sprintf("\x02%s\x00  %s", nick, content)
172+
173+	return
174+}
+40, -0
 1@@ -0,0 +1,40 @@
 2+package main
 3+
 4+import (
 5+	"crypto/tls"
 6+	"git.sr.ht/~taiite/senpai/irc"
 7+	"log"
 8+	"os"
 9+)
10+
11+func main() {
12+	addr := os.Args[1]
13+
14+	log.Printf("Connecting to %s...\n", addr)
15+
16+	conn, err := tls.Dial("tcp", addr, nil)
17+	if err != nil {
18+		log.Panicf("Failed to connect to %s: %v", addr, err)
19+	}
20+	defer conn.Close()
21+
22+	log.Printf("Connected. Registration in progress...\n")
23+
24+	cli, err := irc.NewSession(conn, irc.SessionParams{
25+		Nickname: "senpai",
26+		Username: "senpai",
27+		RealName: "senpai Ier",
28+		Auth:     &irc.SASLPlain{Username: os.Args[2], Password: os.Args[3]},
29+	})
30+	if err != nil {
31+		log.Panicf("Failed to register to %s: %v", addr, err)
32+	}
33+
34+	for {
35+		ev := <-cli.Poll()
36+		switch ev := ev.(type) {
37+		case error:
38+			log.Panicln(ev)
39+		}
40+	}
41+}
+30, -0
 1@@ -0,0 +1,30 @@
 2+package senpai
 3+
 4+import (
 5+	"gopkg.in/yaml.v2"
 6+	"io/ioutil"
 7+)
 8+
 9+type Config struct {
10+	Addr string
11+	User string
12+	Password string
13+}
14+
15+func ParseConfig(buf []byte) (cfg Config, err error) {
16+	err = yaml.Unmarshal(buf, &cfg)
17+	return
18+}
19+
20+func LoadConfigFile(filename string) (cfg Config, err error) {
21+	var buf []byte
22+	
23+	buf, err = ioutil.ReadFile(filename)
24+	if err != nil {
25+		return 
26+	}
27+	
28+	cfg, err = ParseConfig(buf)
29+
30+	return
31+}
A go.mod
+9, -0
 1@@ -0,0 +1,9 @@
 2+module git.sr.ht/~taiite/senpai
 3+
 4+go 1.14
 5+
 6+require (
 7+	github.com/gdamore/tcell v1.3.0
 8+	github.com/mattn/go-runewidth v0.0.9
 9+	gopkg.in/yaml.v2 v2.3.0
10+)
A go.sum
+20, -0
 1@@ -0,0 +1,20 @@
 2+github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08=
 3+github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
 4+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
 5+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
 6+github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM=
 7+github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
 8+github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4=
 9+github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
10+github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
11+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
12+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
13+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
14+golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
15+golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
16+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
17+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
18+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
19+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
20+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
21+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+52, -0
 1@@ -0,0 +1,52 @@
 2+package irc
 3+
 4+import (
 5+	"strings"
 6+	"time"
 7+)
 8+
 9+type Event interface{}
10+
11+type RegisteredEvent struct{}
12+
13+type UserEvent struct {
14+	Nick string
15+	User string
16+	Host string
17+}
18+
19+func (u UserEvent) NickMapped() (nick string) {
20+	nick = strings.ToLower(u.Nick)
21+	return
22+}
23+
24+type ChannelEvent struct {
25+	Channel string
26+}
27+
28+func (c ChannelEvent) ChannelMapped() (channel string) {
29+	channel = strings.ToLower(c.Channel)
30+	return
31+}
32+
33+type UserJoinEvent struct {
34+	UserEvent
35+	ChannelEvent
36+}
37+
38+type SelfJoinEvent struct {
39+	ChannelEvent
40+}
41+
42+type QueryMessageEvent struct {
43+	UserEvent
44+	Content string
45+	Time    time.Time
46+}
47+
48+type ChannelMessageEvent struct {
49+	UserEvent
50+	ChannelEvent
51+	Content string
52+	Time    time.Time
53+}
+625, -0
  1@@ -0,0 +1,625 @@
  2+package irc
  3+
  4+import (
  5+	"bufio"
  6+	"bytes"
  7+	"encoding/base64"
  8+	"errors"
  9+	"fmt"
 10+	"io"
 11+	"strings"
 12+	"sync/atomic"
 13+	"time"
 14+)
 15+
 16+type SASLClient interface {
 17+	Handshake() (mech string)
 18+	Respond(challenge string) (res string, err error)
 19+}
 20+
 21+type SASLPlain struct {
 22+	Username string
 23+	Password string
 24+}
 25+
 26+func (auth *SASLPlain) Handshake() (mech string) {
 27+	mech = "PLAIN"
 28+	return
 29+}
 30+
 31+func (auth *SASLPlain) Respond(challenge string) (res string, err error) {
 32+	if challenge != "+" {
 33+		err = errors.New("Unexpected challenge")
 34+		return
 35+	}
 36+
 37+	user := []byte(auth.Username)
 38+	pass := []byte(auth.Password)
 39+	payload := bytes.Join([][]byte{user, user, pass}, []byte{0})
 40+	res = base64.StdEncoding.EncodeToString(payload)
 41+
 42+	return
 43+}
 44+
 45+var SupportedCapabilities = map[string]struct{}{
 46+	"account-notify":    {},
 47+	"account-tag":       {},
 48+	"away-notify":       {},
 49+	"batch":             {},
 50+	"cap-notify":        {},
 51+	"echo-message":      {},
 52+	"extended-join":     {},
 53+	"invite-notify":     {},
 54+	"labeled-response":  {},
 55+	"message-tags":      {},
 56+	"multi-prefix":      {},
 57+	"server-time":       {},
 58+	"sasl":              {},
 59+	"setname":           {},
 60+	"userhost-in-names": {},
 61+}
 62+
 63+type ConnectionState int
 64+
 65+const (
 66+	ConnStart ConnectionState = iota
 67+	ConnRegistered
 68+	ConnQuit
 69+)
 70+
 71+type User struct {
 72+	Nick    string
 73+	AwayMsg string
 74+}
 75+
 76+type Channel struct {
 77+	Name      string
 78+	Members   map[string]string
 79+	Topic     string
 80+	TopicWho  string
 81+	TopicTime time.Time
 82+	Secret    bool
 83+}
 84+
 85+type action interface{}
 86+
 87+type (
 88+	actionPrivMsg struct {
 89+		To      string
 90+		Content string
 91+	}
 92+
 93+	actionTyping struct {
 94+		To string
 95+	}
 96+	actionTypingStop struct {
 97+		To string
 98+	}
 99+)
100+
101+type SessionParams struct {
102+	Nickname string
103+	Username string
104+	RealName string
105+
106+	Auth SASLClient
107+}
108+
109+type Session struct {
110+	conn io.ReadWriteCloser
111+	msgs chan Message
112+	acts chan action
113+	evts chan Event
114+
115+	running      atomic.Value // bool
116+	state        ConnectionState
117+	typingStamps map[string]time.Time
118+
119+	nick  string
120+	lNick string
121+	user  string
122+	real  string
123+	acct  string
124+	host  string
125+	auth  SASLClient
126+
127+	mode string
128+	motd string
129+
130+	availableCaps map[string]string
131+	enabledCaps   map[string]struct{}
132+	features      map[string]string
133+
134+	users    map[string]User
135+	channels map[string]Channel
136+}
137+
138+func NewSession(conn io.ReadWriteCloser, params SessionParams) (s Session, err error) {
139+	s = Session{
140+		conn:          conn,
141+		msgs:          make(chan Message, 10),
142+		acts:          make(chan action, 10),
143+		evts:          make(chan Event, 10),
144+		typingStamps:  map[string]time.Time{},
145+		nick:          params.Nickname,
146+		lNick:         strings.ToLower(params.Nickname),
147+		user:          params.Username,
148+		real:          params.RealName,
149+		auth:          params.Auth,
150+		availableCaps: map[string]string{},
151+		enabledCaps:   map[string]struct{}{},
152+		features:      map[string]string{},
153+		users:         map[string]User{},
154+		channels:      map[string]Channel{},
155+	}
156+
157+	s.running.Store(true)
158+
159+	err = s.send("CAP LS 302\r\nNICK %s\r\nUSER %s 0 * :%s\r\n", s.nick, s.user, s.real)
160+	if err != nil {
161+		return
162+	}
163+
164+	go func() {
165+		r := bufio.NewScanner(conn)
166+
167+		for r.Scan() {
168+			line := r.Text()
169+			//fmt.Println(" > ", line)
170+
171+			msg, err := Tokenize(line)
172+			if err != nil {
173+				continue
174+			}
175+
176+			err = msg.Validate()
177+			if err != nil {
178+				continue
179+			}
180+
181+			s.msgs <- msg
182+		}
183+
184+		s.Stop()
185+	}()
186+
187+	go s.run()
188+
189+	return
190+}
191+
192+func (s *Session) Running() bool {
193+	return s.running.Load().(bool)
194+}
195+
196+func (s *Session) Stop() {
197+	s.running.Store(false)
198+	s.conn.Close()
199+}
200+
201+func (s *Session) Poll() (events <-chan Event) {
202+	return s.evts
203+}
204+
205+func (s *Session) IsChannel(name string) bool {
206+	return strings.IndexAny(name, "#&") == 0 // TODO compute CHANTYPES
207+}
208+
209+func (s *Session) PrivMsg(to, content string) {
210+	s.acts <- actionPrivMsg{to, content}
211+}
212+
213+func (s *Session) privMsg(act actionPrivMsg) (err error) {
214+	err = s.send("PRIVMSG %s :%s\r\n", act.To, act.Content)
215+	return
216+}
217+
218+func (s *Session) Typing(to string) {
219+	s.acts <- actionTyping{to}
220+}
221+
222+func (s *Session) typing(act actionTyping) (err error) {
223+	if _, ok := s.enabledCaps["message-tags"]; !ok {
224+		return
225+	}
226+
227+	to := strings.ToLower(act.To)
228+
229+	if t, ok := s.typingStamps[to]; ok && time.Now().Sub(t) < 3 {
230+		return
231+	}
232+
233+	err = s.send("@+typing=active TAGMSG %s\r\n", act.To)
234+	return
235+}
236+
237+func (s *Session) TypingStop(to string) {
238+	s.acts <- actionTypingStop{to}
239+}
240+
241+func (s *Session) typingStop(act actionTypingStop) (err error) {
242+	if _, ok := s.enabledCaps["message-tags"]; !ok {
243+		return
244+	}
245+
246+	err = s.send("@+typing=done TAGMSG %s\r\n", act.To)
247+	return
248+}
249+
250+func (s *Session) run() {
251+	for s.Running() {
252+		var (
253+			ev  Event
254+			err error
255+		)
256+
257+		select {
258+		case act := <-s.acts:
259+			switch act := act.(type) {
260+			case actionPrivMsg:
261+				err = s.privMsg(act)
262+			case actionTyping:
263+				err = s.typing(act)
264+			case actionTypingStop:
265+				err = s.typingStop(act)
266+			}
267+		case msg := <-s.msgs:
268+			if s.state == ConnStart {
269+				ev, err = s.handleStart(msg)
270+			} else if s.state == ConnRegistered {
271+				ev, err = s.handle(msg)
272+			}
273+		}
274+
275+		if ev != nil {
276+			s.evts <- ev
277+		}
278+		if err != nil {
279+			s.evts <- err
280+		}
281+	}
282+}
283+
284+func (s *Session) handleStart(msg Message) (ev Event, err error) {
285+	switch msg.Command {
286+	case "AUTHENTICATE":
287+		if s.auth != nil {
288+			var res string
289+
290+			res, err = s.auth.Respond(msg.Params[0])
291+			if err != nil {
292+				err = s.send("AUTHENTICATE *\r\n")
293+				return
294+			}
295+
296+			err = s.send("AUTHENTICATE %s\r\n", res)
297+			if err != nil {
298+				return
299+			}
300+		}
301+	case "900":
302+		err = s.send("CAP END\r\n")
303+		if err != nil {
304+			return
305+		}
306+
307+		s.acct = msg.Params[2]
308+		_, _, s.host = FullMask(msg.Params[1])
309+	case "902", "904", "905", "906", "907", "908":
310+		err = s.send("CAP END\r\n")
311+		if err != nil {
312+			return
313+		}
314+	case "CAP":
315+		switch msg.Params[1] {
316+		case "LS":
317+			var willContinue bool
318+			var ls string
319+
320+			if msg.Params[2] == "*" {
321+				willContinue = true
322+				ls = msg.Params[3]
323+			} else {
324+				willContinue = false
325+				ls = msg.Params[2]
326+			}
327+
328+			for _, c := range TokenizeCaps(ls) {
329+				if c.Enable {
330+					s.availableCaps[c.Name] = c.Value
331+				} else {
332+					delete(s.availableCaps, c.Name)
333+				}
334+			}
335+
336+			if !willContinue {
337+				var req strings.Builder
338+
339+				for c := range s.availableCaps {
340+					if _, ok := SupportedCapabilities[c]; !ok {
341+						continue
342+					}
343+
344+					_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c)
345+				}
346+
347+				_, ok := s.availableCaps["sasl"]
348+				if s.auth == nil || !ok {
349+					_, _ = fmt.Fprintf(&req, "CAP END\r\n")
350+				}
351+
352+				err = s.send(req.String())
353+				if err != nil {
354+					return
355+				}
356+			}
357+		case "ACK":
358+			for _, c := range strings.Split(msg.Params[2], " ") {
359+				s.enabledCaps[c] = struct{}{}
360+
361+				if s.auth != nil && c == "sasl" {
362+					h := s.auth.Handshake()
363+					err = s.send("AUTHENTICATE %s\r\n", h)
364+					if err != nil {
365+						return
366+					}
367+				}
368+			}
369+		}
370+	case "372": // RPL_MOTD
371+		s.motd += "\n" + strings.TrimPrefix(msg.Params[1], "- ")
372+	case "433": // ERR_NICKNAMEINUSE
373+		s.nick = s.nick + "_"
374+
375+		err = s.send("NICK %s\r\n", s.nick)
376+		if err != nil {
377+			return
378+		}
379+	default:
380+		ev, err = s.handle(msg)
381+	}
382+
383+	return
384+}
385+
386+func (s *Session) handle(msg Message) (ev Event, err error) {
387+	switch msg.Command {
388+	case "001": // RPL_WELCOME
389+		s.nick = msg.Params[0]
390+		s.lNick = strings.ToLower(s.nick)
391+		s.state = ConnRegistered
392+		ev = RegisteredEvent{}
393+
394+		if s.host == "" {
395+			err = s.send("WHO %s\r\n", s.nick)
396+			if err != nil {
397+				return
398+			}
399+		}
400+	case "005": // RPL_ISUPPORT
401+		s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
402+	case "352": // RPL_WHOREPLY
403+		if s.lNick == strings.ToLower(msg.Params[5]) {
404+			s.host = msg.Params[3]
405+		}
406+	case "CAP":
407+		switch msg.Params[1] {
408+		case "ACK":
409+			for _, c := range strings.Split(msg.Params[2], " ") {
410+				s.enabledCaps[c] = struct{}{}
411+			}
412+		case "NAK":
413+			for _, c := range strings.Split(msg.Params[2], " ") {
414+				delete(s.enabledCaps, c)
415+			}
416+		case "NEW":
417+			diff := TokenizeCaps(msg.Params[2])
418+
419+			for _, c := range diff {
420+				if c.Enable {
421+					s.availableCaps[c.Name] = c.Value
422+				} else {
423+					delete(s.availableCaps, c.Name)
424+				}
425+			}
426+
427+			var req strings.Builder
428+
429+			for _, c := range diff {
430+				_, ok := SupportedCapabilities[c.Name]
431+				if !c.Enable || !ok {
432+					continue
433+				}
434+
435+				_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
436+			}
437+
438+			_, ok := s.availableCaps["sasl"]
439+			if s.acct == "" && ok {
440+				// TODO authenticate
441+			}
442+
443+			err = s.send(req.String())
444+			if err != nil {
445+				return
446+			}
447+		case "DEL":
448+			diff := TokenizeCaps(msg.Params[2])
449+
450+			for i := range diff {
451+				diff[i].Enable = !diff[i].Enable
452+			}
453+
454+			for _, c := range diff {
455+				if c.Enable {
456+					s.availableCaps[c.Name] = c.Value
457+				} else {
458+					delete(s.availableCaps, c.Name)
459+				}
460+			}
461+
462+			var req strings.Builder
463+
464+			for _, c := range diff {
465+				_, ok := SupportedCapabilities[c.Name]
466+				if !c.Enable || !ok {
467+					continue
468+				}
469+
470+				_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
471+			}
472+
473+			_, ok := s.availableCaps["sasl"]
474+			if s.acct == "" && ok {
475+				// TODO authenticate
476+			}
477+
478+			err = s.send(req.String())
479+			if err != nil {
480+				return
481+			}
482+		}
483+	case "JOIN":
484+		nick, _, _ := FullMask(msg.Prefix)
485+		lNick := strings.ToLower(nick)
486+		channel := strings.ToLower(msg.Params[0])
487+		channelEv := ChannelEvent{Channel: msg.Params[0]}
488+
489+		if lNick == s.lNick {
490+			s.channels[channel] = Channel{
491+				Name:    msg.Params[0],
492+				Members: map[string]string{},
493+			}
494+		} else if c, ok := s.channels[channel]; ok {
495+			if _, ok := s.users[lNick]; !ok {
496+				s.users[lNick] = User{Nick: nick}
497+			}
498+			c.Members[lNick] = ""
499+			ev = UserJoinEvent{ChannelEvent: channelEv, UserEvent: UserEvent{Nick: nick}}
500+		}
501+	case "353": // RPL_NAMREPLY
502+		channel := strings.ToLower(msg.Params[2])
503+
504+		if c, ok := s.channels[channel]; ok {
505+			c.Secret = msg.Params[1] == "@"
506+			names := TokenizeNames(msg.Params[3], "~&@%+") // TODO compute prefixes
507+
508+			for _, name := range names {
509+				nick := name.Nick
510+				lNick := strings.ToLower(nick)
511+
512+				if _, ok := s.users[lNick]; !ok {
513+					s.users[lNick] = User{Nick: nick}
514+				}
515+				c.Members[lNick] = name.PowerLevel
516+			}
517+		}
518+	case "366": // RPL_ENDOFNAMES
519+		ev = SelfJoinEvent{ChannelEvent{Channel: msg.Params[1]}}
520+	case "332": // RPL_TOPIC
521+		channel := strings.ToLower(msg.Params[1])
522+
523+		if c, ok := s.channels[channel]; ok {
524+			c.Topic = msg.Params[2]
525+		}
526+	case "PRIVMSG":
527+		nick, _, _ := FullMask(msg.Prefix)
528+		target := strings.ToLower(msg.Params[0])
529+
530+		if target == s.lNick {
531+			// PRIVMSG to self
532+			t, ok := msg.Time()
533+			if !ok {
534+				t = time.Now()
535+			}
536+			ev = QueryMessageEvent{
537+				UserEvent: UserEvent{Nick: nick},
538+				Content:   msg.Params[1],
539+				Time:      t,
540+			}
541+		} else if _, ok := s.channels[target]; ok {
542+			// PRIVMSG to channel
543+			t, ok := msg.Time()
544+			if !ok {
545+				t = time.Now()
546+			}
547+			ev = ChannelMessageEvent{
548+				UserEvent:    UserEvent{Nick: nick},
549+				ChannelEvent: ChannelEvent{Channel: msg.Params[0]},
550+				Content:      msg.Params[1],
551+				Time:         t,
552+			}
553+		}
554+	case "PING":
555+		err = s.send("PONG :%s\r\n", msg.Params[0])
556+		if err != nil {
557+			return
558+		}
559+	case "ERROR":
560+		err = errors.New("connection terminated")
561+		if len(msg.Params) > 0 {
562+			err = fmt.Errorf("connection terminated: %s", msg.Params[0])
563+		}
564+		s.state = ConnQuit
565+	default:
566+	}
567+	return
568+}
569+
570+func (s *Session) updateFeatures(features []string) {
571+	for _, f := range features {
572+		if f == "" || f == "-" || f == "=" || f == "-=" {
573+			continue
574+		}
575+
576+		var (
577+			add   bool
578+			key   string
579+			value string
580+		)
581+
582+		if strings.HasPrefix(f, "-") {
583+			add = false
584+			f = f[1:]
585+		} else {
586+			add = true
587+		}
588+
589+		kv := strings.SplitN(f, "=", 2)
590+		key = strings.ToUpper(kv[0])
591+		if len(kv) > 1 {
592+			value = kv[1]
593+		}
594+
595+		if add {
596+			s.features[key] = value
597+		} else {
598+			delete(s.features, key)
599+		}
600+	}
601+}
602+
603+/*
604+func (cli *Session) send(format string, args ...interface{}) (err error) {
605+	msg := fmt.Sprintf(format, args...)
606+
607+	for _, line := range strings.Split(msg, "\r\n") {
608+		if line != "" {
609+			fmt.Println("<  ", line)
610+		}
611+	}
612+
613+	_, err = cli.conn.Write([]byte(msg))
614+
615+	return
616+}
617+
618+// */
619+
620+//*
621+func (s *Session) send(format string, args ...interface{}) (err error) {
622+	_, err = fmt.Fprintf(s.conn, format, args...)
623+	return
624+}
625+
626+// */
+327, -0
  1@@ -0,0 +1,327 @@
  2+package irc
  3+
  4+import (
  5+	"errors"
  6+	"fmt"
  7+	"strings"
  8+	"time"
  9+)
 10+
 11+func word(s string) (w, rest string) {
 12+	split := strings.SplitN(s, " ", 2)
 13+
 14+	if len(split) < 2 {
 15+		w = split[0]
 16+		rest = ""
 17+	} else {
 18+		w = split[0]
 19+		rest = split[1]
 20+	}
 21+
 22+	return
 23+}
 24+
 25+func tagEscape(c rune) (escape rune) {
 26+	switch c {
 27+	case ':':
 28+		escape = ';'
 29+	case 's':
 30+		escape = ' '
 31+	case 'r':
 32+		escape = '\r'
 33+	case 'n':
 34+		escape = '\n'
 35+	default:
 36+		escape = c
 37+	}
 38+
 39+	return
 40+}
 41+
 42+func unescapeTagValue(escaped string) (unescaped string) {
 43+	var builder strings.Builder
 44+	builder.Grow(len(escaped))
 45+	escape := false
 46+
 47+	for _, c := range escaped {
 48+		if c == '\\' && !escape {
 49+			escape = true
 50+		} else {
 51+			var cpp rune
 52+
 53+			if escape {
 54+				cpp = tagEscape(c)
 55+			} else {
 56+				cpp = c
 57+			}
 58+
 59+			builder.WriteRune(cpp)
 60+			escape = false
 61+		}
 62+	}
 63+
 64+	unescaped = builder.String()
 65+	return
 66+}
 67+
 68+func parseTags(s string) (tags map[string]string) {
 69+	s = s[1:]
 70+	tags = map[string]string{}
 71+
 72+	for _, item := range strings.Split(s, ";") {
 73+		if item == "" || item == "=" || item == "+" || item == "+=" {
 74+			continue
 75+		}
 76+
 77+		kv := strings.SplitN(item, "=", 2)
 78+		if len(kv) < 2 {
 79+			tags[kv[0]] = ""
 80+		} else {
 81+			tags[kv[0]] = unescapeTagValue(kv[1])
 82+		}
 83+	}
 84+
 85+	return
 86+}
 87+
 88+var (
 89+	errEmptyMessage      = errors.New("empty message")
 90+	errIncompleteMessage = errors.New("message is incomplete")
 91+)
 92+
 93+var (
 94+	errNoPrefix        = errors.New("missing prefix")
 95+	errNotEnoughParams = errors.New("not enough params")
 96+	errUnknownCommand  = errors.New("unknown command")
 97+)
 98+
 99+type Message struct {
100+	Tags    map[string]string
101+	Prefix  string
102+	Command string
103+	Params  []string
104+}
105+
106+func Tokenize(line string) (msg Message, err error) {
107+	line = strings.TrimLeft(line, " ")
108+	if line == "" {
109+		err = errEmptyMessage
110+		return
111+	}
112+
113+	if line[0] == '@' {
114+		var tags string
115+
116+		tags, line = word(line)
117+		msg.Tags = parseTags(tags)
118+	}
119+
120+	line = strings.TrimLeft(line, " ")
121+	if line == "" {
122+		err = errIncompleteMessage
123+		return
124+	}
125+
126+	if line[0] == ':' {
127+		var prefix string
128+
129+		prefix, line = word(line)
130+		msg.Prefix = prefix[1:]
131+	}
132+
133+	line = strings.TrimLeft(line, " ")
134+	if line == "" {
135+		err = errIncompleteMessage
136+		return
137+	}
138+
139+	msg.Command, line = word(line)
140+	msg.Command = strings.ToUpper(msg.Command)
141+
142+	msg.Params = make([]string, 0, 15)
143+	for line != "" {
144+		if line[0] == ':' {
145+			msg.Params = append(msg.Params, line[1:])
146+			break
147+		}
148+
149+		var param string
150+		param, line = word(line)
151+		msg.Params = append(msg.Params, param)
152+	}
153+
154+	return
155+}
156+
157+func (msg *Message) Validate() (err error) {
158+	switch msg.Command {
159+	case "001":
160+		if len(msg.Params) < 1 {
161+			err = errNotEnoughParams
162+		}
163+	case "005":
164+		if len(msg.Params) < 3 {
165+			err = errNotEnoughParams
166+		}
167+	case "352":
168+		if len(msg.Params) < 8 {
169+			err = errNotEnoughParams
170+		}
171+	case "372":
172+		if len(msg.Params) < 2 {
173+			err = errNotEnoughParams
174+		}
175+	case "AUTHENTICATE":
176+		if len(msg.Params) < 1 {
177+			err = errNotEnoughParams
178+		}
179+	case "900":
180+		if len(msg.Params) < 3 {
181+			err = errNotEnoughParams
182+		}
183+	case "901":
184+		if len(msg.Params) < 2 {
185+			err = errNotEnoughParams
186+		}
187+	case "CAP":
188+		if len(msg.Params) < 3 {
189+			err = errNotEnoughParams
190+		} else if msg.Params[1] == "LS" {
191+		} else if msg.Params[1] == "LIST" {
192+		} else if msg.Params[1] == "ACK" {
193+		} else if msg.Params[1] == "NAK" {
194+		} else if msg.Params[1] == "NEW" {
195+		} else if msg.Params[1] == "DEL" {
196+		} else {
197+			err = errUnknownCommand
198+		}
199+	case "JOIN":
200+		if len(msg.Params) < 1 {
201+			err = errNotEnoughParams
202+		} else if msg.Prefix == "" {
203+			err = errNoPrefix
204+		}
205+	case "353":
206+		if len(msg.Params) < 4 {
207+			err = errNotEnoughParams
208+		}
209+	case "332":
210+		if len(msg.Params) < 3 {
211+			err = errNotEnoughParams
212+		}
213+	case "TOPIC":
214+		if len(msg.Params) < 2 {
215+			err = errNotEnoughParams
216+		}
217+	case "PING":
218+		if len(msg.Params) < 1 {
219+			err = errNotEnoughParams
220+		}
221+	case "PONG":
222+		if len(msg.Params) < 1 {
223+			err = errNotEnoughParams
224+		}
225+	default:
226+	}
227+	return
228+}
229+
230+func (msg *Message) Time() (t time.Time, ok bool) {
231+	var tag string
232+	var year, month, day, hour, minute, second, millis int
233+
234+	tag, ok = msg.Tags["time"]
235+	if !ok {
236+		return
237+	}
238+
239+	tag = strings.TrimSuffix(tag, "Z")
240+
241+	_, err := fmt.Sscanf(tag, "%4d-%2d-%2dT%2d:%2d:%2d.%3d", &year, &month, &day, &hour, &minute, &second, &millis)
242+	if err != nil || month < 1 || 12 < month {
243+		ok = false
244+		return
245+	}
246+
247+	t = time.Date(year, time.Month(month), day, hour, minute, second, millis*1000000, time.Local)
248+
249+	return
250+}
251+
252+func FullMask(s string) (nick, user, host string) {
253+	if s == "" {
254+		return
255+	}
256+
257+	spl0 := strings.Split(s, "@")
258+	if 1 < len(spl0) {
259+		host = spl0[1]
260+	}
261+
262+	spl1 := strings.Split(spl0[0], "!")
263+	if 1 < len(spl1) {
264+		user = spl1[1]
265+	}
266+
267+	nick = spl1[0]
268+
269+	return
270+}
271+
272+type Cap struct {
273+	Name   string
274+	Value  string
275+	Enable bool
276+}
277+
278+func TokenizeCaps(caps string) (diff []Cap) {
279+	for _, c := range strings.Split(caps, " ") {
280+		if c == "" || c == "-" || c == "=" || c == "-=" {
281+			continue
282+		}
283+
284+		var item Cap
285+
286+		if strings.HasPrefix(c, "-") {
287+			item.Enable = false
288+			c = c[1:]
289+		} else {
290+			item.Enable = true
291+		}
292+
293+		kv := strings.SplitN(c, "=", 2)
294+		item.Name = strings.ToLower(kv[0])
295+		if len(kv) > 1 {
296+			item.Value = kv[1]
297+		}
298+
299+		diff = append(diff, item)
300+	}
301+
302+	return
303+}
304+
305+type Name struct {
306+	PowerLevel string
307+	Nick       string
308+	User       string
309+	Host       string
310+}
311+
312+func TokenizeNames(trailing string, prefixes string) (names []Name) {
313+	for _, name := range strings.Split(trailing, " ") {
314+		if name == "" {
315+			continue
316+		}
317+
318+		var item Name
319+
320+		mask := strings.TrimLeft(name, prefixes)
321+		item.Nick, item.User, item.Host = FullMask(mask)
322+		item.PowerLevel = name[:len(name)-len(mask)]
323+
324+		names = append(names, item)
325+	}
326+
327+	return
328+}
+107, -0
  1@@ -0,0 +1,107 @@
  2+package ui
  3+
  4+import "time"
  5+
  6+var homeMessages = []string{
  7+	"\x1dYou open an IRC client.",
  8+	"Welcome to the Internet Relay Network!",
  9+	"Mentions & cie go here.",
 10+	"May the IRC be with you.",
 11+	"Hey! I'm senpai, you every IRC student!",
 12+	"Student? No, I'm an IRC \x02client\x02!",
 13+}
 14+
 15+type Line struct {
 16+	Time     time.Time
 17+	IsStatus bool
 18+	Content  string
 19+}
 20+
 21+type Buffer struct {
 22+	Title      string
 23+	Highlights int
 24+	Content    []Line
 25+}
 26+
 27+type BufferList struct {
 28+	List    []Buffer
 29+	Current int
 30+}
 31+
 32+func (bs *BufferList) Add(title string) (pos int, ok bool) {
 33+	for i, b := range bs.List {
 34+		if b.Title == title {
 35+			pos = i
 36+			return
 37+		}
 38+	}
 39+
 40+	pos = len(bs.List)
 41+	ok = true
 42+	bs.List = append(bs.List, Buffer{Title: title})
 43+
 44+	return
 45+}
 46+
 47+func (bs *BufferList) Remove(title string) (ok bool) {
 48+	for i, b := range bs.List {
 49+		if b.Title == title {
 50+			ok = true
 51+			bs.List = append(bs.List[:i], bs.List[i+1:]...)
 52+
 53+			if i == bs.Current {
 54+				bs.Current = 0
 55+			}
 56+
 57+			return
 58+		}
 59+	}
 60+
 61+	return
 62+}
 63+
 64+func (bs *BufferList) Previous() (ok bool) {
 65+	if bs.Current <= 0 {
 66+		ok = false
 67+	} else {
 68+		bs.Current--
 69+		ok = true
 70+	}
 71+
 72+	return
 73+}
 74+
 75+func (bs *BufferList) Next() (ok bool) {
 76+	if bs.Current+1 < len(bs.List) {
 77+		bs.Current++
 78+		ok = true
 79+	} else {
 80+		ok = false
 81+	}
 82+
 83+	return
 84+}
 85+
 86+func (bs *BufferList) Idx(title string) (idx int) {
 87+	if title == "" {
 88+		idx = 0
 89+		return
 90+	}
 91+
 92+	for pos, b := range bs.List {
 93+		if b.Title == title {
 94+			idx = pos
 95+			return
 96+		}
 97+	}
 98+
 99+	idx = -1
100+	return
101+}
102+
103+func (bs *BufferList) AddLine(idx int, line string, t time.Time) {
104+	bs.List[idx].Content = append(bs.List[idx].Content, Line{
105+		Time:    t,
106+		Content: line,
107+	})
108+}
+494, -0
  1@@ -0,0 +1,494 @@
  2+package ui
  3+
  4+import (
  5+	"github.com/gdamore/tcell"
  6+	"github.com/mattn/go-runewidth"
  7+	"sync/atomic"
  8+	"time"
  9+)
 10+
 11+type UI struct {
 12+	screen tcell.Screen
 13+	Events chan tcell.Event
 14+	exit   atomic.Value // bool
 15+
 16+	bufferList BufferList
 17+	textInput  []rune
 18+	textCursor int
 19+}
 20+
 21+func New() (ui *UI, err error) {
 22+	ui = &UI{}
 23+
 24+	ui.screen, err = tcell.NewScreen()
 25+	if err != nil {
 26+		return
 27+	}
 28+
 29+	err = ui.screen.Init()
 30+	if err != nil {
 31+		return
 32+	}
 33+
 34+	_, h := ui.screen.Size()
 35+	ui.screen.Clear()
 36+	ui.screen.ShowCursor(0, h-2)
 37+
 38+	ui.Events = make(chan tcell.Event)
 39+	go func() {
 40+		for !ui.ShouldExit() {
 41+			ui.Events <- ui.screen.PollEvent()
 42+		}
 43+	}()
 44+
 45+	ui.exit.Store(false)
 46+
 47+	ui.bufferList = BufferList{
 48+		List: []Buffer{
 49+			{
 50+				Title:      "home",
 51+				Highlights: 0,
 52+				Content: []Line{{
 53+					Time:    time.Now(),
 54+					Content: homeMessages[0],
 55+				}},
 56+			},
 57+		},
 58+	}
 59+
 60+	ui.textInput = []rune{}
 61+
 62+	ui.Draw()
 63+
 64+	return
 65+}
 66+
 67+func (ui *UI) ShouldExit() bool {
 68+	return ui.exit.Load().(bool)
 69+}
 70+
 71+func (ui *UI) Exit() {
 72+	ui.exit.Store(true)
 73+}
 74+
 75+func (ui *UI) Close() {
 76+	ui.screen.Fini()
 77+}
 78+
 79+func (ui *UI) CurrentBuffer() (title string) {
 80+	title = ui.bufferList.List[ui.bufferList.Current].Title
 81+	return
 82+}
 83+
 84+func (ui *UI) NextBuffer() {
 85+	ok := ui.bufferList.Next()
 86+	if ok {
 87+		ui.drawBuffer()
 88+		ui.drawStatus()
 89+	}
 90+}
 91+
 92+func (ui *UI) PreviousBuffer() {
 93+	ok := ui.bufferList.Previous()
 94+	if ok {
 95+		ui.drawBuffer()
 96+		ui.drawStatus()
 97+	}
 98+}
 99+
100+func (ui *UI) AddBuffer(title string) {
101+	_, ok := ui.bufferList.Add(title)
102+	if ok {
103+		ui.drawStatus()
104+		ui.drawBuffer() // TODO only invalidate buffer list
105+	}
106+}
107+
108+func (ui *UI) RemoveBuffer(title string) {
109+	ok := ui.bufferList.Remove(title)
110+	if ok {
111+		ui.drawStatus()
112+		ui.drawBuffer()
113+	}
114+}
115+
116+func (ui *UI) AddLine(buffer string, line string, t time.Time) {
117+	idx := ui.bufferList.Idx(buffer)
118+	if idx < 0 {
119+		return
120+	}
121+
122+	ui.bufferList.AddLine(idx, line, t)
123+
124+	if idx == ui.bufferList.Current {
125+		ui.drawBuffer()
126+	}
127+}
128+
129+func (ui *UI) InputRune(r rune) {
130+	ui.textInput = append(ui.textInput, r)
131+	ui.textCursor++
132+	ui.drawEditor()
133+}
134+
135+func (ui *UI) InputRight() {
136+	if ui.textCursor < len(ui.textInput) {
137+		ui.textCursor++
138+		ui.drawEditor()
139+	}
140+}
141+
142+func (ui *UI) InputLeft() {
143+	if 0 < ui.textCursor {
144+		ui.textCursor--
145+		ui.drawEditor()
146+	}
147+}
148+
149+func (ui *UI) InputBackspace() {
150+	if 0 < len(ui.textInput) {
151+		ui.textInput = ui.textInput[:len(ui.textInput)-1]
152+		if len(ui.textInput) < ui.textCursor {
153+			ui.textCursor = len(ui.textInput)
154+		}
155+		ui.drawEditor()
156+	}
157+}
158+
159+func (ui *UI) InputEnter() (content string) {
160+	content = string(ui.textInput)
161+
162+	ui.textInput = []rune{}
163+	ui.textCursor = 0
164+	ui.drawEditor()
165+
166+	return
167+}
168+
169+func (ui *UI) Draw() {
170+	ui.drawStatus()
171+	ui.drawEditor()
172+	ui.drawBuffer()
173+}
174+
175+func (ui *UI) drawEditor() {
176+	st := tcell.StyleDefault
177+	w, h := ui.screen.Size()
178+	if w == 0 {
179+		return
180+	}
181+
182+	s := string(ui.textInput)
183+	sw := runewidth.StringWidth(s)
184+
185+	x := 0
186+	y := h - 2
187+	i := 0
188+
189+	for ; w < sw+1 && i < len(ui.textInput); i++ {
190+		r := ui.textInput[i]
191+		rw := runewidth.RuneWidth(r)
192+		sw -= rw
193+	}
194+
195+	for ; i < len(ui.textInput); i++ {
196+		r := ui.textInput[i]
197+		ui.screen.SetContent(x, y, r, nil, st)
198+		x += runewidth.RuneWidth(r)
199+	}
200+
201+	for ; x < w; x++ {
202+		ui.screen.SetContent(x, y, ' ', nil, st)
203+	}
204+
205+	ui.screen.ShowCursor(ui.textCursor, y)
206+	ui.screen.Show()
207+}
208+
209+func (ui *UI) drawBuffer() {
210+	st := tcell.StyleDefault
211+	w, h := ui.screen.Size()
212+	if h < 2 {
213+		return
214+	}
215+
216+	for x := 0; x < w; x++ {
217+		for y := 0; y < h-2; y++ {
218+			ui.screen.SetContent(x, y, ' ', nil, st)
219+		}
220+	}
221+
222+	b := ui.bufferList.List[ui.bufferList.Current]
223+
224+	if len(b.Content) == 0 {
225+		return
226+	}
227+
228+	y := h - 2
229+	start := len(b.Content) - 1
230+	for {
231+		lw := runewidth.StringWidth(b.Content[start].Content)
232+
233+		if y-(lw/w+1) < 0 {
234+			break
235+		}
236+
237+		y -= lw/w + 1
238+
239+		if start <= 0 {
240+			break
241+		}
242+		start--
243+	}
244+	if start > 0 {
245+		start++
246+	}
247+
248+	var bold, italic, underline bool
249+	var colorState int
250+	var fgColor, bgColor int
251+
252+	var lastHour, lastMinute int
253+
254+	for _, line := range b.Content[start:] {
255+		rs := []rune(line.Content)
256+		x := 0
257+
258+		hour := line.Time.Hour()
259+		minute := line.Time.Minute()
260+
261+		if hour != lastHour || minute != lastMinute {
262+			t := []rune{rune(hour/10) + '0', rune(hour%10) + '0', ':', rune(minute/10) + '0', rune(minute%10) + '0', ' '}
263+			tst := tcell.StyleDefault.Bold(true)
264+
265+			for _, r := range t {
266+				if w <= x {
267+					y++
268+					x = 0
269+				}
270+				if h-2 <= y {
271+					break
272+				}
273+
274+				ui.screen.SetContent(x, y, r, nil, tst)
275+				x += runewidth.RuneWidth(r)
276+			}
277+
278+			lastHour = hour
279+			lastMinute = minute
280+		}
281+
282+		for _, r := range rs {
283+			if w <= x {
284+				y++
285+				x = 0
286+			}
287+			if h-2 <= y {
288+				break
289+			}
290+
291+			if colorState == 1 {
292+				fgColor = 0
293+				bgColor = 0
294+				if '0' <= r && r <= '9' {
295+					fgColor = fgColor*10 + int(r-'0')
296+					colorState = 2
297+					continue
298+				}
299+				st = st.Foreground(tcell.ColorDefault)
300+				st = st.Background(tcell.ColorDefault)
301+				colorState = 0
302+			} else if colorState == 2 {
303+				if '0' <= r && r <= '9' {
304+					fgColor = fgColor*10 + int(r-'0')
305+					colorState = 3
306+					continue
307+				}
308+				if r == ',' {
309+					colorState = 4
310+					continue
311+				}
312+				c := colorFromCode(fgColor)
313+				st = st.Foreground(c)
314+				colorState = 0
315+			} else if colorState == 3 {
316+				if r == ',' {
317+					colorState = 4
318+					continue
319+				}
320+				c := colorFromCode(fgColor)
321+				st = st.Foreground(c)
322+				colorState = 0
323+			} else if colorState == 4 {
324+				if '0' <= r && r <= '9' {
325+					bgColor = bgColor*10 + int(r-'0')
326+					colorState = 5
327+					continue
328+				}
329+
330+				c := colorFromCode(fgColor)
331+				st = st.Foreground(c)
332+				colorState = 0
333+
334+				ui.screen.SetContent(x, y, ',', nil, st)
335+				x++
336+			} else if colorState == 5 {
337+				if '0' <= r && r <= '9' {
338+					bgColor = bgColor*10 + int(r-'0')
339+					colorState = 6
340+					continue
341+				}
342+
343+				st = st.Foreground(colorFromCode(fgColor))
344+				st = st.Background(colorFromCode(bgColor))
345+				colorState = 0
346+			} else if colorState == 6 {
347+				st = st.Foreground(colorFromCode(fgColor))
348+				st = st.Background(colorFromCode(bgColor))
349+				colorState = 0
350+			}
351+
352+			if r == 0x00 {
353+				bold = false
354+				italic = false
355+				underline = false
356+				st = st.Normal()
357+				continue
358+			}
359+			if r == 0x02 {
360+				bold = !bold
361+				st = st.Bold(bold)
362+				continue
363+			}
364+			if r == 0x03 {
365+				colorState = 1
366+				continue
367+			}
368+			if r == 0x1D {
369+				italic = !italic
370+				//st = st.Italic(italic)
371+				continue
372+			}
373+			if r == 0x1F {
374+				underline = !underline
375+				//st = st.Underline(underline)
376+				continue
377+			}
378+
379+			ui.screen.SetContent(x, y, r, nil, st)
380+			x += runewidth.RuneWidth(r)
381+		}
382+
383+		y++
384+		st = tcell.StyleDefault
385+		bold = false
386+		italic = false
387+		underline = false
388+		colorState = 0
389+		if h-2 <= y {
390+			break
391+		}
392+	}
393+
394+	ui.screen.Show()
395+}
396+
397+func (ui *UI) drawStatus() {
398+	st := tcell.StyleDefault
399+	w, h := ui.screen.Size()
400+
401+	x := 0
402+	y := h - 1
403+
404+	l := 0
405+	start := 0
406+	for _, b := range ui.bufferList.List {
407+		if 0 < b.Highlights {
408+			l += 3 // TODO
409+		}
410+
411+		sw := runewidth.StringWidth(b.Title) + 1
412+		l += sw
413+	}
414+
415+	if w < l && ui.bufferList.Current > 0 {
416+		start = ui.bufferList.Current - 1
417+	}
418+
419+	for i := start; i < len(ui.bufferList.List); i++ {
420+		b := ui.bufferList.List[i]
421+
422+		if i == ui.bufferList.Current {
423+			st = st.Underline(true)
424+		}
425+		if 0 < b.Highlights {
426+			st = st.Bold(true)
427+		}
428+
429+		rs := []rune(b.Title)
430+		for _, r := range rs {
431+			if w <= x {
432+				break
433+			}
434+
435+			ui.screen.SetContent(x, y, r, nil, st)
436+			x += runewidth.RuneWidth(r)
437+		}
438+
439+		if w+1 <= x {
440+			break
441+		}
442+
443+		st = st.Normal()
444+
445+		ui.screen.SetContent(x, y, ' ', nil, st)
446+		x++
447+	}
448+
449+	for ; x < w; x++ {
450+		ui.screen.SetContent(x, y, ' ', nil, st)
451+	}
452+
453+	ui.screen.Show()
454+}
455+
456+func colorFromCode(code int) (color tcell.Color) {
457+	switch code {
458+	case 0:
459+		color = tcell.ColorWhite
460+	case 1:
461+		color = tcell.ColorBlack
462+	case 2:
463+		color = tcell.ColorBlue
464+	case 3:
465+		color = tcell.ColorGreen
466+	case 4:
467+		color = tcell.ColorRed
468+	case 5:
469+		color = tcell.ColorBrown
470+	case 6:
471+		color = tcell.ColorFuchsia
472+	case 7:
473+		color = tcell.ColorOrange
474+	case 8:
475+		color = tcell.ColorYellow
476+	case 9:
477+		color = tcell.ColorLime
478+	case 10:
479+		color = tcell.ColorAqua
480+	case 11:
481+		color = tcell.ColorLightCyan
482+	case 12:
483+		color = tcell.ColorLightBlue
484+	case 13:
485+		color = tcell.ColorPink
486+	case 14:
487+		color = tcell.ColorGrey
488+	case 15:
489+		color = tcell.ColorLightGrey
490+	default:
491+		color = tcell.Color(code)
492+	}
493+
494+	return
495+}