diff options
-rw-r--r-- | app.go | 174 | ||||
-rw-r--r-- | commands.go | 44 | ||||
-rw-r--r-- | ui/buffers.go | 84 | ||||
-rw-r--r-- | ui/buffers_test.go | 20 | ||||
-rw-r--r-- | ui/draw_utils.go | 39 | ||||
-rw-r--r-- | ui/style.go | 375 | ||||
-rw-r--r-- | ui/style_test.go | 103 | ||||
-rw-r--r-- | ui/ui.go | 21 | ||||
-rw-r--r-- | window.go | 27 |
9 files changed, 515 insertions, 372 deletions
@@ -8,6 +8,7 @@ import ( "os/exec" "strings" "time" + "unicode" "git.sr.ht/~taiite/senpai/irc" "git.sr.ht/~taiite/senpai/ui" @@ -69,7 +70,7 @@ func NewApp(cfg Config) (app *App, err error) { if err != nil { return } - app.win.SetPrompt(">") + app.win.SetPrompt(ui.PlainString(">")) app.initWindow() @@ -147,7 +148,7 @@ func (app *App) ircLoop() { app.queueStatusLine(ui.Line{ At: time.Now(), Head: "IN --", - Body: msg.String(), + Body: ui.PlainString(msg.String()), }) } app.events <- event{ @@ -161,8 +162,8 @@ func (app *App) ircLoop() { } app.queueStatusLine(ui.Line{ Head: "!!", - HeadColor: ui.ColorRed, - Body: "Connection lost", + HeadColor: tcell.ColorRed, + Body: ui.PlainString("Connection lost"), }) time.Sleep(10 * time.Second) } @@ -172,7 +173,7 @@ func (app *App) connect() net.Conn { for { app.queueStatusLine(ui.Line{ Head: "--", - Body: fmt.Sprintf("Connecting to %s...", app.cfg.Addr), + Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr), }) conn, err := app.tryConnect() if err == nil { @@ -180,8 +181,8 @@ func (app *App) connect() net.Conn { } app.queueStatusLine(ui.Line{ Head: "!!", - HeadColor: ui.ColorRed, - Body: fmt.Sprintf("Connection failed: %v", err), + HeadColor: tcell.ColorRed, + Body: ui.PlainSprintf("Connection failed: %v", err), }) time.Sleep(1 * time.Minute) } @@ -229,7 +230,7 @@ func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message { app.queueStatusLine(ui.Line{ At: time.Now(), Head: "OUT --", - Body: msg.String(), + Body: ui.PlainString(msg.String()), }) out <- msg } @@ -408,8 +409,8 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) { app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: time.Now(), Head: "!!", - HeadColor: ui.ColorRed, - Body: fmt.Sprintf("%q: %s", input, err), + HeadColor: tcell.ColorRed, + Body: ui.PlainSprintf("%q: %s", input, err), }) } app.updatePrompt() @@ -459,28 +460,48 @@ func (app *App) handleIRCEvent(ev interface{}) { // Mutate UI state switch ev := ev.(type) { case irc.RegisteredEvent: - body := "Connected to the server" + body := new(ui.StyledStringBuilder) + body.WriteString("Connected to the server") if app.s.Nick() != app.cfg.Nick { - body += " as " + app.s.Nick() + body.WriteString(" as ") + body.WriteString(app.s.Nick()) } app.win.AddLine(Home, false, ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: body, + Body: body.StyledString(), }) case irc.SelfNickEvent: - app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{ + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.FormerNick) + 4 + len(app.s.Nick())) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.FormerNick) + body.SetStyle(tcell.StyleDefault) + body.WriteRune('\u2192') // right arrow + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(app.s.Nick()) + app.addStatusLine(ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()), + HeadColor: tcell.ColorGray, + Body: body.StyledString(), Highlight: true, }) case irc.UserNickEvent: + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.FormerNick) + 4 + len(ev.User)) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.FormerNick) + body.SetStyle(tcell.StyleDefault) + body.WriteRune('\u2192') // right arrow + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.User) for _, c := range app.s.ChannelsSharedWith(ev.User) { app.win.AddLine(c, false, ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User), + HeadColor: tcell.ColorGray, + Body: body.StyledString(), Mergeable: true, }) } @@ -490,41 +511,68 @@ func (app *App) handleIRCEvent(ev interface{}) { WithLimit(200). Before(msg.TimeOrNow()) case irc.UserJoinEvent: + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.User) + 1) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen)) + body.WriteByte('+') + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.User) app.win.AddLine(ev.Channel, false, ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: fmt.Sprintf("\x033+\x0314%s\x03", ev.User), + HeadColor: tcell.ColorGray, + Body: body.StyledString(), Mergeable: true, }) case irc.SelfPartEvent: app.win.RemoveBuffer(ev.Channel) case irc.UserPartEvent: + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.User) + 1) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed)) + body.WriteByte('-') + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.User) app.win.AddLine(ev.Channel, false, ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User), + HeadColor: tcell.ColorGray, + Body: body.StyledString(), Mergeable: true, }) case irc.UserQuitEvent: + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.User) + 1) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed)) + body.WriteByte('-') + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString(ev.User) for _, c := range ev.Channels { app.win.AddLine(c, false, ui.Line{ At: msg.TimeOrNow(), Head: "--", - Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User), + HeadColor: tcell.ColorGray, + Body: body.StyledString(), Mergeable: true, }) } case irc.TopicChangeEvent: + body := new(ui.StyledStringBuilder) + body.Grow(len(ev.Topic) + 18) + body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + body.WriteString("Topic changed to: ") + body.WriteString(ev.Topic) app.win.AddLine(ev.Channel, false, ui.Line{ - At: msg.TimeOrNow(), - Head: "--", - Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic), + At: msg.TimeOrNow(), + Head: "--", + HeadColor: tcell.ColorGray, + Body: body.StyledString(), }) case irc.MessageEvent: buffer, line, hlNotification := app.formatMessage(ev) app.win.AddLine(buffer, hlNotification, line) if hlNotification { - app.notifyHighlight(buffer, ev.User, ev.Content) + app.notifyHighlight(buffer, ev.User, line.Body.String()) } if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) { app.lastQuery = msg.Prefix.Name @@ -561,7 +609,7 @@ func (app *App) handleIRCEvent(ev interface{}) { app.addStatusLine(ui.Line{ At: msg.TimeOrNow(), Head: head, - Body: body, + Body: ui.PlainString(body), }) } } @@ -608,7 +656,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) { fmt.Sprintf("BUFFER=%s", buffer), fmt.Sprintf("HERE=%s", here), fmt.Sprintf("SENDER=%s", nick), - fmt.Sprintf("MESSAGE=%s", cleanMessage(content)), + fmt.Sprintf("MESSAGE=%s", content), ) output, err := cmd.CombinedOutput() if err != nil { @@ -616,8 +664,8 @@ func (app *App) notifyHighlight(buffer, nick, content string) { app.addStatusLine(ui.Line{ At: time.Now(), Head: "!!", - HeadColor: ui.ColorRed, - Body: body, + HeadColor: tcell.ColorRed, + Body: ui.PlainString(body), }) } } @@ -690,33 +738,44 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification = (isHighlight || isQuery) && !isFromSelf head := ev.User - headColor := ui.ColorWhite + headColor := tcell.ColorWhite if isFromSelf && isQuery { head = "\u2192 " + ev.Target - headColor = ui.IdentColor(ev.Target) + headColor = identColor(ev.Target) } else if isAction || isNotice { head = "*" } else { - headColor = ui.IdentColor(head) - } - - body := strings.TrimSuffix(ev.Content, "\x01") - if isNotice && isAction { - c := ircColorSequence(ui.IdentColor(ev.User)) - body = fmt.Sprintf("%s%s\x0F:%s", c, ev.User, body[7:]) + headColor = identColor(head) + } + + content := strings.TrimSuffix(ev.Content, "\x01") + content = strings.TrimRightFunc(ev.Content, unicode.IsSpace) + if isAction { + content = content[7:] + } + body := new(ui.StyledStringBuilder) + if isNotice { + color := identColor(ev.User) + body.SetStyle(tcell.StyleDefault.Foreground(color)) + body.WriteString(ev.User) + body.SetStyle(tcell.StyleDefault) + body.WriteString(": ") + body.WriteStyledString(ui.IRCString(content)) } else if isAction { - c := ircColorSequence(ui.IdentColor(ev.User)) - body = fmt.Sprintf("%s%s\x0F%s", c, ev.User, body[7:]) - } else if isNotice { - c := ircColorSequence(ui.IdentColor(ev.User)) - body = fmt.Sprintf("%s%s\x0F: %s", c, ev.User, body) + color := identColor(ev.User) + body.SetStyle(tcell.StyleDefault.Foreground(color)) + body.WriteString(ev.User) + body.SetStyle(tcell.StyleDefault) + body.WriteStyledString(ui.IRCString(content)) + } else { + body.WriteStyledString(ui.IRCString(content)) } line = ui.Line{ At: ev.Time, Head: head, - Body: body, HeadColor: headColor, + Body: body.StyledString(), Highlight: hlLine, } return @@ -726,34 +785,11 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, func (app *App) updatePrompt() { buffer := app.win.CurrentBuffer() command := app.win.InputIsCommand() + var prompt ui.StyledString if buffer == Home || command { - app.win.SetPrompt(">") + prompt = ui.PlainString(">") } else { - app.win.SetPrompt(app.s.Nick()) - } -} - -// ircColorSequence returns the color formatting sequence of a color code. -func ircColorSequence(code int) string { - var c [3]rune - c[0] = 0x03 - c[1] = rune(code/10) + '0' - c[2] = rune(code%10) + '0' - return string(c[:]) -} - -// cleanMessage removes IRC formatting from a string. -func cleanMessage(s string) string { - var res strings.Builder - var sb ui.StyleBuffer - res.Grow(len(s)) - for _, r := range s { - if _, ok := sb.WriteRune(r); ok != 0 { - if 1 < ok { - res.WriteRune(',') - } - res.WriteRune(r) - } + prompt = identString(app.s.Nick()) } - return res.String() + app.win.SetPrompt(prompt) } diff --git a/commands.go b/commands.go index 009bc81..a704acf 100644 --- a/commands.go +++ b/commands.go @@ -7,6 +7,7 @@ import ( "git.sr.ht/~taiite/senpai/irc" "git.sr.ht/~taiite/senpai/ui" + "github.com/gdamore/tcell/v2" ) type command struct { @@ -151,7 +152,7 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) { app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, Head: "--", - Body: "Available commands:", + Body: ui.PlainString("Available commands:"), }) for cmdName, cmd := range commands { if cmd.Desc == "" { @@ -159,11 +160,11 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) { } app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, - Body: fmt.Sprintf(" \x02%s\x02 %s", cmdName, cmd.Usage), + Body: ui.PlainSprintf(" \x02%s\x02 %s", cmdName, cmd.Usage), }) app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, - Body: fmt.Sprintf(" %s", cmd.Desc), + Body: ui.PlainSprintf(" %s", cmd.Desc), }) app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, @@ -175,19 +176,26 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) { app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, Head: "--", - Body: fmt.Sprintf("Commands that match \"%s\":", search), + Body: ui.PlainSprintf("Commands that match \"%s\":", search), }) for cmdName, cmd := range commands { if !strings.Contains(cmdName, search) { continue } + usage := new(ui.StyledStringBuilder) + usage.Grow(len(cmdName) + 1 + len(cmd.Usage)) + usage.SetStyle(tcell.StyleDefault.Bold(true)) + usage.WriteString(cmdName) + usage.SetStyle(tcell.StyleDefault) + usage.WriteByte(' ') + usage.WriteString(cmd.Usage) app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, - Body: fmt.Sprintf("\x02%s\x02 %s", cmdName, cmd.Usage), + Body: usage.StyledString(), }) app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, - Body: fmt.Sprintf(" %s", cmd.Desc), + Body: ui.PlainSprintf(" %s", cmd.Desc), }) app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, @@ -197,7 +205,7 @@ func commandDoHelp(app *App, buffer string, args []string) (err error) { if !found { app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{ At: t, - Body: fmt.Sprintf(" no command matches %q", args[0]), + Body: ui.PlainSprintf(" no command matches %q", args[0]), }) } } @@ -252,22 +260,24 @@ func commandDoMsg(app *App, buffer string, args []string) (err error) { } func commandDoNames(app *App, buffer string, args []string) (err error) { - var sb strings.Builder - sb.WriteString("\x0314Names: ") + sb := new(ui.StyledStringBuilder) + sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey)) + sb.WriteString("Names: ") for _, name := range app.s.Names(buffer) { if name.PowerLevel != "" { - sb.WriteString("\x033") + sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen)) sb.WriteString(name.PowerLevel) - sb.WriteString("\x0314") + sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey)) } sb.WriteString(name.Name.Name) - sb.WriteRune(' ') + sb.WriteByte(' ') } - body := sb.String() + body := sb.StyledString() + // TODO remove last space app.win.AddLine(buffer, false, ui.Line{ At: time.Now(), Head: "--", - Body: body[:len(body)-1], + Body: body, }) return } @@ -351,14 +361,14 @@ func commandDoTopic(app *App, buffer string, args []string) (err error) { topic, who, at := app.s.Topic(buffer) if who == nil { - body = fmt.Sprintf("\x0314Topic: %s", topic) + body = fmt.Sprintf("Topic: %s", topic) } else { - body = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic) + body = fmt.Sprintf("Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic) } app.win.AddLine(buffer, false, ui.Line{ At: time.Now(), Head: "--", - Body: body, + Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)), }) } else { app.s.ChangeTopic(buffer, args[0]) diff --git a/ui/buffers.go b/ui/buffers.go index b6020a8..0ebaadf 100644 --- a/ui/buffers.go +++ b/ui/buffers.go @@ -19,8 +19,8 @@ type point struct { type Line struct { At time.Time Head string - Body string - HeadColor int + Body StyledString + HeadColor tcell.Color Highlight bool Mergeable bool @@ -34,29 +34,29 @@ func (l *Line) computeSplitPoints() { l.splitPoints = []point{} } - var wb widthBuffer + width := 0 lastWasSplit := false l.splitPoints = l.splitPoints[:0] - for i, r := range l.Body { + for i, r := range l.Body.string { curIsSplit := IsSplitRune(r) if i == 0 || lastWasSplit != curIsSplit { l.splitPoints = append(l.splitPoints, point{ - X: wb.Width(), + X: width, I: i, Split: curIsSplit, }) } lastWasSplit = curIsSplit - wb.WriteRune(r) + width += runeWidth(r) } if !lastWasSplit { l.splitPoints = append(l.splitPoints, point{ - X: wb.Width(), - I: len(l.Body), + X: width, + I: len(l.Body.string), Split: true, }) } @@ -118,16 +118,17 @@ func (l *Line) NewLines(width int) []int { // terminal. In this case, no newline is placed before (like in the // 2nd if-else branch). The for loop is used to place newlines in // the word. - var wb widthBuffer + // TODO handle multi-codepoint graphemes?? :( + wordWidth := 0 h := 1 - for j, r := range l.Body[sp1.I:sp2.I] { - wb.WriteRune(r) - if h*width < x+wb.Width() { + for j, r := range l.Body.string[sp1.I:sp2.I] { + wordWidth += runeWidth(r) + if h*width < x+wordWidth { l.newLines = append(l.newLines, sp1.I+j) h++ } } - x = (x + wb.Width()) % width + x = (x + wordWidth) % width if x == 0 { // The placement of the word is such that it ends right at the // end of the row. @@ -147,7 +148,7 @@ func (l *Line) NewLines(width int) []int { } } - if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body) { + if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body.string) { // DROP any newline that is placed at the end of the string because we // don't care about those. l.newLines = l.newLines[:len(l.newLines)-1] @@ -255,14 +256,19 @@ func (bs *BufferList) AddLine(title string, highlight bool, line Line) { b := &bs.list[idx] n := len(b.lines) - line.Body = strings.TrimRight(line.Body, "\t ") line.At = line.At.UTC() if line.Mergeable && n != 0 && b.lines[n-1].Mergeable { l := &b.lines[n-1] - l.Body += " " + line.Body + newBody := new(StyledStringBuilder) + newBody.Grow(len(l.Body.string) + 2 + len(line.Body.string)) + newBody.WriteStyledString(l.Body) + newBody.WriteString(" ") + newBody.WriteStyledString(line.Body) + l.Body = newBody.StyledString() l.computeSplitPoints() l.width = 0 + // TODO change b.scrollAmt if it's not 0 and bs.current is idx. } else { line.computeSplitPoints() b.lines = append(b.lines, line) @@ -307,7 +313,7 @@ func (bs *BufferList) AddLines(title string, lines []Line) { limit = i break } - if l.Body == firstLineBody { + if l.Body.string == firstLineBody.string { // This line happened at the same millisecond // as the first line of the buffer, and has the // same contents. Heuristic: it's the same @@ -396,7 +402,7 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width, st = st.Reverse(true) } title := truncate(b.title, width, "\u2026") - printString(screen, &x, y, st, title) + printString(screen, &x, y, Styled(title, st)) if 0 < b.highlights { st = st.Foreground(tcell.ColorRed).Reverse(true) screen.SetContent(x, y, ' ', nil, st) @@ -417,10 +423,9 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width, } func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int) { - st := tcell.StyleDefault for x := x0; x < x0+bs.tlWidth; x++ { for y := y0; y < y0+bs.tlHeight; y++ { - screen.SetContent(x, y, ' ', nil, st) + screen.SetContent(x, y, ' ', nil, tcell.StyleDefault) } } @@ -441,18 +446,25 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int } if i == 0 || b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) { - printTime(screen, x0, yi, st.Bold(true), line.At.Local()) + st := tcell.StyleDefault.Bold(true) + printTime(screen, x0, yi, st, line.At.Local()) } - identSt := st.Foreground(colorFromCode(line.HeadColor)).Reverse(line.Highlight) - printIdent(screen, x0+7, yi, nickColWidth, identSt, line.Head) + identSt := tcell.StyleDefault. + Foreground(line.HeadColor). + Reverse(line.Highlight) + printIdent(screen, x0+7, yi, nickColWidth, Styled(line.Head, identSt)) x := x1 y := yi + style := tcell.StyleDefault + nextStyles := line.Body.styles - var sb StyleBuffer - sb.Reset() - for i, r := range line.Body { + for i, r := range line.Body.string { + if 0 < len(nextStyles) && nextStyles[0].Start == i { + style = nextStyles[0].Style + nextStyles = nextStyles[1:] + } if 0 < len(nls) && i == nls[0] { x = x1 y++ @@ -466,26 +478,10 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int continue } - if st, ok := sb.WriteRune(r); ok != 0 { - if 1 < ok { - screen.SetContent(x, y, ',', nil, st) - x++ - } - screen.SetContent(x, y, r, nil, st) - x += runeWidth(r) - } + screen.SetContent(x, y, r, nil, style) + x += runeWidth(r) } - - sb.Reset() } b.isAtTop = y0 <= yi } - -func IrcColorCode(code int) string { - var c [3]rune - c[0] = 0x03 - c[1] = rune(code/10) + '0' - c[2] = rune(code%10) + '0' - return string(c[:]) -} diff --git a/ui/buffers_test.go b/ui/buffers_test.go index b78f8e9..13a32de 100644 --- a/ui/buffers_test.go +++ b/ui/buffers_test.go @@ -6,7 +6,7 @@ import ( ) func assertSplitPoints(t *testing.T, body string, expected []point) { - l := Line{Body: body} + l := Line{Body: PlainString(body)} l.computeSplitPoints() if len(l.splitPoints) != len(expected) { @@ -71,7 +71,7 @@ func showSplit(s string, nls []int) string { } func assertNewLines(t *testing.T, body string, width int, expected int) { - l := Line{Body: body} + l := Line{Body: PlainString(body)} l.computeSplitPoints() actual := l.NewLines(width) @@ -141,6 +141,8 @@ func TestRenderedHeight(t *testing.T) { assertNewLines(t, "have a good day!", 17, 1) // |have a good day! | // LEN=15, WIDTH=11 + // TODO find other zero- or two-width chars, since IRC color codes are + // now handled by app.go assertNewLines(t, "\x0342barmand\x03: cc", 1, 10) // |b|a|r|m|a|n|d|:|c|c| assertNewLines(t, "\x0342barmand\x03: cc", 2, 5) // |ba|rm|an|d:|cc| assertNewLines(t, "\x0342barmand\x03: cc", 3, 4) // |bar|man|d: |cc | @@ -160,17 +162,3 @@ func TestRenderedHeight(t *testing.T) { assertNewLines(t, "cc en direct du word wrapping des familles le tests ça v a va va v a va", 46, 2) } - -/* -func assertTrimWidth(t *testing.T, s string, w int, expected string) { - actual := trimWidth(s, w) - if actual != expected { - t.Errorf("%q (width=%d): expected to be trimmed as %q, got %q\n", s, w, expected, actual) - } -} - -func TestTrimWidth(t *testing.T) { - assertTrimWidth(t, "ludovicchabant/fn", 16, "ludovicchabant/…") - assertTrimWidth(t, "zzzzzzzzzzzzzz黒猫/sr", 16, "zzzzzzzzzzzzzz黒…") -} -// */ diff --git a/ui/draw_utils.go b/ui/draw_utils.go index 893a77f..947e71b 100644 --- a/ui/draw_utils.go +++ b/ui/draw_utils.go @@ -7,24 +7,39 @@ import ( "github.com/gdamore/tcell/v2" ) -func printIdent(screen tcell.Screen, x, y, width int, st tcell.Style, s string) { - s = truncate(s, width, "\u2026") - x += width - StringWidth(s) - screen.SetContent(x-1, y, ' ', nil, st) - printString(screen, &x, y, st, s) - screen.SetContent(x, y, ' ', nil, st) +func printString(screen tcell.Screen, x *int, y int, s StyledString) { + style := tcell.StyleDefault + nextStyles := s.styles + for i, r := range s.string { + if 0 < len(nextStyles) && nextStyles[0].Start == i { + style = nextStyles[0].Style + nextStyles = nextStyles[1:] + } + screen.SetContent(*x, y, r, nil, style) + *x += runeWidth(r) + } } -func printString(screen tcell.Screen, x *int, y int, st tcell.Style, s string) { - for _, r := range s { - screen.SetContent(*x, y, r, nil, st) - *x += runeWidth(r) +func printIdent(screen tcell.Screen, x, y, width int, s StyledString) { + s.string = truncate(s.string, width, "\u2026") + x += width - stringWidth(s.string) + st := tcell.StyleDefault + if len(s.styles) != 0 && s.styles[0].Start == 0 { + st = s.styles[0].Style + } + screen.SetContent(x-1, y, ' ', nil, st) + printString(screen, &x, y, s) + if len(s.styles) != 0 { + // TODO check if it's not a style that is from the truncated + // part of s. + st = s.styles[len(s.styles)-1].Style } + screen.SetContent(x, y, ' ', nil, st) } func printNumber(screen tcell.Screen, x *int, y int, st tcell.Style, n int) { - s := fmt.Sprintf("%d", n) - printString(screen, x, y, st, s) + s := Styled(fmt.Sprintf("%d", n), st) + printString(screen, x, y, s) } func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) { diff --git a/ui/style.go b/ui/style.go index d86e9f4..f596ac0 100644 --- a/ui/style.go +++ b/ui/style.go @@ -1,7 +1,10 @@ package ui import ( - "hash/fnv" + "fmt" + "strconv" + "strings" + "unicode/utf8" "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" @@ -13,234 +16,246 @@ func runeWidth(r rune) int { return condition.RuneWidth(r) } +func stringWidth(s string) int { + return condition.StringWidth(s) +} + func truncate(s string, w int, tail string) string { return condition.Truncate(s, w, tail) } -type widthBuffer struct { - width int - color colorBuffer +// Taken from <https://modern.ircdocs.horse/formatting.html> + +var baseCodes = []tcell.Color{ + tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen, + tcell.ColorRed, tcell.ColorBrown, tcell.ColorPurple, tcell.ColorOrange, + tcell.ColorYellow, tcell.ColorLightGreen, tcell.ColorTeal, tcell.ColorLightCyan, + tcell.ColorLightBlue, tcell.ColorPink, tcell.ColorGrey, tcell.ColorLightGrey, } -func (wb *widthBuffer) Width() int { - return wb.width +// unused +var ansiCodes = []uint64{ + /* 16-27 */ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, + /* 28-39 */ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, + /* 40-51 */ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, + /* 52-63 */ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, + /* 64-75 */ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, + /* 76-87 */ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, + /* 88-98 */ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, } -func (wb *widthBuffer) WriteString(s string) { - for _, r := range s { - wb.WriteRune(r) - } +var hexCodes = []int32{ + 0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a, + 0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045, + 0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b, + 0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff, 0xa500ff, 0xff00ff, 0xff0098, + 0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc, + 0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3, + 0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff, } -func (wb *widthBuffer) WriteRune(r rune) { - if ok := wb.color.WriteRune(r); ok != 0 { - if 1 < ok { - wb.width++ - } - wb.width += runeWidth(r) +func colorFromCode(code int) (color tcell.Color) { + if code < 0 || 99 <= code { + color = tcell.ColorDefault + } else if code < 16 { + color = baseCodes[code] + } else { + color = tcell.NewHexColor(hexCodes[code-16]) } + return } -func StringWidth(s string) int { - var wb widthBuffer - wb.WriteString(s) - return wb.Width() +type rangedStyle struct { + Start int // byte index at which Style is effective + Style tcell.Style } -type StyleBuffer struct { - st tcell.Style - color colorBuffer - bold bool - reverse bool - italic bool - strikethrough bool - underline bool +type StyledString struct { + string + styles []rangedStyle // sorted, elements cannot have the same Start value } -func (sb *StyleBuffer) Reset() { - sb.color.Reset() - sb.st = tcell.StyleDefault - sb.bold = false - sb.reverse = false - sb.italic = false - sb.strikethrough = false - sb.underline = false +func PlainString(s string) StyledString { + return StyledString{string: s} } -func (sb *StyleBuffer) WriteRune(r rune) (st tcell.Style, ok int) { - if r == 0x00 || r == 0x0F { - sb.Reset() - return sb.st, 0 - } - if r == 0x02 { - sb.bold = !sb.bold - sb.st = sb.st.Bold(sb.bold) - return sb.st, 0 - } - if r == 0x16 { - sb.reverse = !sb.reverse - sb.st = st.Reverse(sb.reverse) - return sb.st, 0 - } - if r == 0x1D { - sb.italic = !sb.italic - sb.st = st.Italic(sb.italic) - return sb.st, 0 - } - if r == 0x1E { - sb.strikethrough = !sb.strikethrough - sb.st = st.StrikeThrough(sb.strikethrough) - return sb.st, 0 - } - if r == 0x1F { - sb.underline = !sb.underline - sb.st = st.Underline(sb.underline) - return sb.st, 0 +func PlainSprintf(format string, a ...interface{}) StyledString { + return PlainString(fmt.Sprintf(format, a...)) +} + +func Styled(s string, style tcell.Style) StyledString { + rStyle := rangedStyle{ + Start: 0, + Style: style, } - if ok = sb.color.WriteRune(r); ok != 0 { - sb.st = sb.color.Style(sb.st) + return StyledString{ + string: s, + styles: []rangedStyle{rStyle}, } - - return sb.st, ok } -type colorBuffer struct { - state int - fg, bg int +func (s StyledString) String() string { + return s.string } -func (cb *colorBuffer) Reset() { - cb.state = 0 - cb.fg = -1 - cb.bg = -1 +func isDigit(c byte) bool { + return '0' <= c && c <= '9' } -func (cb *colorBuffer) Style(st tcell.Style) tcell.Style { - if 0 <= cb.fg { - st = st.Foreground(colorFromCode(cb.fg)) - } else { - st = st.Foreground(tcell.ColorDefault) +func parseColorNumber(raw string) (color tcell.Color, n int) { + if len(raw) == 0 || !isDigit(raw[0]) { + return } - if 0 <= cb.bg { - st = st.Background(colorFromCode(cb.bg)) - } else { - st = st.Background(tcell.ColorDefault) + + // len(raw) >= 1 and its first character is a digit. + + if len(raw) == 1 || !isDigit(raw[1]) { + code, _ := strconv.Atoi(raw[:1]) + return colorFromCode(code), 1 } - return st + + // len(raw) >= 2 and the two first characters are digits. + + code, _ := strconv.Atoi(raw[:2]) + return colorFromCode(code), 2 } -func (cb *colorBuffer) WriteRune(r rune) (ok int) { - if cb.state == 1 { - if '0' <= r && r <= '9' { - cb.fg = int(r - '0') - cb.state = 2 - return - } - } else if cb.state == 2 { - if '0' <= r && r <= '9' { - cb.fg = 10*cb.fg + int(r-'0') - cb.state = 3 - return - } - if r == ',' { - cb.state = 4 - return - } - } else if cb.state == 3 { - if r == ',' { - cb.state = 4 - return - } - } else if cb.state == 4 { - if '0' <= r && r <= '9' { - cb.bg = int(r - '0') - cb.state = 5 - return - } - ok++ - } else if cb.state == 5 { - cb.state = 0 - if '0' <= r && r <= '9' { - cb.bg = 10*cb.bg + int(r-'0') - return - } +func parseColor(raw string) (fg, bg tcell.Color, n int) { + fg, n = parseColorNumber(raw) + raw = raw[n:] + + if len(raw) == 0 || raw[0] != ',' { + return fg, tcell.ColorDefault, n } - if r == 0x03 { - cb.state = 1 - cb.fg = -1 - cb.bg = -1 - return + n++ + bg, p := parseColorNumber(raw[1:]) + n += p + + if bg == tcell.ColorDefault { + // Lone comma, do not parse as part of a color code. + return fg, tcell.ColorDefault, n - 1 } - cb.state = 0 - ok++ - return + return fg, bg, n } -const ( - ColorWhite = iota - ColorBlack - ColorBlue - ColorGreen - ColorRed -) +func IRCString(raw string) StyledString { + var formatted strings.Builder + var styles []rangedStyle + var last tcell.Style -// Taken from <https://modern.ircdocs.horse/formatting.html> + for len(raw) != 0 { + r, runeSize := utf8.DecodeRuneInString(raw) + if r == utf8.RuneError { + break + } + _, _, lastAttrs := last.Decompose() + current := last + if r == 0x0F { + current = tcell.StyleDefault + } else if r == 0x02 { + lastWasBold := lastAttrs&tcell.AttrBold != 0 + current = last.Bold(!lastWasBold) + } else if r == 0x03 { + fg, bg, n := parseColor(raw[1:]) + raw = raw[n:] + if n == 0 { + // Both `fg` and `bg` are equal to + // tcell.ColorDefault. + current = last.Foreground(tcell.ColorDefault). + Background(tcell.ColorDefault) + } else if bg == tcell.ColorDefault { + current = last.Foreground(fg) + } else { + current = last.Foreground(fg).Background(bg) + } + } else if r == 0x16 { + lastWasReverse := lastAttrs&tcell.AttrReverse != 0 + current = last.Reverse(!lastWasReverse) + } else if r == 0x1D { + lastWasItalic := lastAttrs&tcell.AttrItalic != 0 + current = last.Italic(!lastWasItalic) + } else if r == 0x1E { + lastWasStrikeThrough := lastAttrs&tcell.AttrStrikeThrough != 0 + current = last.StrikeThrough(!lastWasStrikeThrough) + } else if r == 0x1F { + lastWasUnderline := lastAttrs&tcell.AttrUnderline != 0 + current = last.Underline(!lastWasUnderline) + } else { + formatted.WriteRune(r) + } + if last != current { + if len(styles) != 0 && styles[len(styles)-1].Start == formatted.Len() { + styles[len(styles)-1] = rangedStyle{ + Start: formatted.Len(), + Style: current, + } + } else { + styles = append(styles, rangedStyle{ + Start: formatted.Len(), + Style: current, + }) + } + } + last = current + raw = raw[runeSize:] + } -var baseCodes = []tcell.Color{ - tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen, - tcell.ColorRed, tcell.ColorBrown, tcell.ColorPurple, tcell.ColorOrange, - tcell.ColorYellow, tcell.ColorLightGreen, tcell.ColorTeal, tcell.ColorLightCyan, - tcell.ColorLightBlue, tcell.ColorPink, tcell.ColorGrey, tcell.ColorLightGrey, + return StyledString{ + string: formatted.String(), + styles: styles, + } } -var ansiCodes = []uint64{ - /* 16-27 */ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, - /* 28-39 */ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, - /* 40-51 */ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, - /* 52-63 */ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, - /* 64-75 */ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, - /* 76-87 */ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, - /* 88-98 */ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, +type StyledStringBuilder struct { + strings.Builder + styles []rangedStyle } -var hexCodes = []int32{ - 0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a, - 0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045, - 0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b, - 0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff, 0xa500ff, 0xff00ff, 0xff0098, - 0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc, - 0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3, - 0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff, +func (sb *StyledStringBuilder) WriteStyledString(s StyledString) { + start := len(sb.styles) + sb.styles = append(sb.styles, s.styles...) + for i := start; i < len(sb.styles); i++ { + sb.styles[i].Start += sb.Len() + } + sb.WriteString(s.string) } -func colorFromCode(code int) (color tcell.Color) { - if code < 0 || 99 <= code { - color = tcell.ColorDefault - } else if code < 16 { - color = baseCodes[code] - } else { - color = tcell.NewHexColor(hexCodes[code-16]) +func (sb *StyledStringBuilder) AddStyle(start int, style tcell.Style) { + for i := 0; i < len(sb.styles); i++ { + if sb.styles[i].Start == i { + sb.styles[i].Style = style + break + } else if sb.styles[i].Start < i { + sb.styles = append(sb.styles[:i+1], sb.styles[i:]...) + sb.styles[i+1] = rangedStyle{ + Start: start, + Style: style, + } + break + } } - return + sb.styles = append(sb.styles, rangedStyle{ + Start: start, + Style: style, + }) } -// see <https://modern.ircdocs.horse/formatting.html> -var identColorBlacklist = []int{ - 1, 8, 16, 17, 24, 27, 28, 36, 48, 60, 88, 89, 90, 91, +func (sb *StyledStringBuilder) SetStyle(style tcell.Style) { + sb.styles = append(sb.styles, rangedStyle{ + Start: sb.Len(), + Style: style, + }) } -func IdentColor(s string) (code int) { - h := fnv.New32() - _, _ = h.Write([]byte(s)) - - code = int(h.Sum32()) % (99 - len(identColorBlacklist)) - for _, c := range identColorBlacklist { - if c <= code { - code++ - } +func (sb *StyledStringBuilder) StyledString() StyledString { + styles := sb.styles + if len(sb.styles) != 0 && sb.styles[len(sb.styles)-1].Start == sb.Len() { + styles = sb.styles[:len(sb.styles)-1] + } + return StyledString{ + string: sb.String(), + styles: styles, } - - return } diff --git a/ui/style_test.go b/ui/style_test.go index ae478ab..4ba2e9d 100644 --- a/ui/style_test.go +++ b/ui/style_test.go @@ -1,25 +1,96 @@ package ui -import "testing" +import ( + "testing" -func assertStringWidth(t *testing.T, input string, expected int) { - actual := StringWidth(input) - if actual != expected { - t.Errorf("%q: expected width of %d got %d", input, expected, actual) + "github.com/gdamore/tcell/v2" +) + +func assertIRCString(t *testing.T, input string, expected StyledString) { + actual := IRCString(input) + if actual.string != expected.string { + t.Errorf("%q: expected string %q, got %q", input, expected.string, actual.string) + } + if len(actual.styles) != len(expected.styles) { + t.Errorf("%q: expected %d styles, got %d", input, len(expected.styles), len(actual.styles)) + return + } + for i := range actual.styles { + if actual.styles[i] != expected.styles[i] { + t.Errorf("%q: style #%d expected to be %+v, got %+v", input, i, expected.styles[i], actual.styles[i]) + } } } -func TestStringWidth(t *testing.T) { - assertStringWidth(t, "", 0) +func TestIRCString(t *testing.T) { + assertIRCString(t, "", StyledString{ + string: "", + styles: nil, + }) - assertStringWidth(t, "hello", 5) - assertStringWidth(t, "\x02hello", 5) - assertStringWidth(t, "\x035hello", 5) - assertStringWidth(t, "\x0305hello", 5) - assertStringWidth(t, "\x0305,0hello", 5) - assertStringWidth(t, "\x0305,09hello", 5) + assertIRCString(t, "hello", StyledString{ + string: "hello", + styles: nil, + }) + assertIRCString(t, "\x02hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Bold(true)}, + }, + }) + assertIRCString(t, "\x035hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)}, + }, + }) + assertIRCString(t, "\x0305hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)}, + }, + }) + assertIRCString(t, "\x0305,0hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)}, + }, + }) + assertIRCString(t, "\x035,00hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)}, + }, + }) + assertIRCString(t, "\x0305,00hello", StyledString{ + string: "hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)}, + }, + }) - assertStringWidth(t, "\x0305,hello", 6) - assertStringWidth(t, "\x03050hello", 6) - assertStringWidth(t, "\x0305,090hello", 6) + assertIRCString(t, "\x035,hello", StyledString{ + string: ",hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)}, + }, + }) + assertIRCString(t, "\x0305,hello", StyledString{ + string: ",hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)}, + }, + }) + assertIRCString(t, "\x03050hello", StyledString{ + string: "0hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)}, + }, + }) + assertIRCString(t, "\x0305,000hello", StyledString{ + string: "0hello", + styles: []rangedStyle{ + rangedStyle{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)}, + }, + }) } @@ -12,7 +12,7 @@ type Config struct { NickColWidth int ChanColWidth int AutoComplete func(cursorIdx int, text []rune) []Completion - Mouse bool + Mouse bool } type UI struct { @@ -23,7 +23,7 @@ type UI struct { bs BufferList e Editor - prompt string + prompt StyledString status string } @@ -159,7 +159,7 @@ func (ui *UI) SetStatus(status string) { ui.status = status } -func (ui *UI) SetPrompt(prompt string) { +func (ui *UI) SetPrompt(prompt StyledString) { ui.prompt = prompt } @@ -245,8 +245,7 @@ func (ui *UI) Draw() { for x := ui.config.ChanColWidth; x < 9+ui.config.ChanColWidth+ui.config.NickColWidth; x++ { ui.screen.SetContent(x, h-1, ' ', nil, tcell.StyleDefault) } - st := tcell.StyleDefault.Foreground(colorFromCode(IdentColor(ui.prompt))) - printIdent(ui.screen, ui.config.ChanColWidth+7, h-1, ui.config.NickColWidth, st, ui.prompt) + printIdent(ui.screen, ui.config.ChanColWidth+7, h-1, ui.config.NickColWidth, ui.prompt) ui.screen.Show() } @@ -262,8 +261,16 @@ func (ui *UI) drawStatusBar(x0, y, width int) { return } + s := new(StyledStringBuilder) + s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) + s.WriteString("--") + x := x0 + 5 + ui.config.NickColWidth - printString(ui.screen, &x, y, st, "--") + printString(ui.screen, &x, y, s.StyledString()) x += 2 - printString(ui.screen, &x, y, st, ui.status) + + s.Reset() + s.WriteString(ui.status) + + printString(ui.screen, &x, y, s.StyledString()) } @@ -1,30 +1,23 @@ package senpai import ( - "math/rand" + "hash/fnv" "strings" "time" "git.sr.ht/~taiite/senpai/ui" + "github.com/gdamore/tcell/v2" ) var Home = "home" -var homeMessages = []string{ - "\x1dYou open an IRC client.", - "Welcome to the Internet Relay Network!", - "DMs & cie go here.", - "May the IRC be with you.", - "Hey! I'm senpai, you everyday IRC student!", - "Student? No, I'm an IRC \x02client\x02!", -} +const welcomeMessage = "senpai dev build. See senpai(1) for a list of keybindings and commands. Private messages and status notices go here." func (app *App) initWindow() { - hmIdx := rand.Intn(len(homeMessages)) app.win.AddBuffer(Home) app.win.AddLine(Home, false, ui.Line{ Head: "--", - Body: homeMessages[hmIdx], + Body: ui.PlainString(welcomeMessage), At: time.Now(), }) } @@ -67,3 +60,15 @@ func (app *App) setStatus() { } app.win.SetStatus(status) } + +func identColor(ident string) tcell.Color { + h := fnv.New32() + _, _ = h.Write([]byte(ident)) + return tcell.Color((h.Sum32()%15)+1) + tcell.ColorValid +} + +func identString(ident string) ui.StyledString { + color := identColor(ident) + style := tcell.StyleDefault.Foreground(color) + return ui.Styled(ident, style) +} |