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
+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 }
M
ui/ui.go
+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+}