commit 287e408

Hubert Hirtz  ·  2021-05-26 10:44:35 +0000 UTC
parent 98550d8
Pick nick colors in terminal color scheme

So that the colors go well with the terminal background.
9 files changed,  +515, -372
M app.go
M app.go
+105, -69
  1@@ -8,6 +8,7 @@ import (
  2 	"os/exec"
  3 	"strings"
  4 	"time"
  5+	"unicode"
  6 
  7 	"git.sr.ht/~taiite/senpai/irc"
  8 	"git.sr.ht/~taiite/senpai/ui"
  9@@ -69,7 +70,7 @@ func NewApp(cfg Config) (app *App, err error) {
 10 	if err != nil {
 11 		return
 12 	}
 13-	app.win.SetPrompt(">")
 14+	app.win.SetPrompt(ui.PlainString(">"))
 15 
 16 	app.initWindow()
 17 
 18@@ -147,7 +148,7 @@ func (app *App) ircLoop() {
 19 				app.queueStatusLine(ui.Line{
 20 					At:   time.Now(),
 21 					Head: "IN --",
 22-					Body: msg.String(),
 23+					Body: ui.PlainString(msg.String()),
 24 				})
 25 			}
 26 			app.events <- event{
 27@@ -161,8 +162,8 @@ func (app *App) ircLoop() {
 28 		}
 29 		app.queueStatusLine(ui.Line{
 30 			Head:      "!!",
 31-			HeadColor: ui.ColorRed,
 32-			Body:      "Connection lost",
 33+			HeadColor: tcell.ColorRed,
 34+			Body:      ui.PlainString("Connection lost"),
 35 		})
 36 		time.Sleep(10 * time.Second)
 37 	}
 38@@ -172,7 +173,7 @@ func (app *App) connect() net.Conn {
 39 	for {
 40 		app.queueStatusLine(ui.Line{
 41 			Head: "--",
 42-			Body: fmt.Sprintf("Connecting to %s...", app.cfg.Addr),
 43+			Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr),
 44 		})
 45 		conn, err := app.tryConnect()
 46 		if err == nil {
 47@@ -180,8 +181,8 @@ func (app *App) connect() net.Conn {
 48 		}
 49 		app.queueStatusLine(ui.Line{
 50 			Head:      "!!",
 51-			HeadColor: ui.ColorRed,
 52-			Body:      fmt.Sprintf("Connection failed: %v", err),
 53+			HeadColor: tcell.ColorRed,
 54+			Body:      ui.PlainSprintf("Connection failed: %v", err),
 55 		})
 56 		time.Sleep(1 * time.Minute)
 57 	}
 58@@ -229,7 +230,7 @@ func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
 59 			app.queueStatusLine(ui.Line{
 60 				At:   time.Now(),
 61 				Head: "OUT --",
 62-				Body: msg.String(),
 63+				Body: ui.PlainString(msg.String()),
 64 			})
 65 			out <- msg
 66 		}
 67@@ -408,8 +409,8 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
 68 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 69 				At:        time.Now(),
 70 				Head:      "!!",
 71-				HeadColor: ui.ColorRed,
 72-				Body:      fmt.Sprintf("%q: %s", input, err),
 73+				HeadColor: tcell.ColorRed,
 74+				Body:      ui.PlainSprintf("%q: %s", input, err),
 75 			})
 76 		}
 77 		app.updatePrompt()
 78@@ -459,28 +460,48 @@ func (app *App) handleIRCEvent(ev interface{}) {
 79 	// Mutate UI state
 80 	switch ev := ev.(type) {
 81 	case irc.RegisteredEvent:
 82-		body := "Connected to the server"
 83+		body := new(ui.StyledStringBuilder)
 84+		body.WriteString("Connected to the server")
 85 		if app.s.Nick() != app.cfg.Nick {
 86-			body += " as " + app.s.Nick()
 87+			body.WriteString(" as ")
 88+			body.WriteString(app.s.Nick())
 89 		}
 90 		app.win.AddLine(Home, false, ui.Line{
 91 			At:   msg.TimeOrNow(),
 92 			Head: "--",
 93-			Body: body,
 94+			Body: body.StyledString(),
 95 		})
 96 	case irc.SelfNickEvent:
 97-		app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
 98+		body := new(ui.StyledStringBuilder)
 99+		body.Grow(len(ev.FormerNick) + 4 + len(app.s.Nick()))
100+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
101+		body.WriteString(ev.FormerNick)
102+		body.SetStyle(tcell.StyleDefault)
103+		body.WriteRune('\u2192') // right arrow
104+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
105+		body.WriteString(app.s.Nick())
106+		app.addStatusLine(ui.Line{
107 			At:        msg.TimeOrNow(),
108 			Head:      "--",
109-			Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
110+			HeadColor: tcell.ColorGray,
111+			Body:      body.StyledString(),
112 			Highlight: true,
113 		})
114 	case irc.UserNickEvent:
115+		body := new(ui.StyledStringBuilder)
116+		body.Grow(len(ev.FormerNick) + 4 + len(ev.User))
117+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
118+		body.WriteString(ev.FormerNick)
119+		body.SetStyle(tcell.StyleDefault)
120+		body.WriteRune('\u2192') // right arrow
121+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
122+		body.WriteString(ev.User)
123 		for _, c := range app.s.ChannelsSharedWith(ev.User) {
124 			app.win.AddLine(c, false, ui.Line{
125 				At:        msg.TimeOrNow(),
126 				Head:      "--",
127-				Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User),
128+				HeadColor: tcell.ColorGray,
129+				Body:      body.StyledString(),
130 				Mergeable: true,
131 			})
132 		}
133@@ -490,41 +511,68 @@ func (app *App) handleIRCEvent(ev interface{}) {
134 			WithLimit(200).
135 			Before(msg.TimeOrNow())
136 	case irc.UserJoinEvent:
137+		body := new(ui.StyledStringBuilder)
138+		body.Grow(len(ev.User) + 1)
139+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
140+		body.WriteByte('+')
141+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
142+		body.WriteString(ev.User)
143 		app.win.AddLine(ev.Channel, false, ui.Line{
144 			At:        msg.TimeOrNow(),
145 			Head:      "--",
146-			Body:      fmt.Sprintf("\x033+\x0314%s\x03", ev.User),
147+			HeadColor: tcell.ColorGray,
148+			Body:      body.StyledString(),
149 			Mergeable: true,
150 		})
151 	case irc.SelfPartEvent:
152 		app.win.RemoveBuffer(ev.Channel)
153 	case irc.UserPartEvent:
154+		body := new(ui.StyledStringBuilder)
155+		body.Grow(len(ev.User) + 1)
156+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed))
157+		body.WriteByte('-')
158+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
159+		body.WriteString(ev.User)
160 		app.win.AddLine(ev.Channel, false, ui.Line{
161 			At:        msg.TimeOrNow(),
162 			Head:      "--",
163-			Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
164+			HeadColor: tcell.ColorGray,
165+			Body:      body.StyledString(),
166 			Mergeable: true,
167 		})
168 	case irc.UserQuitEvent:
169+		body := new(ui.StyledStringBuilder)
170+		body.Grow(len(ev.User) + 1)
171+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed))
172+		body.WriteByte('-')
173+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
174+		body.WriteString(ev.User)
175 		for _, c := range ev.Channels {
176 			app.win.AddLine(c, false, ui.Line{
177 				At:        msg.TimeOrNow(),
178 				Head:      "--",
179-				Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
180+				HeadColor: tcell.ColorGray,
181+				Body:      body.StyledString(),
182 				Mergeable: true,
183 			})
184 		}
185 	case irc.TopicChangeEvent:
186+		body := new(ui.StyledStringBuilder)
187+		body.Grow(len(ev.Topic) + 18)
188+		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
189+		body.WriteString("Topic changed to: ")
190+		body.WriteString(ev.Topic)
191 		app.win.AddLine(ev.Channel, false, ui.Line{
192-			At:   msg.TimeOrNow(),
193-			Head: "--",
194-			Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic),
195+			At:        msg.TimeOrNow(),
196+			Head:      "--",
197+			HeadColor: tcell.ColorGray,
198+			Body:      body.StyledString(),
199 		})
200 	case irc.MessageEvent:
201 		buffer, line, hlNotification := app.formatMessage(ev)
202 		app.win.AddLine(buffer, hlNotification, line)
203 		if hlNotification {
204-			app.notifyHighlight(buffer, ev.User, ev.Content)
205+			app.notifyHighlight(buffer, ev.User, line.Body.String())
206 		}
207 		if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) {
208 			app.lastQuery = msg.Prefix.Name
209@@ -561,7 +609,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
210 		app.addStatusLine(ui.Line{
211 			At:   msg.TimeOrNow(),
212 			Head: head,
213-			Body: body,
214+			Body: ui.PlainString(body),
215 		})
216 	}
217 }
218@@ -608,7 +656,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
219 		fmt.Sprintf("BUFFER=%s", buffer),
220 		fmt.Sprintf("HERE=%s", here),
221 		fmt.Sprintf("SENDER=%s", nick),
222-		fmt.Sprintf("MESSAGE=%s", cleanMessage(content)),
223+		fmt.Sprintf("MESSAGE=%s", content),
224 	)
225 	output, err := cmd.CombinedOutput()
226 	if err != nil {
227@@ -616,8 +664,8 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
228 		app.addStatusLine(ui.Line{
229 			At:        time.Now(),
230 			Head:      "!!",
231-			HeadColor: ui.ColorRed,
232-			Body:      body,
233+			HeadColor: tcell.ColorRed,
234+			Body:      ui.PlainString(body),
235 		})
236 	}
237 }
238@@ -690,33 +738,44 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
239 	hlNotification = (isHighlight || isQuery) && !isFromSelf
240 
241 	head := ev.User
242-	headColor := ui.ColorWhite
243+	headColor := tcell.ColorWhite
244 	if isFromSelf && isQuery {
245 		head = "\u2192 " + ev.Target
246-		headColor = ui.IdentColor(ev.Target)
247+		headColor = identColor(ev.Target)
248 	} else if isAction || isNotice {
249 		head = "*"
250 	} else {
251-		headColor = ui.IdentColor(head)
252-	}
253-
254-	body := strings.TrimSuffix(ev.Content, "\x01")
255-	if isNotice && isAction {
256-		c := ircColorSequence(ui.IdentColor(ev.User))
257-		body = fmt.Sprintf("%s%s\x0F:%s", c, ev.User, body[7:])
258+		headColor = identColor(head)
259+	}
260+
261+	content := strings.TrimSuffix(ev.Content, "\x01")
262+	content = strings.TrimRightFunc(ev.Content, unicode.IsSpace)
263+	if isAction {
264+		content = content[7:]
265+	}
266+	body := new(ui.StyledStringBuilder)
267+	if isNotice {
268+		color := identColor(ev.User)
269+		body.SetStyle(tcell.StyleDefault.Foreground(color))
270+		body.WriteString(ev.User)
271+		body.SetStyle(tcell.StyleDefault)
272+		body.WriteString(": ")
273+		body.WriteStyledString(ui.IRCString(content))
274 	} else if isAction {
275-		c := ircColorSequence(ui.IdentColor(ev.User))
276-		body = fmt.Sprintf("%s%s\x0F%s", c, ev.User, body[7:])
277-	} else if isNotice {
278-		c := ircColorSequence(ui.IdentColor(ev.User))
279-		body = fmt.Sprintf("%s%s\x0F: %s", c, ev.User, body)
280+		color := identColor(ev.User)
281+		body.SetStyle(tcell.StyleDefault.Foreground(color))
282+		body.WriteString(ev.User)
283+		body.SetStyle(tcell.StyleDefault)
284+		body.WriteStyledString(ui.IRCString(content))
285+	} else {
286+		body.WriteStyledString(ui.IRCString(content))
287 	}
288 
289 	line = ui.Line{
290 		At:        ev.Time,
291 		Head:      head,
292-		Body:      body,
293 		HeadColor: headColor,
294+		Body:      body.StyledString(),
295 		Highlight: hlLine,
296 	}
297 	return
298@@ -726,34 +785,11 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
299 func (app *App) updatePrompt() {
300 	buffer := app.win.CurrentBuffer()
301 	command := app.win.InputIsCommand()
302+	var prompt ui.StyledString
303 	if buffer == Home || command {
304-		app.win.SetPrompt(">")
305+		prompt = ui.PlainString(">")
306 	} else {
307-		app.win.SetPrompt(app.s.Nick())
308-	}
309-}
310-
311-// ircColorSequence returns the color formatting sequence of a color code.
312-func ircColorSequence(code int) string {
313-	var c [3]rune
314-	c[0] = 0x03
315-	c[1] = rune(code/10) + '0'
316-	c[2] = rune(code%10) + '0'
317-	return string(c[:])
318-}
319-
320-// cleanMessage removes IRC formatting from a string.
321-func cleanMessage(s string) string {
322-	var res strings.Builder
323-	var sb ui.StyleBuffer
324-	res.Grow(len(s))
325-	for _, r := range s {
326-		if _, ok := sb.WriteRune(r); ok != 0 {
327-			if 1 < ok {
328-				res.WriteRune(',')
329-			}
330-			res.WriteRune(r)
331-		}
332+		prompt = identString(app.s.Nick())
333 	}
334-	return res.String()
335+	app.win.SetPrompt(prompt)
336 }
+27, -17
  1@@ -7,6 +7,7 @@ import (
  2 
  3 	"git.sr.ht/~taiite/senpai/irc"
  4 	"git.sr.ht/~taiite/senpai/ui"
  5+	"github.com/gdamore/tcell/v2"
  6 )
  7 
  8 type command struct {
  9@@ -151,7 +152,7 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) {
 10 		app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 11 			At:   t,
 12 			Head: "--",
 13-			Body: "Available commands:",
 14+			Body: ui.PlainString("Available commands:"),
 15 		})
 16 		for cmdName, cmd := range commands {
 17 			if cmd.Desc == "" {
 18@@ -159,11 +160,11 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) {
 19 			}
 20 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 21 				At:   t,
 22-				Body: fmt.Sprintf("  \x02%s\x02 %s", cmdName, cmd.Usage),
 23+				Body: ui.PlainSprintf("  \x02%s\x02 %s", cmdName, cmd.Usage),
 24 			})
 25 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 26 				At:   t,
 27-				Body: fmt.Sprintf("    %s", cmd.Desc),
 28+				Body: ui.PlainSprintf("    %s", cmd.Desc),
 29 			})
 30 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 31 				At: t,
 32@@ -175,19 +176,26 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) {
 33 		app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 34 			At:   t,
 35 			Head: "--",
 36-			Body: fmt.Sprintf("Commands that match \"%s\":", search),
 37+			Body: ui.PlainSprintf("Commands that match \"%s\":", search),
 38 		})
 39 		for cmdName, cmd := range commands {
 40 			if !strings.Contains(cmdName, search) {
 41 				continue
 42 			}
 43+			usage := new(ui.StyledStringBuilder)
 44+			usage.Grow(len(cmdName) + 1 + len(cmd.Usage))
 45+			usage.SetStyle(tcell.StyleDefault.Bold(true))
 46+			usage.WriteString(cmdName)
 47+			usage.SetStyle(tcell.StyleDefault)
 48+			usage.WriteByte(' ')
 49+			usage.WriteString(cmd.Usage)
 50 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 51 				At:   t,
 52-				Body: fmt.Sprintf("\x02%s\x02 %s", cmdName, cmd.Usage),
 53+				Body: usage.StyledString(),
 54 			})
 55 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 56 				At:   t,
 57-				Body: fmt.Sprintf("  %s", cmd.Desc),
 58+				Body: ui.PlainSprintf("  %s", cmd.Desc),
 59 			})
 60 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 61 				At: t,
 62@@ -197,7 +205,7 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) {
 63 		if !found {
 64 			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
 65 				At:   t,
 66-				Body: fmt.Sprintf("  no command matches %q", args[0]),
 67+				Body: ui.PlainSprintf("  no command matches %q", args[0]),
 68 			})
 69 		}
 70 	}
 71@@ -252,22 +260,24 @@ func commandDoMsg(app *App, buffer string, args []string) (err error) {
 72 }
 73 
 74 func commandDoNames(app *App, buffer string, args []string) (err error) {
 75-	var sb strings.Builder
 76-	sb.WriteString("\x0314Names: ")
 77+	sb := new(ui.StyledStringBuilder)
 78+	sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey))
 79+	sb.WriteString("Names: ")
 80 	for _, name := range app.s.Names(buffer) {
 81 		if name.PowerLevel != "" {
 82-			sb.WriteString("\x033")
 83+			sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
 84 			sb.WriteString(name.PowerLevel)
 85-			sb.WriteString("\x0314")
 86+			sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey))
 87 		}
 88 		sb.WriteString(name.Name.Name)
 89-		sb.WriteRune(' ')
 90+		sb.WriteByte(' ')
 91 	}
 92-	body := sb.String()
 93+	body := sb.StyledString()
 94+	// TODO remove last space
 95 	app.win.AddLine(buffer, false, ui.Line{
 96 		At:   time.Now(),
 97 		Head: "--",
 98-		Body: body[:len(body)-1],
 99+		Body: body,
100 	})
101 	return
102 }
103@@ -351,14 +361,14 @@ func commandDoTopic(app *App, buffer string, args []string) (err error) {
104 
105 		topic, who, at := app.s.Topic(buffer)
106 		if who == nil {
107-			body = fmt.Sprintf("\x0314Topic: %s", topic)
108+			body = fmt.Sprintf("Topic: %s", topic)
109 		} else {
110-			body = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
111+			body = fmt.Sprintf("Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
112 		}
113 		app.win.AddLine(buffer, false, ui.Line{
114 			At:   time.Now(),
115 			Head: "--",
116-			Body: body,
117+			Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)),
118 		})
119 	} else {
120 		app.s.ChangeTopic(buffer, args[0])
+40, -44
  1@@ -19,8 +19,8 @@ type point struct {
  2 type Line struct {
  3 	At        time.Time
  4 	Head      string
  5-	Body      string
  6-	HeadColor int
  7+	Body      StyledString
  8+	HeadColor tcell.Color
  9 	Highlight bool
 10 	Mergeable bool
 11 
 12@@ -34,29 +34,29 @@ func (l *Line) computeSplitPoints() {
 13 		l.splitPoints = []point{}
 14 	}
 15 
 16-	var wb widthBuffer
 17+	width := 0
 18 	lastWasSplit := false
 19 	l.splitPoints = l.splitPoints[:0]
 20 
 21-	for i, r := range l.Body {
 22+	for i, r := range l.Body.string {
 23 		curIsSplit := IsSplitRune(r)
 24 
 25 		if i == 0 || lastWasSplit != curIsSplit {
 26 			l.splitPoints = append(l.splitPoints, point{
 27-				X:     wb.Width(),
 28+				X:     width,
 29 				I:     i,
 30 				Split: curIsSplit,
 31 			})
 32 		}
 33 
 34 		lastWasSplit = curIsSplit
 35-		wb.WriteRune(r)
 36+		width += runeWidth(r)
 37 	}
 38 
 39 	if !lastWasSplit {
 40 		l.splitPoints = append(l.splitPoints, point{
 41-			X:     wb.Width(),
 42-			I:     len(l.Body),
 43+			X:     width,
 44+			I:     len(l.Body.string),
 45 			Split: true,
 46 		})
 47 	}
 48@@ -118,16 +118,17 @@ func (l *Line) NewLines(width int) []int {
 49 			// terminal.  In this case, no newline is placed before (like in the
 50 			// 2nd if-else branch).  The for loop is used to place newlines in
 51 			// the word.
 52-			var wb widthBuffer
 53+			// TODO handle multi-codepoint graphemes?? :(
 54+			wordWidth := 0
 55 			h := 1
 56-			for j, r := range l.Body[sp1.I:sp2.I] {
 57-				wb.WriteRune(r)
 58-				if h*width < x+wb.Width() {
 59+			for j, r := range l.Body.string[sp1.I:sp2.I] {
 60+				wordWidth += runeWidth(r)
 61+				if h*width < x+wordWidth {
 62 					l.newLines = append(l.newLines, sp1.I+j)
 63 					h++
 64 				}
 65 			}
 66-			x = (x + wb.Width()) % width
 67+			x = (x + wordWidth) % width
 68 			if x == 0 {
 69 				// The placement of the word is such that it ends right at the
 70 				// end of the row.
 71@@ -147,7 +148,7 @@ func (l *Line) NewLines(width int) []int {
 72 		}
 73 	}
 74 
 75-	if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body) {
 76+	if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body.string) {
 77 		// DROP any newline that is placed at the end of the string because we
 78 		// don't care about those.
 79 		l.newLines = l.newLines[:len(l.newLines)-1]
 80@@ -255,14 +256,19 @@ func (bs *BufferList) AddLine(title string, highlight bool, line Line) {
 81 
 82 	b := &bs.list[idx]
 83 	n := len(b.lines)
 84-	line.Body = strings.TrimRight(line.Body, "\t ")
 85 	line.At = line.At.UTC()
 86 
 87 	if line.Mergeable && n != 0 && b.lines[n-1].Mergeable {
 88 		l := &b.lines[n-1]
 89-		l.Body += "  " + line.Body
 90+		newBody := new(StyledStringBuilder)
 91+		newBody.Grow(len(l.Body.string) + 2 + len(line.Body.string))
 92+		newBody.WriteStyledString(l.Body)
 93+		newBody.WriteString("  ")
 94+		newBody.WriteStyledString(line.Body)
 95+		l.Body = newBody.StyledString()
 96 		l.computeSplitPoints()
 97 		l.width = 0
 98+		// TODO change b.scrollAmt if it's not 0 and bs.current is idx.
 99 	} else {
100 		line.computeSplitPoints()
101 		b.lines = append(b.lines, line)
102@@ -307,7 +313,7 @@ func (bs *BufferList) AddLines(title string, lines []Line) {
103 				limit = i
104 				break
105 			}
106-			if l.Body == firstLineBody {
107+			if l.Body.string == firstLineBody.string {
108 				// This line happened at the same millisecond
109 				// as the first line of the buffer, and has the
110 				// same contents. Heuristic: it's the same
111@@ -396,7 +402,7 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
112 			st = st.Reverse(true)
113 		}
114 		title := truncate(b.title, width, "\u2026")
115-		printString(screen, &x, y, st, title)
116+		printString(screen, &x, y, Styled(title, st))
117 		if 0 < b.highlights {
118 			st = st.Foreground(tcell.ColorRed).Reverse(true)
119 			screen.SetContent(x, y, ' ', nil, st)
120@@ -417,10 +423,9 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
121 }
122 
123 func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int) {
124-	st := tcell.StyleDefault
125 	for x := x0; x < x0+bs.tlWidth; x++ {
126 		for y := y0; y < y0+bs.tlHeight; y++ {
127-			screen.SetContent(x, y, ' ', nil, st)
128+			screen.SetContent(x, y, ' ', nil, tcell.StyleDefault)
129 		}
130 	}
131 
132@@ -441,18 +446,25 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
133 		}
134 
135 		if i == 0 || b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) {
136-			printTime(screen, x0, yi, st.Bold(true), line.At.Local())
137+			st := tcell.StyleDefault.Bold(true)
138+			printTime(screen, x0, yi, st, line.At.Local())
139 		}
140 
141-		identSt := st.Foreground(colorFromCode(line.HeadColor)).Reverse(line.Highlight)
142-		printIdent(screen, x0+7, yi, nickColWidth, identSt, line.Head)
143+		identSt := tcell.StyleDefault.
144+			Foreground(line.HeadColor).
145+			Reverse(line.Highlight)
146+		printIdent(screen, x0+7, yi, nickColWidth, Styled(line.Head, identSt))
147 
148 		x := x1
149 		y := yi
150+		style := tcell.StyleDefault
151+		nextStyles := line.Body.styles
152 
153-		var sb StyleBuffer
154-		sb.Reset()
155-		for i, r := range line.Body {
156+		for i, r := range line.Body.string {
157+			if 0 < len(nextStyles) && nextStyles[0].Start == i {
158+				style = nextStyles[0].Style
159+				nextStyles = nextStyles[1:]
160+			}
161 			if 0 < len(nls) && i == nls[0] {
162 				x = x1
163 				y++
164@@ -466,26 +478,10 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
165 				continue
166 			}
167 
168-			if st, ok := sb.WriteRune(r); ok != 0 {
169-				if 1 < ok {
170-					screen.SetContent(x, y, ',', nil, st)
171-					x++
172-				}
173-				screen.SetContent(x, y, r, nil, st)
174-				x += runeWidth(r)
175-			}
176+			screen.SetContent(x, y, r, nil, style)
177+			x += runeWidth(r)
178 		}
179-
180-		sb.Reset()
181 	}
182 
183 	b.isAtTop = y0 <= yi
184 }
185-
186-func IrcColorCode(code int) string {
187-	var c [3]rune
188-	c[0] = 0x03
189-	c[1] = rune(code/10) + '0'
190-	c[2] = rune(code%10) + '0'
191-	return string(c[:])
192-}
+4, -16
 1@@ -6,7 +6,7 @@ import (
 2 )
 3 
 4 func assertSplitPoints(t *testing.T, body string, expected []point) {
 5-	l := Line{Body: body}
 6+	l := Line{Body: PlainString(body)}
 7 	l.computeSplitPoints()
 8 
 9 	if len(l.splitPoints) != len(expected) {
10@@ -71,7 +71,7 @@ func showSplit(s string, nls []int) string {
11 }
12 
13 func assertNewLines(t *testing.T, body string, width int, expected int) {
14-	l := Line{Body: body}
15+	l := Line{Body: PlainString(body)}
16 	l.computeSplitPoints()
17 
18 	actual := l.NewLines(width)
19@@ -141,6 +141,8 @@ func TestRenderedHeight(t *testing.T) {
20 	assertNewLines(t, "have a good day!", 17, 1) // |have a good day! |
21 
22 	// LEN=15, WIDTH=11
23+	// TODO find other zero- or two-width chars, since IRC color codes are
24+	// now handled by app.go
25 	assertNewLines(t, "\x0342barmand\x03: cc", 1, 10) // |b|a|r|m|a|n|d|:|c|c|
26 	assertNewLines(t, "\x0342barmand\x03: cc", 2, 5)  // |ba|rm|an|d:|cc|
27 	assertNewLines(t, "\x0342barmand\x03: cc", 3, 4)  // |bar|man|d: |cc |
28@@ -160,17 +162,3 @@ func TestRenderedHeight(t *testing.T) {
29 
30 	assertNewLines(t, "cc en direct du word wrapping des familles le tests ça v a va va v a va", 46, 2)
31 }
32-
33-/*
34-func assertTrimWidth(t *testing.T, s string, w int, expected string) {
35-	actual := trimWidth(s, w)
36-	if actual != expected {
37-		t.Errorf("%q (width=%d): expected to be trimmed as %q, got %q\n", s, w, expected, actual)
38-	}
39-}
40-
41-func TestTrimWidth(t *testing.T) {
42-	assertTrimWidth(t, "ludovicchabant/fn", 16, "ludovicchabant/…")
43-	assertTrimWidth(t, "zzzzzzzzzzzzzz黒猫/sr", 16, "zzzzzzzzzzzzzz黒…")
44-}
45-// */
+27, -12
 1@@ -7,24 +7,39 @@ import (
 2 	"github.com/gdamore/tcell/v2"
 3 )
 4 
 5-func printIdent(screen tcell.Screen, x, y, width int, st tcell.Style, s string) {
 6-	s = truncate(s, width, "\u2026")
 7-	x += width - StringWidth(s)
 8-	screen.SetContent(x-1, y, ' ', nil, st)
 9-	printString(screen, &x, y, st, s)
10-	screen.SetContent(x, y, ' ', nil, st)
11+func printString(screen tcell.Screen, x *int, y int, s StyledString) {
12+	style := tcell.StyleDefault
13+	nextStyles := s.styles
14+	for i, r := range s.string {
15+		if 0 < len(nextStyles) && nextStyles[0].Start == i {
16+			style = nextStyles[0].Style
17+			nextStyles = nextStyles[1:]
18+		}
19+		screen.SetContent(*x, y, r, nil, style)
20+		*x += runeWidth(r)
21+	}
22 }
23 
24-func printString(screen tcell.Screen, x *int, y int, st tcell.Style, s string) {
25-	for _, r := range s {
26-		screen.SetContent(*x, y, r, nil, st)
27-		*x += runeWidth(r)
28+func printIdent(screen tcell.Screen, x, y, width int, s StyledString) {
29+	s.string = truncate(s.string, width, "\u2026")
30+	x += width - stringWidth(s.string)
31+	st := tcell.StyleDefault
32+	if len(s.styles) != 0 && s.styles[0].Start == 0 {
33+		st = s.styles[0].Style
34+	}
35+	screen.SetContent(x-1, y, ' ', nil, st)
36+	printString(screen, &x, y, s)
37+	if len(s.styles) != 0 {
38+		// TODO check if it's not a style that is from the truncated
39+		// part of s.
40+		st = s.styles[len(s.styles)-1].Style
41 	}
42+	screen.SetContent(x, y, ' ', nil, st)
43 }
44 
45 func printNumber(screen tcell.Screen, x *int, y int, st tcell.Style, n int) {
46-	s := fmt.Sprintf("%d", n)
47-	printString(screen, x, y, st, s)
48+	s := Styled(fmt.Sprintf("%d", n), st)
49+	printString(screen, x, y, s)
50 }
51 
52 func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
+195, -180
  1@@ -1,7 +1,10 @@
  2 package ui
  3 
  4 import (
  5-	"hash/fnv"
  6+	"fmt"
  7+	"strconv"
  8+	"strings"
  9+	"unicode/utf8"
 10 
 11 	"github.com/gdamore/tcell/v2"
 12 	"github.com/mattn/go-runewidth"
 13@@ -13,234 +16,246 @@ func runeWidth(r rune) int {
 14 	return condition.RuneWidth(r)
 15 }
 16 
 17+func stringWidth(s string) int {
 18+	return condition.StringWidth(s)
 19+}
 20+
 21 func truncate(s string, w int, tail string) string {
 22 	return condition.Truncate(s, w, tail)
 23 }
 24 
 25-type widthBuffer struct {
 26-	width int
 27-	color colorBuffer
 28+// Taken from <https://modern.ircdocs.horse/formatting.html>
 29+
 30+var baseCodes = []tcell.Color{
 31+	tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen,
 32+	tcell.ColorRed, tcell.ColorBrown, tcell.ColorPurple, tcell.ColorOrange,
 33+	tcell.ColorYellow, tcell.ColorLightGreen, tcell.ColorTeal, tcell.ColorLightCyan,
 34+	tcell.ColorLightBlue, tcell.ColorPink, tcell.ColorGrey, tcell.ColorLightGrey,
 35 }
 36 
 37-func (wb *widthBuffer) Width() int {
 38-	return wb.width
 39+// unused
 40+var ansiCodes = []uint64{
 41+	/* 16-27 */ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
 42+	/* 28-39 */ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
 43+	/* 40-51 */ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
 44+	/* 52-63 */ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
 45+	/* 64-75 */ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
 46+	/* 76-87 */ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
 47+	/* 88-98 */ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 48 }
 49 
 50-func (wb *widthBuffer) WriteString(s string) {
 51-	for _, r := range s {
 52-		wb.WriteRune(r)
 53-	}
 54+var hexCodes = []int32{
 55+	0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a,
 56+	0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045,
 57+	0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b,
 58+	0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff, 0xa500ff, 0xff00ff, 0xff0098,
 59+	0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc,
 60+	0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3,
 61+	0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff,
 62 }
 63 
 64-func (wb *widthBuffer) WriteRune(r rune) {
 65-	if ok := wb.color.WriteRune(r); ok != 0 {
 66-		if 1 < ok {
 67-			wb.width++
 68-		}
 69-		wb.width += runeWidth(r)
 70+func colorFromCode(code int) (color tcell.Color) {
 71+	if code < 0 || 99 <= code {
 72+		color = tcell.ColorDefault
 73+	} else if code < 16 {
 74+		color = baseCodes[code]
 75+	} else {
 76+		color = tcell.NewHexColor(hexCodes[code-16])
 77 	}
 78+	return
 79 }
 80 
 81-func StringWidth(s string) int {
 82-	var wb widthBuffer
 83-	wb.WriteString(s)
 84-	return wb.Width()
 85+type rangedStyle struct {
 86+	Start int // byte index at which Style is effective
 87+	Style tcell.Style
 88 }
 89 
 90-type StyleBuffer struct {
 91-	st            tcell.Style
 92-	color         colorBuffer
 93-	bold          bool
 94-	reverse       bool
 95-	italic        bool
 96-	strikethrough bool
 97-	underline     bool
 98+type StyledString struct {
 99+	string
100+	styles []rangedStyle // sorted, elements cannot have the same Start value
101 }
102 
103-func (sb *StyleBuffer) Reset() {
104-	sb.color.Reset()
105-	sb.st = tcell.StyleDefault
106-	sb.bold = false
107-	sb.reverse = false
108-	sb.italic = false
109-	sb.strikethrough = false
110-	sb.underline = false
111+func PlainString(s string) StyledString {
112+	return StyledString{string: s}
113 }
114 
115-func (sb *StyleBuffer) WriteRune(r rune) (st tcell.Style, ok int) {
116-	if r == 0x00 || r == 0x0F {
117-		sb.Reset()
118-		return sb.st, 0
119-	}
120-	if r == 0x02 {
121-		sb.bold = !sb.bold
122-		sb.st = sb.st.Bold(sb.bold)
123-		return sb.st, 0
124-	}
125-	if r == 0x16 {
126-		sb.reverse = !sb.reverse
127-		sb.st = st.Reverse(sb.reverse)
128-		return sb.st, 0
129-	}
130-	if r == 0x1D {
131-		sb.italic = !sb.italic
132-		sb.st = st.Italic(sb.italic)
133-		return sb.st, 0
134-	}
135-	if r == 0x1E {
136-		sb.strikethrough = !sb.strikethrough
137-		sb.st = st.StrikeThrough(sb.strikethrough)
138-		return sb.st, 0
139-	}
140-	if r == 0x1F {
141-		sb.underline = !sb.underline
142-		sb.st = st.Underline(sb.underline)
143-		return sb.st, 0
144+func PlainSprintf(format string, a ...interface{}) StyledString {
145+	return PlainString(fmt.Sprintf(format, a...))
146+}
147+
148+func Styled(s string, style tcell.Style) StyledString {
149+	rStyle := rangedStyle{
150+		Start: 0,
151+		Style: style,
152 	}
153-	if ok = sb.color.WriteRune(r); ok != 0 {
154-		sb.st = sb.color.Style(sb.st)
155+	return StyledString{
156+		string: s,
157+		styles: []rangedStyle{rStyle},
158 	}
159-
160-	return sb.st, ok
161 }
162 
163-type colorBuffer struct {
164-	state  int
165-	fg, bg int
166+func (s StyledString) String() string {
167+	return s.string
168 }
169 
170-func (cb *colorBuffer) Reset() {
171-	cb.state = 0
172-	cb.fg = -1
173-	cb.bg = -1
174+func isDigit(c byte) bool {
175+	return '0' <= c && c <= '9'
176 }
177 
178-func (cb *colorBuffer) Style(st tcell.Style) tcell.Style {
179-	if 0 <= cb.fg {
180-		st = st.Foreground(colorFromCode(cb.fg))
181-	} else {
182-		st = st.Foreground(tcell.ColorDefault)
183+func parseColorNumber(raw string) (color tcell.Color, n int) {
184+	if len(raw) == 0 || !isDigit(raw[0]) {
185+		return
186 	}
187-	if 0 <= cb.bg {
188-		st = st.Background(colorFromCode(cb.bg))
189-	} else {
190-		st = st.Background(tcell.ColorDefault)
191+
192+	// len(raw) >= 1 and its first character is a digit.
193+
194+	if len(raw) == 1 || !isDigit(raw[1]) {
195+		code, _ := strconv.Atoi(raw[:1])
196+		return colorFromCode(code), 1
197 	}
198-	return st
199+
200+	// len(raw) >= 2 and the two first characters are digits.
201+
202+	code, _ := strconv.Atoi(raw[:2])
203+	return colorFromCode(code), 2
204 }
205 
206-func (cb *colorBuffer) WriteRune(r rune) (ok int) {
207-	if cb.state == 1 {
208-		if '0' <= r && r <= '9' {
209-			cb.fg = int(r - '0')
210-			cb.state = 2
211-			return
212-		}
213-	} else if cb.state == 2 {
214-		if '0' <= r && r <= '9' {
215-			cb.fg = 10*cb.fg + int(r-'0')
216-			cb.state = 3
217-			return
218-		}
219-		if r == ',' {
220-			cb.state = 4
221-			return
222-		}
223-	} else if cb.state == 3 {
224-		if r == ',' {
225-			cb.state = 4
226-			return
227-		}
228-	} else if cb.state == 4 {
229-		if '0' <= r && r <= '9' {
230-			cb.bg = int(r - '0')
231-			cb.state = 5
232-			return
233-		}
234-		ok++
235-	} else if cb.state == 5 {
236-		cb.state = 0
237-		if '0' <= r && r <= '9' {
238-			cb.bg = 10*cb.bg + int(r-'0')
239-			return
240-		}
241+func parseColor(raw string) (fg, bg tcell.Color, n int) {
242+	fg, n = parseColorNumber(raw)
243+	raw = raw[n:]
244+
245+	if len(raw) == 0 || raw[0] != ',' {
246+		return fg, tcell.ColorDefault, n
247 	}
248 
249-	if r == 0x03 {
250-		cb.state = 1
251-		cb.fg = -1
252-		cb.bg = -1
253-		return
254+	n++
255+	bg, p := parseColorNumber(raw[1:])
256+	n += p
257+
258+	if bg == tcell.ColorDefault {
259+		// Lone comma, do not parse as part of a color code.
260+		return fg, tcell.ColorDefault, n - 1
261 	}
262 
263-	cb.state = 0
264-	ok++
265-	return
266+	return fg, bg, n
267 }
268 
269-const (
270-	ColorWhite = iota
271-	ColorBlack
272-	ColorBlue
273-	ColorGreen
274-	ColorRed
275-)
276+func IRCString(raw string) StyledString {
277+	var formatted strings.Builder
278+	var styles []rangedStyle
279+	var last tcell.Style
280 
281-// Taken from <https://modern.ircdocs.horse/formatting.html>
282+	for len(raw) != 0 {
283+		r, runeSize := utf8.DecodeRuneInString(raw)
284+		if r == utf8.RuneError {
285+			break
286+		}
287+		_, _, lastAttrs := last.Decompose()
288+		current := last
289+		if r == 0x0F {
290+			current = tcell.StyleDefault
291+		} else if r == 0x02 {
292+			lastWasBold := lastAttrs&tcell.AttrBold != 0
293+			current = last.Bold(!lastWasBold)
294+		} else if r == 0x03 {
295+			fg, bg, n := parseColor(raw[1:])
296+			raw = raw[n:]
297+			if n == 0 {
298+				// Both `fg` and `bg` are equal to
299+				// tcell.ColorDefault.
300+				current = last.Foreground(tcell.ColorDefault).
301+					Background(tcell.ColorDefault)
302+			} else if bg == tcell.ColorDefault {
303+				current = last.Foreground(fg)
304+			} else {
305+				current = last.Foreground(fg).Background(bg)
306+			}
307+		} else if r == 0x16 {
308+			lastWasReverse := lastAttrs&tcell.AttrReverse != 0
309+			current = last.Reverse(!lastWasReverse)
310+		} else if r == 0x1D {
311+			lastWasItalic := lastAttrs&tcell.AttrItalic != 0
312+			current = last.Italic(!lastWasItalic)
313+		} else if r == 0x1E {
314+			lastWasStrikeThrough := lastAttrs&tcell.AttrStrikeThrough != 0
315+			current = last.StrikeThrough(!lastWasStrikeThrough)
316+		} else if r == 0x1F {
317+			lastWasUnderline := lastAttrs&tcell.AttrUnderline != 0
318+			current = last.Underline(!lastWasUnderline)
319+		} else {
320+			formatted.WriteRune(r)
321+		}
322+		if last != current {
323+			if len(styles) != 0 && styles[len(styles)-1].Start == formatted.Len() {
324+				styles[len(styles)-1] = rangedStyle{
325+					Start: formatted.Len(),
326+					Style: current,
327+				}
328+			} else {
329+				styles = append(styles, rangedStyle{
330+					Start: formatted.Len(),
331+					Style: current,
332+				})
333+			}
334+		}
335+		last = current
336+		raw = raw[runeSize:]
337+	}
338 
339-var baseCodes = []tcell.Color{
340-	tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen,
341-	tcell.ColorRed, tcell.ColorBrown, tcell.ColorPurple, tcell.ColorOrange,
342-	tcell.ColorYellow, tcell.ColorLightGreen, tcell.ColorTeal, tcell.ColorLightCyan,
343-	tcell.ColorLightBlue, tcell.ColorPink, tcell.ColorGrey, tcell.ColorLightGrey,
344+	return StyledString{
345+		string: formatted.String(),
346+		styles: styles,
347+	}
348 }
349 
350-var ansiCodes = []uint64{
351-	/* 16-27 */ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
352-	/* 28-39 */ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
353-	/* 40-51 */ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
354-	/* 52-63 */ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
355-	/* 64-75 */ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
356-	/* 76-87 */ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
357-	/* 88-98 */ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
358+type StyledStringBuilder struct {
359+	strings.Builder
360+	styles []rangedStyle
361 }
362 
363-var hexCodes = []int32{
364-	0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a,
365-	0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045,
366-	0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b,
367-	0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff, 0xa500ff, 0xff00ff, 0xff0098,
368-	0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc,
369-	0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3,
370-	0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff,
371+func (sb *StyledStringBuilder) WriteStyledString(s StyledString) {
372+	start := len(sb.styles)
373+	sb.styles = append(sb.styles, s.styles...)
374+	for i := start; i < len(sb.styles); i++ {
375+		sb.styles[i].Start += sb.Len()
376+	}
377+	sb.WriteString(s.string)
378 }
379 
380-func colorFromCode(code int) (color tcell.Color) {
381-	if code < 0 || 99 <= code {
382-		color = tcell.ColorDefault
383-	} else if code < 16 {
384-		color = baseCodes[code]
385-	} else {
386-		color = tcell.NewHexColor(hexCodes[code-16])
387+func (sb *StyledStringBuilder) AddStyle(start int, style tcell.Style) {
388+	for i := 0; i < len(sb.styles); i++ {
389+		if sb.styles[i].Start == i {
390+			sb.styles[i].Style = style
391+			break
392+		} else if sb.styles[i].Start < i {
393+			sb.styles = append(sb.styles[:i+1], sb.styles[i:]...)
394+			sb.styles[i+1] = rangedStyle{
395+				Start: start,
396+				Style: style,
397+			}
398+			break
399+		}
400 	}
401-	return
402+	sb.styles = append(sb.styles, rangedStyle{
403+		Start: start,
404+		Style: style,
405+	})
406 }
407 
408-// see <https://modern.ircdocs.horse/formatting.html>
409-var identColorBlacklist = []int{
410-	1, 8, 16, 17, 24, 27, 28, 36, 48, 60, 88, 89, 90, 91,
411+func (sb *StyledStringBuilder) SetStyle(style tcell.Style) {
412+	sb.styles = append(sb.styles, rangedStyle{
413+		Start: sb.Len(),
414+		Style: style,
415+	})
416 }
417 
418-func IdentColor(s string) (code int) {
419-	h := fnv.New32()
420-	_, _ = h.Write([]byte(s))
421-
422-	code = int(h.Sum32()) % (99 - len(identColorBlacklist))
423-	for _, c := range identColorBlacklist {
424-		if c <= code {
425-			code++
426-		}
427+func (sb *StyledStringBuilder) StyledString() StyledString {
428+	styles := sb.styles
429+	if len(sb.styles) != 0 && sb.styles[len(sb.styles)-1].Start == sb.Len() {
430+		styles = sb.styles[:len(sb.styles)-1]
431+	}
432+	return StyledString{
433+		string: sb.String(),
434+		styles: styles,
435 	}
436-
437-	return
438 }
+87, -16
  1@@ -1,25 +1,96 @@
  2 package ui
  3 
  4-import "testing"
  5+import (
  6+	"testing"
  7 
  8-func assertStringWidth(t *testing.T, input string, expected int) {
  9-	actual := StringWidth(input)
 10-	if actual != expected {
 11-		t.Errorf("%q: expected width of %d got %d", input, expected, actual)
 12+	"github.com/gdamore/tcell/v2"
 13+)
 14+
 15+func assertIRCString(t *testing.T, input string, expected StyledString) {
 16+	actual := IRCString(input)
 17+	if actual.string != expected.string {
 18+		t.Errorf("%q: expected string %q, got %q", input, expected.string, actual.string)
 19+	}
 20+	if len(actual.styles) != len(expected.styles) {
 21+		t.Errorf("%q: expected %d styles, got %d", input, len(expected.styles), len(actual.styles))
 22+		return
 23+	}
 24+	for i := range actual.styles {
 25+		if actual.styles[i] != expected.styles[i] {
 26+			t.Errorf("%q: style #%d expected to be %+v, got %+v", input, i, expected.styles[i], actual.styles[i])
 27+		}
 28 	}
 29 }
 30 
 31-func TestStringWidth(t *testing.T) {
 32-	assertStringWidth(t, "", 0)
 33+func TestIRCString(t *testing.T) {
 34+	assertIRCString(t, "", StyledString{
 35+		string: "",
 36+		styles: nil,
 37+	})
 38 
 39-	assertStringWidth(t, "hello", 5)
 40-	assertStringWidth(t, "\x02hello", 5)
 41-	assertStringWidth(t, "\x035hello", 5)
 42-	assertStringWidth(t, "\x0305hello", 5)
 43-	assertStringWidth(t, "\x0305,0hello", 5)
 44-	assertStringWidth(t, "\x0305,09hello", 5)
 45+	assertIRCString(t, "hello", StyledString{
 46+		string: "hello",
 47+		styles: nil,
 48+	})
 49+	assertIRCString(t, "\x02hello", StyledString{
 50+		string: "hello",
 51+		styles: []rangedStyle{
 52+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Bold(true)},
 53+		},
 54+	})
 55+	assertIRCString(t, "\x035hello", StyledString{
 56+		string: "hello",
 57+		styles: []rangedStyle{
 58+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
 59+		},
 60+	})
 61+	assertIRCString(t, "\x0305hello", StyledString{
 62+		string: "hello",
 63+		styles: []rangedStyle{
 64+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
 65+		},
 66+	})
 67+	assertIRCString(t, "\x0305,0hello", StyledString{
 68+		string: "hello",
 69+		styles: []rangedStyle{
 70+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
 71+		},
 72+	})
 73+	assertIRCString(t, "\x035,00hello", StyledString{
 74+		string: "hello",
 75+		styles: []rangedStyle{
 76+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
 77+		},
 78+	})
 79+	assertIRCString(t, "\x0305,00hello", StyledString{
 80+		string: "hello",
 81+		styles: []rangedStyle{
 82+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
 83+		},
 84+	})
 85 
 86-	assertStringWidth(t, "\x0305,hello", 6)
 87-	assertStringWidth(t, "\x03050hello", 6)
 88-	assertStringWidth(t, "\x0305,090hello", 6)
 89+	assertIRCString(t, "\x035,hello", StyledString{
 90+		string: ",hello",
 91+		styles: []rangedStyle{
 92+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
 93+		},
 94+	})
 95+	assertIRCString(t, "\x0305,hello", StyledString{
 96+		string: ",hello",
 97+		styles: []rangedStyle{
 98+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
 99+		},
100+	})
101+	assertIRCString(t, "\x03050hello", StyledString{
102+		string: "0hello",
103+		styles: []rangedStyle{
104+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
105+		},
106+	})
107+	assertIRCString(t, "\x0305,000hello", StyledString{
108+		string: "0hello",
109+		styles: []rangedStyle{
110+			rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
111+		},
112+	})
113 }
+14, -7
 1@@ -12,7 +12,7 @@ type Config struct {
 2 	NickColWidth int
 3 	ChanColWidth int
 4 	AutoComplete func(cursorIdx int, text []rune) []Completion
 5-	Mouse bool
 6+	Mouse        bool
 7 }
 8 
 9 type UI struct {
10@@ -23,7 +23,7 @@ type UI struct {
11 
12 	bs     BufferList
13 	e      Editor
14-	prompt string
15+	prompt StyledString
16 	status string
17 }
18 
19@@ -159,7 +159,7 @@ func (ui *UI) SetStatus(status string) {
20 	ui.status = status
21 }
22 
23-func (ui *UI) SetPrompt(prompt string) {
24+func (ui *UI) SetPrompt(prompt StyledString) {
25 	ui.prompt = prompt
26 }
27 
28@@ -245,8 +245,7 @@ func (ui *UI) Draw() {
29 	for x := ui.config.ChanColWidth; x < 9+ui.config.ChanColWidth+ui.config.NickColWidth; x++ {
30 		ui.screen.SetContent(x, h-1, ' ', nil, tcell.StyleDefault)
31 	}
32-	st := tcell.StyleDefault.Foreground(colorFromCode(IdentColor(ui.prompt)))
33-	printIdent(ui.screen, ui.config.ChanColWidth+7, h-1, ui.config.NickColWidth, st, ui.prompt)
34+	printIdent(ui.screen, ui.config.ChanColWidth+7, h-1, ui.config.NickColWidth, ui.prompt)
35 
36 	ui.screen.Show()
37 }
38@@ -262,8 +261,16 @@ func (ui *UI) drawStatusBar(x0, y, width int) {
39 		return
40 	}
41 
42+	s := new(StyledStringBuilder)
43+	s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
44+	s.WriteString("--")
45+
46 	x := x0 + 5 + ui.config.NickColWidth
47-	printString(ui.screen, &x, y, st, "--")
48+	printString(ui.screen, &x, y, s.StyledString())
49 	x += 2
50-	printString(ui.screen, &x, y, st, ui.status)
51+
52+	s.Reset()
53+	s.WriteString(ui.status)
54+
55+	printString(ui.screen, &x, y, s.StyledString())
56 }
+16, -11
 1@@ -1,30 +1,23 @@
 2 package senpai
 3 
 4 import (
 5-	"math/rand"
 6+	"hash/fnv"
 7 	"strings"
 8 	"time"
 9 
10 	"git.sr.ht/~taiite/senpai/ui"
11+	"github.com/gdamore/tcell/v2"
12 )
13 
14 var Home = "home"
15 
16-var homeMessages = []string{
17-	"\x1dYou open an IRC client.",
18-	"Welcome to the Internet Relay Network!",
19-	"DMs & cie go here.",
20-	"May the IRC be with you.",
21-	"Hey! I'm senpai, you everyday IRC student!",
22-	"Student? No, I'm an IRC \x02client\x02!",
23-}
24+const welcomeMessage = "senpai dev build. See senpai(1) for a list of keybindings and commands. Private messages and status notices go here."
25 
26 func (app *App) initWindow() {
27-	hmIdx := rand.Intn(len(homeMessages))
28 	app.win.AddBuffer(Home)
29 	app.win.AddLine(Home, false, ui.Line{
30 		Head: "--",
31-		Body: homeMessages[hmIdx],
32+		Body: ui.PlainString(welcomeMessage),
33 		At:   time.Now(),
34 	})
35 }
36@@ -67,3 +60,15 @@ func (app *App) setStatus() {
37 	}
38 	app.win.SetStatus(status)
39 }
40+
41+func identColor(ident string) tcell.Color {
42+	h := fnv.New32()
43+	_, _ = h.Write([]byte(ident))
44+	return tcell.Color((h.Sum32()%15)+1) + tcell.ColorValid
45+}
46+
47+func identString(ident string) ui.StyledString {
48+	color := identColor(ident)
49+	style := tcell.StyleDefault.Foreground(color)
50+	return ui.Styled(ident, style)
51+}