diff options
author | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-03 23:04:51 +0200 |
---|---|---|
committer | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-04 11:12:32 +0200 |
commit | 3c8dceaf96964eae12a06abb04035cf184bdbf61 (patch) | |
tree | e3de1c86b31a488ccf687a27d57d7d7e3e463f6e | |
parent | Improve line editing (diff) |
Rework display
- Put timestamps, nicks and messages into separate columns
- Print a bar in the "typing" row
- Fix word wrapping
- Improve channel list display
Diffstat (limited to '')
-rw-r--r-- | cmd/irc/main.go | 192 | ||||
-rw-r--r-- | ui/buffers.go | 659 | ||||
-rw-r--r-- | ui/buffers_test.go | 204 | ||||
-rw-r--r-- | ui/style.go | 191 | ||||
-rw-r--r-- | ui/style_test.go (renamed from ui/width_test.go) | 0 | ||||
-rw-r--r-- | ui/ui.go | 510 | ||||
-rw-r--r-- | ui/width.go | 75 |
7 files changed, 900 insertions, 931 deletions
diff --git a/cmd/irc/main.go b/cmd/irc/main.go index 151d99d..e2cd5f3 100644 --- a/cmd/irc/main.go +++ b/cmd/irc/main.go @@ -3,16 +3,16 @@ package main import ( "crypto/tls" "fmt" - "git.sr.ht/~taiite/senpai" - "git.sr.ht/~taiite/senpai/irc" - "git.sr.ht/~taiite/senpai/ui" - "github.com/gdamore/tcell" - "hash/fnv" "log" "math/rand" "os" "strings" "time" + + "git.sr.ht/~taiite/senpai" + "git.sr.ht/~taiite/senpai/irc" + "git.sr.ht/~taiite/senpai/ui" + "github.com/gdamore/tcell" ) func init() { @@ -39,7 +39,7 @@ func main() { defer app.Close() addr := cfg.Addr - app.AddLine("home", fmt.Sprintf("Connecting to %s...", addr), time.Now(), false) + app.AddLine(ui.Home, ui.NewLineNow("--", fmt.Sprintf("Connecting to %s...", addr))) conn, err := tls.Dial("tcp", addr, nil) if err != nil { @@ -60,48 +60,48 @@ func main() { for !app.ShouldExit() { select { case ev := <-s.Poll(): - handleIRCEvent(app, ev) + handleIRCEvent(app, &s, ev) case ev := <-app.Events: handleUIEvent(app, &s, ev) } } } -func handleIRCEvent(app *ui.UI, ev irc.Event) { +func handleIRCEvent(app *ui.UI, s *irc.Session, ev irc.Event) { switch ev := ev.(type) { case irc.RegisteredEvent: - app.AddLine("home", "Connected to the server", time.Now(), false) + app.AddLine("", ui.NewLineNow("--", "Connected to the server")) case irc.SelfJoinEvent: app.AddBuffer(ev.Channel) case irc.UserJoinEvent: line := fmt.Sprintf("\x033+\x0314%s", ev.Nick) - app.AddLine(ev.Channel, line, ev.Time, true) + app.AddLine(ev.Channel, ui.NewLine(ev.Time, "", line, true)) case irc.SelfPartEvent: app.RemoveBuffer(ev.Channel) case irc.UserPartEvent: line := fmt.Sprintf("\x034-\x0314%s", ev.Nick) - app.AddLine(ev.Channel, line, ev.Time, true) + app.AddLine(ev.Channel, ui.NewLine(ev.Time, "", line, true)) case irc.QueryMessageEvent: if ev.Command == "PRIVMSG" { - line := formatIRCMessage(ev.Nick, ev.Content) - app.AddLine("home", line, ev.Time, false) - app.TypingStop("home", ev.Nick) + l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, false) + app.AddLine(ui.Home, l) + app.TypingStop(ui.Home, ev.Nick) } else if ev.Command == "NOTICE" { - line := formatIRCNotice(ev.Nick, ev.Content) - app.AddLine(app.CurrentBuffer(), line, ev.Time, false) - app.TypingStop(app.CurrentBuffer(), ev.Nick) + l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, true) + app.AddLine("", l) + app.TypingStop("", ev.Nick) } else { panic("unknown command") } case irc.ChannelMessageEvent: - line := formatIRCMessage(ev.Nick, ev.Content) - app.AddLine(ev.Channel, line, ev.Time, false) + l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, ev.Command == "NOTICE") + app.AddLine(ev.Channel, l) app.TypingStop(ev.Channel, ev.Nick) case irc.QueryTypingEvent: if ev.State == 1 || ev.State == 2 { - app.TypingStart("home", ev.Nick) + app.TypingStart(ui.Home, ev.Nick) } else { - app.TypingStop("home", ev.Nick) + app.TypingStop(ui.Home, ev.Nick) } case irc.ChannelTypingEvent: if ev.State == 1 || ev.State == 2 { @@ -111,27 +111,16 @@ func handleIRCEvent(app *ui.UI, ev irc.Event) { } case irc.HistoryEvent: var lines []ui.Line - var lastT time.Time - isChannel := ev.Target[0] == '#' for _, m := range ev.Messages { switch m := m.(type) { case irc.ChannelMessageEvent: - if isChannel { - line := formatIRCMessage(m.Nick, m.Content) - line = strings.TrimRight(line, "\t ") - if lastT.Truncate(time.Minute) != m.Time.Truncate(time.Minute) { - lastT = m.Time - hour := lastT.Hour() - minute := lastT.Minute() - line = fmt.Sprintf("\x02%02d:%02d\x00 %s", hour, minute, line) - } - lines = append(lines, ui.NewLine(m.Time, false, line)) - } else { - panic("TODO") - } + l := ui.LineFromIRCMessage(m.Time, m.Nick, m.Content, m.Command == "NOTICE") + lines = append(lines, l) + default: + panic("TODO") } } - app.AddHistoryLines(ev.Target, lines) + app.AddLines(ev.Target, lines) case error: log.Panicln(ev) } @@ -151,39 +140,58 @@ func handleUIEvent(app *ui.UI, s *irc.Session, ev tcell.Event) { app.ScrollUp() if app.IsAtTop() { buffer := app.CurrentBuffer() - t := app.CurrentBufferOldestTime() - s.RequestHistory(buffer, t) + at := time.Now() + if t := app.CurrentBufferOldestTime(); t != nil { + at = *t + } + s.RequestHistory(buffer, at) } case tcell.KeyCtrlD, tcell.KeyPgDn: app.ScrollDown() case tcell.KeyCtrlN: - if app.NextBuffer() && app.IsAtTop() { + app.NextBuffer() + if app.IsAtTop() { buffer := app.CurrentBuffer() - t := app.CurrentBufferOldestTime() - s.RequestHistory(buffer, t) + at := time.Now() + if t := app.CurrentBufferOldestTime(); t != nil { + at = *t + } + s.RequestHistory(buffer, at) } case tcell.KeyCtrlP: - if app.PreviousBuffer() && app.IsAtTop() { + app.PreviousBuffer() + if app.IsAtTop() { buffer := app.CurrentBuffer() - t := app.CurrentBufferOldestTime() - s.RequestHistory(buffer, t) + at := time.Now() + if t := app.CurrentBufferOldestTime(); t != nil { + at = *t + } + s.RequestHistory(buffer, at) } case tcell.KeyRight: if ev.Modifiers() == tcell.ModAlt { - if app.NextBuffer() && app.IsAtTop() { + app.NextBuffer() + if app.IsAtTop() { buffer := app.CurrentBuffer() - t := app.CurrentBufferOldestTime() - s.RequestHistory(buffer, t) + at := time.Now() + if t := app.CurrentBufferOldestTime(); t != nil { + at = *t + } + s.RequestHistory(buffer, at) } } else { app.InputRight() } case tcell.KeyLeft: if ev.Modifiers() == tcell.ModAlt { - if app.PreviousBuffer() && app.IsAtTop() { + app.PreviousBuffer() + if app.IsAtTop() { buffer := app.CurrentBuffer() - t := app.CurrentBufferOldestTime() - s.RequestHistory(buffer, t) + at := time.Now() + if t := app.CurrentBufferOldestTime(); t != nil { + at = *t + } + s.RequestHistory(buffer, at) } } else { app.InputLeft() @@ -199,7 +207,7 @@ func handleUIEvent(app *ui.UI, s *irc.Session, ev tcell.Event) { handleInput(app, s, buffer, input) case tcell.KeyRune: app.InputRune(ev.Rune()) - if app.CurrentBuffer() != "home" && !app.InputIsCommand() { + if app.CurrentBuffer() != ui.Home && !app.InputIsCommand() { s.Typing(app.CurrentBuffer()) } } @@ -232,21 +240,20 @@ func handleInput(app *ui.UI, s *irc.Session, buffer, content string) { switch cmd { case "": - if buffer == "home" { + if buffer == ui.Home || len(strings.TrimSpace(args)) == 0 { return } s.PrivMsg(buffer, args) if !s.HasCapability("echo-message") { - line := formatIRCMessage(s.Nick(), args) - app.AddLine(buffer, line, time.Now(), false) + app.AddLine(buffer, ui.NewLineNow(s.Nick(), args)) } case "QUOTE": s.SendRaw(args) case "J", "JOIN": s.Join(args) case "PART": - if buffer == "home" { + if buffer == ui.Home { return } @@ -256,12 +263,13 @@ func handleInput(app *ui.UI, s *irc.Session, buffer, content string) { s.Part(args) case "ME": - if buffer == "home" { + if buffer == ui.Home { return } line := fmt.Sprintf("\x01ACTION %s\x01", args) s.PrivMsg(buffer, line) + // TODO echo message case "MSG": split := strings.SplitN(args, " ", 2) if len(split) < 2 { @@ -271,74 +279,6 @@ func handleInput(app *ui.UI, s *irc.Session, buffer, content string) { target := split[0] content := split[1] s.PrivMsg(target, content) + // TODO echo mssage } } - -func formatIRCMessage(nick, content string) (line string) { - c := color(nick) - - if content == "" { - line = fmt.Sprintf("%s%s\x00:", c, nick) - return - } - - if content[0] == 1 { - content = strings.TrimSuffix(content[1:], "\x01") - - if strings.HasPrefix(content, "ACTION") { - line = fmt.Sprintf("%s%s\x00%s", c, nick, content[6:]) - } else { - line = fmt.Sprintf("\x1dCTCP request from\x1d %s%s\x00: %s", c, nick, content) - } - - return - } - - line = fmt.Sprintf("%s%s\x00: %s", c, nick, content) - - return -} - -func formatIRCNotice(nick, content string) (line string) { - c := color(nick) - - if content == "" { - line = fmt.Sprintf("(%s%s\x00: )", c, nick) - return - } - - if content[0] == 1 { - content = strings.TrimSuffix(content[1:], "\x01") - - if strings.HasPrefix(content, "ACTION") { - line = fmt.Sprintf("(%s%s\x00%s)", c, nick, content[6:]) - } else { - line = fmt.Sprintf("(\x1dCTCP request from\x1d %s%s\x00: %s)", c, nick, content) - } - } else { - line = fmt.Sprintf("(%s%s\x00: %s)", c, nick, content) - } - - return -} - -func color(nick string) string { - h := fnv.New32() - _, _ = h.Write([]byte(nick)) - - sum := h.Sum32() % 96 - - if 1 <= sum { - sum++ - } - if 8 <= sum { - sum++ - } - - var c [3]rune - c[0] = '\x03' - c[1] = rune(sum/10) + '0' - c[2] = rune(sum%10) + '0' - - return string(c[:]) -} diff --git a/ui/buffers.go b/ui/buffers.go index afd23aa..a61766c 100644 --- a/ui/buffers.go +++ b/ui/buffers.go @@ -2,10 +2,17 @@ package ui import ( "fmt" + "hash/fnv" + "math" "strings" "time" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" ) +var Home = "home" + var homeMessages = []string{ "\x1dYou open an IRC client.", "Welcome to the Internet Relay Network!", @@ -15,294 +22,578 @@ var homeMessages = []string{ "Student? No, I'm an IRC \x02client\x02!", } -func IsSplitRune(c rune) bool { - return c == ' ' || c == '\t' +func IsSplitRune(r rune) bool { + return r == ' ' || r == '\t' } -type Point struct { - X int - I int - +type point struct { + X, I int Split bool } type Line struct { - Time time.Time - IsStatus bool - Content string - - SplitPoints []Point - renderedHeight int + at time.Time + head string + body string + isStatus bool + + splitPoints []point + width int + newLines []int } -func NewLine(t time.Time, isStatus bool, content string) (line Line) { - line.Time = t - line.IsStatus = isStatus - line.Content = content - - line.Invalidate() - line.computeSplitPoints() - - return +func NewLine(at time.Time, head string, body string, isStatus bool) Line { + l := Line{ + at: at, + head: head, + body: body, + isStatus: isStatus, + splitPoints: []point{}, + newLines: []int{}, + } + l.computeSplitPoints() + return l } -func NewLineNow(content string) (line Line) { - line = NewLine(time.Now(), false, content) - return +func NewLineNow(head, body string) Line { + return NewLine(time.Now(), head, body, false) } -func (line *Line) Invalidate() { - line.renderedHeight = 0 +func LineFromIRCMessage(at time.Time, nick string, content string, isNotice bool) Line { + if strings.HasPrefix(content, "\x01ACTION") { + c := ircColorCode(identColor(nick)) + content = fmt.Sprintf("%s%s\x0F%s", c, nick, content[7:]) + nick = "*" + } else if isNotice { + c := ircColorCode(identColor(nick)) + content = fmt.Sprintf("(%s%s\x0F: %s)", c, nick, content) + nick = "*" + } + return NewLine(at, nick, content, false) } -func (line *Line) RenderedHeight(screenWidth int) (height int) { - if line.renderedHeight <= 0 { - line.computeRenderedHeight(screenWidth) +func (l *Line) computeSplitPoints() { + var wb widthBuffer + lastWasSplit := false + l.splitPoints = l.splitPoints[:0] + + for i, r := range l.body { + curIsSplit := IsSplitRune(r) + + if i == 0 || lastWasSplit != curIsSplit { + l.splitPoints = append(l.splitPoints, point{ + X: wb.Width(), + I: i, + Split: curIsSplit, + }) + } + + lastWasSplit = curIsSplit + wb.WriteRune(r) + } + + if !lastWasSplit { + l.splitPoints = append(l.splitPoints, point{ + X: wb.Width(), + I: len(l.body), + Split: true, + }) } - height = line.renderedHeight - return } -// TODO clean and understand the fucking function -func (line *Line) computeRenderedHeight(screenWidth int) { - var lastSP Point - line.renderedHeight = 1 - x := 0 +func (l *Line) NewLines(width int) []int { + // Beware! This function was made by your local Test Driven Developper™ who + // doesn't understand one bit of this function and how it works (though it + // might not work that well if you're here...). The code below is thus very + // cryptic and not well structured. However, I'm going to try to explain + // some of those lines! - //fmt.Printf("\n%d %q\n", screenWidth, line.Content) - for _, sp := range line.SplitPoints { - l := sp.X - lastSP.X + if l.width == width { + return l.newLines + } + l.newLines = l.newLines[:0] + l.width = width - if !sp.Split && x == 0 { - // Don't add space at the beginning of a row - } else if screenWidth < l { - line.renderedHeight += (x + l) / screenWidth - x = (x + l) % screenWidth - } else if screenWidth == l { + x := 0 + for i := 1; i < len(l.splitPoints); i++ { + // Iterate through the split points 2 by 2. Split points are placed at + // the begining of whitespace (see IsSplitRune) and at the begining of + // non-whitespace. Iterating on 2 points each time, sp1 and sp2, allow + // consideration of a "word" of (non-)whitespace. + // Split points have the index I in the string and the width X of the + // screen. Finally, the Split field is set to true if the split point + // is at the begining of a whitespace. + + // Below, "row" means a line in the terminal, while "line" means (l *Line). + + sp1 := l.splitPoints[i-1] + sp2 := l.splitPoints[i] + + if 0 < len(l.newLines) && x == 0 && sp1.Split { + // Except for the first row, let's skip the whitespace at the start + // of the row. + } else if !sp1.Split && sp2.X-sp1.X == width { + // Some word occupies the width of the terminal, lets place a + // newline at the PREVIOUS split point (i-2, which is whitespace) + // ONLY if there isn't already one. + if 1 < i && l.newLines[len(l.newLines)-1] != l.splitPoints[i-2].I { + l.newLines = append(l.newLines, l.splitPoints[i-2].I) + } + // and also place a newline after the word. + x = 0 + l.newLines = append(l.newLines, sp2.I) + } else if sp2.X-sp1.X+x < width { + // It fits. Advance the X coordinate with the width of the word. + x += sp2.X - sp1.X + } else if sp2.X-sp1.X+x == width { + // It fits, but there is no more space in the row. + x = 0 + l.newLines = append(l.newLines, sp2.I) + } else if width < sp2.X-sp1.X { + // It doesn't fit at all. The word is longer than the width of the + // 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 + h := 1 + for j, r := range l.body[sp1.I:sp2.I] { + wb.WriteRune(r) + if h*width < x+wb.Width() { + l.newLines = append(l.newLines, sp1.I+j) + h++ + } + } + x = (x + wb.Width()) % width if x == 0 { - line.renderedHeight++ - } else { - line.renderedHeight += 2 - x = 0 + // The placement of the word is such that it ends right at the + // end of the row. + l.newLines = append(l.newLines, sp2.I) } - } else if screenWidth < x+l { - line.renderedHeight++ - if sp.Split { - x = l % screenWidth - } else { + } else { + // So... IIUC this branch would be the same as + // else if width < sp2.X-sp1.X+x + // IE. It doesn't fit, but the word can still be placed on the next + // row. + l.newLines = append(l.newLines, sp1.I) + if sp1.Split { x = 0 + } else { + x = sp2.X - sp1.X } - } else if screenWidth == x+l { - line.renderedHeight++ - x = 0 - } else { - x = x + l } - - //fmt.Printf("%d %d %t occupied by %q\n", line.renderedHeight, x, sp.Split, line.Content[:sp.I]) - lastSP = sp } - if x == 0 && 1 < line.renderedHeight { - line.renderedHeight-- + if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.body) { + // 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] } + + return l.newLines } -func (line *Line) computeSplitPoints() { - var wb widthBuffer - lastWasSplit := false +type buffer struct { + title string + highlights int + unread bool - for i, r := range line.Content { - curIsSplit := IsSplitRune(r) + lines []Line + typings []string - if lastWasSplit != curIsSplit { - line.SplitPoints = append(line.SplitPoints, Point{ - X: wb.Width(), - I: i, - Split: curIsSplit, - }) + scrollAmt int + isAtTop bool +} + +func (b *buffer) DrawLines(screen tcell.Screen, width int, height int) { + st := tcell.StyleDefault + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + screen.SetContent(x, y, ' ', nil, st) } + } - lastWasSplit = curIsSplit - wb.WriteRune(r) + nickColWidth := 16 + + var sb styleBuffer + sb.Reset() + y0 := b.scrollAmt + height + for i := len(b.lines) - 1; 0 <= i; i-- { + if y0 < 0 { + break + } + + x0 := 5 + 1 + nickColWidth + 2 + + line := &b.lines[i] + nls := line.NewLines(width - x0) + y0 -= len(nls) + 1 + if height <= y0 { + continue + } + + if i == 0 || b.lines[i-1].at.Truncate(time.Minute) != line.at.Truncate(time.Minute) { + printTime(screen, 0, y0, st.Bold(true), line.at) + } + + head := truncate(line.head, nickColWidth) + x := 6 + nickColWidth - StringWidth(head) + c := identColor(line.head) + printString(screen, &x, y0, st.Foreground(colorFromCode(c)), head) + + x = x0 + y := y0 + + for i, r := range line.body { + if 0 < len(nls) && i == nls[0] { + x = x0 + y++ + nls = nls[1:] + if height < y { + break + } + } + + if y != y0 && x == x0 && IsSplitRune(r) { + 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.RuneWidth(r) + } + } + + sb.Reset() } - if !lastWasSplit { - line.SplitPoints = append(line.SplitPoints, Point{ - X: wb.Width(), - I: len(line.Content), - Split: true, - }) + b.isAtTop = 0 <= y0 +} + +type bufferList struct { + list []buffer + current int + + width int + height int +} + +func newBufferList(width, height int) bufferList { + return bufferList{ + list: []buffer{}, + width: width, + height: height, } } -type Buffer struct { - Title string - Highlights int - Content []Line - Typings []string +func (bs *bufferList) Resize(width, height int) { + bs.width = width + bs.height = height } -type BufferList struct { - List []Buffer - Current int +func (bs *bufferList) Next() { + bs.current = (bs.current + 1) % len(bs.list) } -func (bs *BufferList) Add(title string) (pos int, ok bool) { - for i, b := range bs.List { - if b.Title == title { - pos = i +func (bs *bufferList) Previous() { + bs.current = (bs.current - 1 + len(bs.list)) % len(bs.list) +} + +func (bs *bufferList) Add(title string) (ok bool) { + lTitle := strings.ToLower(title) + for _, b := range bs.list { + if strings.ToLower(b.title) == lTitle { return } } - pos = len(bs.List) ok = true - bs.List = append(bs.List, Buffer{Title: title}) - + bs.list = append(bs.list, buffer{title: title}) return } -func (bs *BufferList) Remove(title string) (ok bool) { - for i, b := range bs.List { - if b.Title == title { +func (bs *bufferList) Remove(title string) (ok bool) { + lTitle := strings.ToLower(title) + for i, b := range bs.list { + if strings.ToLower(b.title) == lTitle { ok = true - bs.List = append(bs.List[:i], bs.List[i+1:]...) - - if i == bs.Current { - bs.Current = 0 + bs.list = append(bs.list[:i], bs.list[i+1:]...) + if len(bs.list) <= bs.current { + bs.current-- } - return } } - return } -func (bs *BufferList) Previous() (ok bool) { - if bs.Current <= 0 { - ok = false +func (bs *bufferList) AddLine(title string, line Line) { + idx := bs.idx(title) + if idx < 0 { + return + } + + b := &bs.list[idx] + n := len(b.lines) + line.body = strings.TrimRight(line.body, "\t ") + + if line.isStatus && n != 0 && b.lines[n-1].isStatus { + l := &b.lines[n-1] + l.body += " " + line.body + l.computeSplitPoints() + l.width = 0 } else { - bs.Current-- - ok = true + b.lines = append(b.lines, line) + if idx == bs.current && 0 < b.scrollAmt { + b.scrollAmt++ + } } +} - return +func (bs *bufferList) AddLines(title string, lines []Line) { + idx := bs.idx(title) + if idx < 0 { + return + } + + b := &bs.list[idx] + limit := len(lines) + + if 0 < len(b.lines) { + firstLineTime := b.lines[0].at.Round(time.Millisecond) + for i := len(lines) - 1; 0 <= i; i-- { + if firstLineTime == lines[i].at.Round(time.Millisecond) { + limit = i + break + } + } + } + + b.lines = append(lines[:limit], b.lines...) } -func (bs *BufferList) Next() (ok bool) { - if bs.Current+1 < len(bs.List) { - bs.Current++ - ok = true - } else { - ok = false +func (bs *bufferList) TypingStart(title, nick string) { + idx := bs.idx(title) + if idx < 0 { + return } + b := &bs.list[idx] - return + lNick := strings.ToLower(nick) + for _, n := range b.typings { + if strings.ToLower(n) == lNick { + return + } + } + b.typings = append(b.typings, nick) } -func (bs *BufferList) Idx(title string) (idx int) { - if title == "" { - idx = 0 +func (bs *bufferList) TypingStop(title, nick string) { + idx := bs.idx(title) + if idx < 0 { return } + b := &bs.list[idx] - for pos, b := range bs.List { - if b.Title == title { - idx = pos + lNick := strings.ToLower(nick) + for i, n := range b.typings { + if strings.ToLower(n) == lNick { + b.typings = append(b.typings[:i], b.typings[i+1:]...) return } } +} + +func (bs *bufferList) Current() (title string) { + return bs.list[bs.current].title +} - idx = -1 +func (bs *bufferList) CurrentOldestTime() (t *time.Time) { + ls := bs.list[bs.current].lines + if 0 < len(ls) { + t = &ls[0].at + } return } -func (bs *BufferList) AddLine(idx int, line string, t time.Time, isStatus bool) { - b := &bs.List[idx] - n := len(bs.List[idx].Content) +func (bs *bufferList) ScrollUp() { + b := &bs.list[bs.current] + if b.isAtTop { + return + } + b.scrollAmt += bs.height / 2 +} + +func (bs *bufferList) ScrollDown() { + b := &bs.list[bs.current] + b.scrollAmt -= bs.height / 2 - line = strings.TrimRight(line, "\t ") + if b.scrollAmt < 0 { + b.scrollAmt = 0 + } +} - if isStatus && n != 0 && b.Content[n-1].IsStatus { - l := &b.Content[n-1] - l.Content += " " + line +func (bs *bufferList) IsAtTop() bool { + b := &bs.list[bs.current] + return b.isAtTop +} + +func (bs *bufferList) idx(title string) int { + if title == "" { + return bs.current + } - lineWidth := StringWidth(line) - lastSP := l.SplitPoints[len(l.SplitPoints)-1] - sp := Point{ - X: lastSP.X + 1 + lineWidth, - I: len(l.SplitPoints), + lTitle := strings.ToLower(title) + for i, b := range bs.list { + if strings.ToLower(b.title) == lTitle { + return i } + } + return -1 +} - l.SplitPoints = append(l.SplitPoints, sp) - l.Invalidate() - } else { - if n == 0 || b.Content[n-1].Time.Truncate(time.Minute) != t.Truncate(time.Minute) { - hour := t.Hour() - minute := t.Minute() +func (bs *bufferList) Draw(screen tcell.Screen) { + bs.list[bs.current].DrawLines(screen, bs.width, bs.height-3) + bs.drawStatusBar(screen, bs.height-3) + bs.drawTitleList(screen, bs.height-1) +} - line = fmt.Sprintf("\x02%02d:%02d\x00 %s", hour, minute, line) +func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) { + st := tcell.StyleDefault.Dim(true) + nicks := bs.list[bs.current].typings + verb := " is typing..." + x := 0 + + if 1 < len(nicks) { + verb = " are typing..." + for _, nick := range nicks[:len(nicks)-2] { + printString(screen, &x, y, st, nick) + printString(screen, &x, y, st, ", ") } + printString(screen, &x, y, st, nicks[len(nicks)-2]) + printString(screen, &x, y, st, " and ") + } + if 0 < len(nicks) { + printString(screen, &x, y, st, nicks[len(nicks)-1]) + printString(screen, &x, y, st, verb) + } - l := NewLine(t, isStatus, line) - b.Content = append(b.Content, l) + if 0 < x { + screen.SetContent(x, y, 0x251c, nil, st) + x++ + } + for x < bs.width { + screen.SetContent(x, y, 0x2500, nil, st) + x++ } } -func (bs *BufferList) AddHistoryLines(idx int, lines []Line) { - if len(lines) == 0 { - return +func (bs *bufferList) drawTitleList(screen tcell.Screen, y int) { + var widths []int + for _, b := range bs.list { + width := StringWidth(b.title) + if 0 < b.highlights { + width += int(math.Log10(float64(b.highlights))) + 1 + } + widths = append(widths, width) } - b := &bs.List[idx] - limit := -1 + st := tcell.StyleDefault - if len(b.Content) != 0 { - firstTime := b.Content[0].Time.Round(time.Millisecond) - for i := len(lines) - 1; i >= 0; i-- { - if firstTime == lines[i].Time.Round(time.Millisecond) { - limit = i - break - } - } + for x := 0; x < bs.width; x++ { + screen.SetContent(x, y, ' ', nil, st) } - if limit == -1 { - limit = len(lines) + x := (bs.width - widths[bs.current]) / 2 + printString(screen, &x, y, st.Underline(true), bs.list[bs.current].title) + x += 2 + + i := (bs.current + 1) % len(bs.list) + for x < bs.width && i != bs.current { + b := &bs.list[i] + st = tcell.StyleDefault + if b.unread { + st = st.Bold(true) + } + printString(screen, &x, y, st, b.title) + if 0 < b.highlights { + st = st.Foreground(tcell.ColorRed).Reverse(true) + printNumber(screen, &x, y, st, b.highlights) + } + x += 2 + i = (i + 1) % len(bs.list) } - bs.List[idx].Content = append(lines[:limit], b.Content...) + i = (bs.current - 1 + len(bs.list)) % len(bs.list) + x = (bs.width - widths[bs.current]) / 2 + for 0 < x && i != bs.current { + x -= widths[i] + 2 + b := &bs.list[i] + st = tcell.StyleDefault + if b.unread { + st = st.Bold(true) + } + printString(screen, &x, y, st, b.title) + if 0 < b.highlights { + st = st.Foreground(tcell.ColorRed).Reverse(true) + printNumber(screen, &x, y, st, b.highlights) + } + x -= widths[i] + i = (i - 1 + len(bs.list)) % len(bs.list) + } } -func (bs *BufferList) Invalidate() { - for i := range bs.List { - for j := range bs.List[i].Content { - bs.List[i].Content[j].Invalidate() - } +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.RuneWidth(r) } } -func (bs *BufferList) TypingStart(idx int, nick string) { - b := &bs.List[idx] +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) +} - for _, n := range b.Typings { - if n == nick { - return - } - } +func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) { + hr0 := rune(t.Hour()/10) + '0' + hr1 := rune(t.Hour()%10) + '0' + mn0 := rune(t.Minute()/10) + '0' + mn1 := rune(t.Minute()%10) + '0' + screen.SetContent(x+0, y, hr0, nil, st) + screen.SetContent(x+1, y, hr1, nil, st) + screen.SetContent(x+2, y, ':', nil, st) + screen.SetContent(x+3, y, mn0, nil, st) + screen.SetContent(x+4, y, mn1, nil, st) +} - b.Typings = append(b.Typings, nick) +func truncate(s string, w int) string { + c := runewidth.Condition{ZeroWidthJoiner: true} + return c.Truncate(s, w, "\u2026") } -func (bs *BufferList) TypingStop(idx int, nick string) { - b := &bs.List[idx] +func identColor(s string) (code int) { + h := fnv.New32() + _, _ = h.Write([]byte(s)) - for i, n := range b.Typings { - if n == nick { - b.Typings = append(b.Typings[:i], b.Typings[i+1:]...) - return - } + code = int(h.Sum32()) % 96 + if 1 <= code { + code++ } + if 8 <= code { + code++ + } + + return +} + +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 80ac8ec..dc1cf02 100644 --- a/ui/buffers_test.go +++ b/ui/buffers_test.go @@ -1,42 +1,48 @@ package ui -import "testing" +import ( + "strings" + "testing" +) -func assertSplitPoints(t *testing.T, line string, expected []Point) { - l := Line{Content: line} +func assertSplitPoints(t *testing.T, body string, expected []point) { + l := Line{body: body} l.computeSplitPoints() - if len(l.SplitPoints) != len(expected) { - t.Errorf("%q: expected %d split points got %d", line, len(expected), len(l.SplitPoints)) + if len(l.splitPoints) != len(expected) { + t.Errorf("%q: expected %d split points got %d", body, len(expected), len(l.splitPoints)) return } for i := 0; i < len(expected); i++ { e := expected[i] - a := l.SplitPoints[i] + a := l.splitPoints[i] if e.X != a.X { - t.Errorf("%q, point #%d: expected X=%d got %d", line, i, e.X, a.X) + t.Errorf("%q, point #%d: expected X=%d got %d", body, i, e.X, a.X) } if e.I != a.I { - t.Errorf("%q, point #%d: expected I=%d got %d", line, i, e.I, a.I) + t.Errorf("%q, point #%d: expected I=%d got %d", body, i, e.I, a.I) } if e.Split != a.Split { - t.Errorf("%q, point #%d: expected Split=%t got %t", line, i, e.Split, a.Split) + t.Errorf("%q, point #%d: expected Split=%t got %t", body, i, e.Split, a.Split) } } } func TestLineSplitPoints(t *testing.T) { - assertSplitPoints(t, "hello", []Point{ + assertSplitPoints(t, "hello", []point{ + {X: 0, I: 0, Split: false}, {X: 5, I: 5, Split: true}, }) - assertSplitPoints(t, "hello world", []Point{ + assertSplitPoints(t, "hello world", []point{ + {X: 0, I: 0, Split: false}, {X: 5, I: 5, Split: true}, {X: 6, I: 6, Split: false}, {X: 11, I: 11, Split: true}, }) - assertSplitPoints(t, "lorem ipsum dolor shit amet", []Point{ + assertSplitPoints(t, "lorem ipsum dolor shit amet", []point{ + {X: 0, I: 0, Split: false}, {X: 5, I: 5, Split: true}, {X: 6, I: 6, Split: false}, {X: 11, I: 11, Split: true}, @@ -49,90 +55,122 @@ func TestLineSplitPoints(t *testing.T) { }) } -func assertRenderedHeight(t *testing.T, line string, width int, expected int) { - l := Line{Content: line} +func showSplit(s string, nls []int) string { + var sb strings.Builder + sb.Grow(len(s) + len(nls)) + + for i, r := range s { + if 0 < len(nls) && i == nls[0] { + sb.WriteRune('|') + nls = nls[1:] + } + sb.WriteRune(r) + } + + return sb.String() +} + +func assertNewLines(t *testing.T, body string, width int, expected int) { + l := Line{body: body} l.computeSplitPoints() - l.Invalidate() - actual := l.RenderedHeight(width) + actual := l.NewLines(width) - if actual != expected { - t.Errorf("%q with width=%d expected to take %d lines, takes %d", line, width, expected, actual) + if len(actual)+1 != expected { + s := showSplit(body, actual) + t.Errorf("%q with width=%d expected to take %d lines, takes %d: '%s' (%v)", body, width, expected, len(actual)+1, s, actual) + return } } func TestRenderedHeight(t *testing.T) { - assertRenderedHeight(t, "0123456789", 1, 10) - assertRenderedHeight(t, "0123456789", 2, 5) - assertRenderedHeight(t, "0123456789", 3, 4) - assertRenderedHeight(t, "0123456789", 4, 3) - assertRenderedHeight(t, "0123456789", 5, 2) - assertRenderedHeight(t, "0123456789", 6, 2) - assertRenderedHeight(t, "0123456789", 7, 2) - assertRenderedHeight(t, "0123456789", 8, 2) - assertRenderedHeight(t, "0123456789", 9, 2) - assertRenderedHeight(t, "0123456789", 10, 1) - assertRenderedHeight(t, "0123456789", 11, 1) + assertNewLines(t, "0123456789", 1, 10) + assertNewLines(t, "0123456789", 2, 5) + assertNewLines(t, "0123456789", 3, 4) + assertNewLines(t, "0123456789", 4, 3) + assertNewLines(t, "0123456789", 5, 2) + assertNewLines(t, "0123456789", 6, 2) + assertNewLines(t, "0123456789", 7, 2) + assertNewLines(t, "0123456789", 8, 2) + assertNewLines(t, "0123456789", 9, 2) + assertNewLines(t, "0123456789", 10, 1) + assertNewLines(t, "0123456789", 11, 1) // LEN=9, WIDTH=9 - assertRenderedHeight(t, "take care", 1, 8) // |t|a|k|e|c|a|r|e| - assertRenderedHeight(t, "take care", 2, 4) // |ta|ke|ca|re| - assertRenderedHeight(t, "take care", 3, 3) // |tak|e c|are| - assertRenderedHeight(t, "take care", 4, 2) // |take|care| - assertRenderedHeight(t, "take care", 5, 2) // |take |care | - assertRenderedHeight(t, "take care", 6, 2) // |take |care | - assertRenderedHeight(t, "take care", 7, 2) // |take |care | - assertRenderedHeight(t, "take care", 8, 2) // |take |care | - assertRenderedHeight(t, "take care", 9, 1) // |take care| - assertRenderedHeight(t, "take care", 10, 1) // |take care | + assertNewLines(t, "take care", 1, 8) // |t|a|k|e|c|a|r|e| + assertNewLines(t, "take care", 2, 4) // |ta|ke|ca|re| + assertNewLines(t, "take care", 3, 3) // |tak|e c|are| + assertNewLines(t, "take care", 4, 2) // |take|care| + assertNewLines(t, "take care", 5, 2) // |take |care | + assertNewLines(t, "take care", 6, 2) // |take |care | + assertNewLines(t, "take care", 7, 2) // |take |care | + assertNewLines(t, "take care", 8, 2) // |take |care | + assertNewLines(t, "take care", 9, 1) // |take care| + assertNewLines(t, "take care", 10, 1) // |take care | // LEN=10, WIDTH=10 - assertRenderedHeight(t, "take care", 1, 8) // |t|a|k|e|c|a|r|e| - assertRenderedHeight(t, "take care", 2, 4) // |ta|ke|ca|re| - assertRenderedHeight(t, "take care", 3, 4) // |tak|e |car|e | - assertRenderedHeight(t, "take care", 4, 2) // |take|care| - assertRenderedHeight(t, "take care", 5, 2) // |take |care | - assertRenderedHeight(t, "take care", 6, 2) // |take |care | - assertRenderedHeight(t, "take care", 7, 2) // |take |care | - assertRenderedHeight(t, "take care", 8, 2) // |take |care | - assertRenderedHeight(t, "take care", 9, 2) // |take |care | - assertRenderedHeight(t, "take care", 10, 1) // |take care| - assertRenderedHeight(t, "take care", 11, 1) // |take care | + assertNewLines(t, "take care", 1, 8) // |t|a|k|e|c|a|r|e| + assertNewLines(t, "take care", 2, 4) // |ta|ke|ca|re| + assertNewLines(t, "take care", 3, 4) // |tak|e |car|e | + assertNewLines(t, "take care", 4, 2) // |take|care| + assertNewLines(t, "take care", 5, 2) // |take |care | + assertNewLines(t, "take care", 6, 2) // |take |care | + assertNewLines(t, "take care", 7, 2) // |take |care | + assertNewLines(t, "take care", 8, 2) // |take |care | + assertNewLines(t, "take care", 9, 2) // |take |care | + assertNewLines(t, "take care", 10, 1) // |take care| + assertNewLines(t, "take care", 11, 1) // |take care | // LEN=16, WIDTH=16 - assertRenderedHeight(t, "have a good day!", 1, 13) // |h|a|v|e|a|g|o|o|d|d|a|y|!| - assertRenderedHeight(t, "have a good day!", 2, 7) // |ha|ve|a |go|od|da|y!| - assertRenderedHeight(t, "have a good day!", 3, 5) // |hav|e a|goo|d d|ay!| - assertRenderedHeight(t, "have a good day!", 4, 4) // |have|a |good|day!| - assertRenderedHeight(t, "have a good day!", 5, 4) // |have |a |good |day! | - assertRenderedHeight(t, "have a good day!", 6, 3) // |have a|good |day! | - assertRenderedHeight(t, "have a good day!", 7, 3) // |have a |good |day! | - assertRenderedHeight(t, "have a good day!", 8, 3) // |have a |good |day! | - assertRenderedHeight(t, "have a good day!", 9, 2) // |have a |good day!| - assertRenderedHeight(t, "have a good day!", 10, 2) // |have a |good day! | - assertRenderedHeight(t, "have a good day!", 11, 2) // |have a good|day! | - assertRenderedHeight(t, "have a good day!", 12, 2) // |have a good |day! | - assertRenderedHeight(t, "have a good day!", 13, 2) // |have a good |day! | - assertRenderedHeight(t, "have a good day!", 14, 2) // |have a good |day! | - assertRenderedHeight(t, "have a good day!", 15, 2) // |have a good |day! | - assertRenderedHeight(t, "have a good day!", 16, 1) // |have a good day!| - assertRenderedHeight(t, "have a good day!", 17, 1) // |have a good day! | + assertNewLines(t, "have a good day!", 1, 13) // |h|a|v|e|a|g|o|o|d|d|a|y|!| + assertNewLines(t, "have a good day!", 2, 7) // |ha|ve|a |go|od|da|y!| + assertNewLines(t, "have a good day!", 3, 5) // |hav|e a|goo|d d|ay!| + assertNewLines(t, "have a good day!", 4, 4) // |have|a |good|day!| + assertNewLines(t, "have a good day!", 5, 4) // |have |a |good |day! | + assertNewLines(t, "have a good day!", 6, 3) // |have a|good |day! | + assertNewLines(t, "have a good day!", 7, 3) // |have a |good |day! | + assertNewLines(t, "have a good day!", 8, 3) // |have a |good |day! | + assertNewLines(t, "have a good day!", 9, 2) // |have a |good day!| + assertNewLines(t, "have a good day!", 10, 2) // |have a |good day! | + assertNewLines(t, "have a good day!", 11, 2) // |have a good|day! | + assertNewLines(t, "have a good day!", 12, 2) // |have a good |day! | + assertNewLines(t, "have a good day!", 13, 2) // |have a good |day! | + assertNewLines(t, "have a good day!", 14, 2) // |have a good |day! | + assertNewLines(t, "have a good day!", 15, 2) // |have a good |day! | + assertNewLines(t, "have a good day!", 16, 1) // |have a good day!| + assertNewLines(t, "have a good day!", 17, 1) // |have a good day! | // LEN=15, WIDTH=11 - assertRenderedHeight(t, "\x0342barmand\x03: cc", 1, 10) // |b|a|r|m|a|n|d|:|c|c| - assertRenderedHeight(t, "\x0342barmand\x03: cc", 2, 5) // |ba|rm|an|d:|cc| - assertRenderedHeight(t, "\x0342barmand\x03: cc", 3, 4) // |bar|man|d: |cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 4, 3) // |barm|and:|cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 5, 3) // |barma|nd: |cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 6, 2) // |barman|d: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 7, 2) // |barmand|: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 8, 2) // |barmand:|cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 9, 2) // |barmand: |cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 10, 2) // |barmand: |cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 11, 1) // |barmand: cc| - assertRenderedHeight(t, "\x0342barmand\x03: cc", 12, 1) // |barmand: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 13, 1) // |barmand: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 14, 1) // |barmand: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 15, 1) // |barmand: cc | - assertRenderedHeight(t, "\x0342barmand\x03: cc", 16, 1) // |barmand: cc | + 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 | + assertNewLines(t, "\x0342barmand\x03: cc", 4, 3) // |barm|and:|cc | + assertNewLines(t, "\x0342barmand\x03: cc", 5, 3) // |barma|nd: |cc | + assertNewLines(t, "\x0342barmand\x03: cc", 6, 2) // |barman|d: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 7, 2) // |barmand|: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 8, 2) // |barmand:|cc | + assertNewLines(t, "\x0342barmand\x03: cc", 9, 2) // |barmand: |cc | + assertNewLines(t, "\x0342barmand\x03: cc", 10, 2) // |barmand: |cc | + assertNewLines(t, "\x0342barmand\x03: cc", 11, 1) // |barmand: cc| + assertNewLines(t, "\x0342barmand\x03: cc", 12, 1) // |barmand: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 13, 1) // |barmand: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 14, 1) // |barmand: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 15, 1) // |barmand: cc | + assertNewLines(t, "\x0342barmand\x03: cc", 16, 1) // |barmand: cc | + + 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/style.go b/ui/style.go new file mode 100644 index 0000000..9c09b78 --- /dev/null +++ b/ui/style.go @@ -0,0 +1,191 @@ +package ui + +import ( + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" +) + +type widthBuffer struct { + width int + color colorBuffer +} + +func (wb *widthBuffer) Width() int { + return wb.width +} + +func (wb *widthBuffer) WriteString(s string) { + for _, r := range s { + wb.WriteRune(r) + } +} + +func (wb *widthBuffer) WriteRune(r rune) { + if ok := wb.color.WriteRune(r); ok != 0 { + if 1 < ok { + wb.width++ + } + wb.width += runewidth.RuneWidth(r) + } +} + +func StringWidth(s string) int { + var wb widthBuffer + wb.WriteString(s) + return wb.Width() +} + +type styleBuffer struct { + st tcell.Style + color colorBuffer + bold bool + italic bool + underline bool +} + +func (sb *styleBuffer) Reset() { + sb.color.Reset() + sb.st = tcell.StyleDefault + sb.bold = false + sb.italic = false + sb.underline = false +} + +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 == 0x1D { + sb.italic = !sb.italic + //sb.st = st.Italic(sb.italic) + return sb.st, 0 + } + if r == 0x1F { + sb.underline = !sb.underline + sb.st = st.Underline(sb.underline) + return sb.st, 0 + } + if ok = sb.color.WriteRune(r); ok != 0 { + sb.st = sb.color.Style(sb.st) + } + + return sb.st, ok +} + +type colorBuffer struct { + state int + fg, bg int +} + +func (cb *colorBuffer) Reset() { + cb.state = 0 + cb.fg = -1 + cb.bg = -1 +} + +func (cb *colorBuffer) Style(st tcell.Style) tcell.Style { + if 0 <= cb.fg { + st = st.Foreground(colorFromCode(cb.fg)) + } + if 0 <= cb.bg { + st = st.Background(colorFromCode(cb.bg)) + } + return st +} + +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 + } + } + + if r == 0x03 { + cb.state = 1 + cb.fg = -1 + cb.bg = -1 + return + } + + cb.state = 0 + ok++ + return +} + +func colorFromCode(code int) (color tcell.Color) { + switch code { + case 0: + color = tcell.ColorWhite + case 1: + color = tcell.ColorBlack + case 2: + color = tcell.ColorBlue + case 3: + color = tcell.ColorGreen + case 4: + color = tcell.ColorRed + case 5: + color = tcell.ColorBrown + case 6: + color = tcell.ColorPurple + case 7: + color = tcell.ColorOrange + case 8: + color = tcell.ColorYellow + case 9: + color = tcell.ColorLightGreen + case 10: + color = tcell.ColorTeal + case 11: + color = tcell.ColorFuchsia + case 12: + color = tcell.ColorLightBlue + case 13: + color = tcell.ColorPink + case 14: + color = tcell.ColorGrey + case 15: + color = tcell.ColorLightGrey + case 99: + color = tcell.ColorDefault + default: + color = tcell.Color(code) + } + return +} diff --git a/ui/width_test.go b/ui/style_test.go index ae478ab..ae478ab 100644 --- a/ui/width_test.go +++ b/ui/style_test.go @@ -1,11 +1,11 @@ package ui import ( - "github.com/gdamore/tcell" - "github.com/mattn/go-runewidth" "math/rand" "sync/atomic" "time" + + "github.com/gdamore/tcell" ) type UI struct { @@ -13,11 +13,8 @@ type UI struct { Events chan tcell.Event exit atomic.Value // bool - bufferList BufferList - scrollAmt int - scrollAtTop bool - - e editor + bs bufferList + e editor } func New() (ui *UI, err error) { @@ -47,15 +44,9 @@ func New() (ui *UI, err error) { ui.exit.Store(false) hmIdx := rand.Intn(len(homeMessages)) - ui.bufferList = BufferList{ - List: []Buffer{ - { - Title: "home", - Highlights: 0, - Content: []Line{NewLineNow(homeMessages[hmIdx])}, - }, - }, - } + ui.bs = newBufferList(w, h) + ui.bs.Add(Home) + ui.bs.AddLine("", NewLineNow("--", homeMessages[hmIdx])) ui.e = newEditor(w) @@ -76,147 +67,70 @@ func (ui *UI) Close() { ui.screen.Fini() } -func (ui *UI) CurrentBuffer() (title string) { - title = ui.bufferList.List[ui.bufferList.Current].Title - return +func (ui *UI) CurrentBuffer() string { + return ui.bs.Current() } -func (ui *UI) CurrentBufferOldestTime() (t time.Time) { - b := ui.bufferList.List[ui.bufferList.Current].Content - if len(b) == 0 { - t = time.Now() - } else { - t = b[0].Time - } - return +func (ui *UI) CurrentBufferOldestTime() (t *time.Time) { + return ui.bs.CurrentOldestTime() } -func (ui *UI) NextBuffer() (ok bool) { - ok = ui.bufferList.Next() - if ok { - ui.scrollAmt = 0 - ui.scrollAtTop = false - ui.drawTyping() - ui.drawBuffer() - ui.drawStatus() - } - return +func (ui *UI) NextBuffer() { + ui.bs.Next() + ui.draw() } -func (ui *UI) PreviousBuffer() (ok bool) { - ok = ui.bufferList.Previous() - if ok { - ui.scrollAmt = 0 - ui.scrollAtTop = false - ui.drawTyping() - ui.drawBuffer() - ui.drawStatus() - } - return +func (ui *UI) PreviousBuffer() { + ui.bs.Previous() + ui.draw() } func (ui *UI) ScrollUp() { - if ui.scrollAtTop { - return - } - - _, h := ui.screen.Size() - ui.scrollAmt += h / 2 - ui.drawBuffer() + ui.bs.ScrollUp() + ui.draw() } func (ui *UI) ScrollDown() { - if ui.scrollAmt == 0 { - return - } - - _, h := ui.screen.Size() - ui.scrollAmt -= h / 2 - if ui.scrollAmt < 0 { - ui.scrollAmt = 0 - } - ui.scrollAtTop = false - - ui.drawBuffer() + ui.bs.ScrollDown() + ui.draw() } func (ui *UI) IsAtTop() bool { - return ui.scrollAtTop + return ui.bs.IsAtTop() } func (ui *UI) AddBuffer(title string) { - _, ok := ui.bufferList.Add(title) + ok := ui.bs.Add(title) if ok { - ui.drawStatus() - ui.drawTyping() - ui.drawBuffer() // TODO only invalidate buffer list + ui.draw() } } func (ui *UI) RemoveBuffer(title string) { - ok := ui.bufferList.Remove(title) + ok := ui.bs.Remove(title) if ok { - ui.drawStatus() - ui.drawTyping() - ui.drawBuffer() + ui.draw() } } -func (ui *UI) AddLine(buffer string, line string, t time.Time, isStatus bool) { - idx := ui.bufferList.Idx(buffer) - if idx < 0 { - return - } - - ui.bufferList.AddLine(idx, line, t, isStatus) +func (ui *UI) AddLine(buffer string, line Line) { + ui.bs.AddLine(buffer, line) + ui.draw() +} - if idx == ui.bufferList.Current { - if 0 < ui.scrollAmt { - ui.scrollAmt++ - } else { - ui.drawBuffer() - } - } +func (ui *UI) AddLines(buffer string, lines []Line) { + ui.bs.AddLines(buffer, lines) + ui.draw() } func (ui *UI) TypingStart(buffer, nick string) { - idx := ui.bufferList.Idx(buffer) - if idx < 0 { - return - } - - ui.bufferList.TypingStart(idx, nick) - - if idx == ui.bufferList.Current { - ui.drawTyping() - } + ui.bs.TypingStart(buffer, nick) + ui.draw() } func (ui *UI) TypingStop(buffer, nick string) { - idx := ui.bufferList.Idx(buffer) - if idx < 0 { - return - } - - ui.bufferList.TypingStop(idx, nick) - - if idx == ui.bufferList.Current { - ui.drawTyping() - } -} - -func (ui *UI) AddHistoryLines(buffer string, lines []Line) { - idx := ui.bufferList.Idx(buffer) - if idx < 0 { - return - } - - ui.bufferList.AddHistoryLines(idx, lines) - - if idx == ui.bufferList.Current { - ui.scrollAtTop = false - ui.drawBuffer() - } + ui.bs.TypingStop(buffer, nick) + ui.draw() } func (ui *UI) InputIsCommand() bool { @@ -229,337 +143,48 @@ func (ui *UI) InputLen() int { func (ui *UI) InputRune(r rune) { ui.e.PutRune(r) - _, h := ui.screen.Size() - ui.e.Draw(ui.screen, h-2) - ui.screen.Show() + ui.draw() } func (ui *UI) InputRight() { ui.e.Right() - _, h := ui.screen.Size() - ui.e.Draw(ui.screen, h-2) - ui.screen.Show() + ui.draw() } func (ui *UI) InputLeft() { ui.e.Left() - _, h := ui.screen.Size() - ui.e.Draw(ui.screen, h-2) - ui.screen.Show() + ui.draw() } func (ui *UI) InputBackspace() (ok bool) { ok = ui.e.RemRune() if ok { - _, h := ui.screen.Size() - ui.e.Draw(ui.screen, h-2) - ui.screen.Show() + ui.draw() } return } func (ui *UI) InputEnter() (content string) { content = ui.e.Flush() - _, h := ui.screen.Size() - ui.e.Draw(ui.screen, h-2) - ui.screen.Show() + ui.draw() return } func (ui *UI) Resize() { - w, _ := ui.screen.Size() + w, h := ui.screen.Size() ui.e.Resize(w) - ui.bufferList.Invalidate() - ui.scrollAmt = 0 + ui.bs.Resize(w, h) ui.draw() } func (ui *UI) draw() { _, h := ui.screen.Size() - ui.drawStatus() ui.e.Draw(ui.screen, h-2) - ui.drawTyping() - ui.drawBuffer() -} - -func (ui *UI) drawTyping() { - st := tcell.StyleDefault.Dim(true) - w, h := ui.screen.Size() - if w == 0 { - return - } - - nicks := ui.bufferList.List[ui.bufferList.Current].Typings - if len(nicks) == 0 { - return - } - - x := 0 - y := h - 3 - - if 1 < len(nicks) { - for _, nick := range nicks[:len(nicks)-2] { - for _, r := range nick { - if w <= x { - return - } - ui.screen.SetContent(x, y, r, nil, st) - x += runewidth.RuneWidth(r) - } - - if w <= x { - return - } - ui.screen.SetContent(x, y, ',', nil, st) - x++ - if w <= x { - return - } - ui.screen.SetContent(x, y, ' ', nil, st) - x++ - } - - for _, r := range nicks[len(nicks)-2] { - if w <= x { - return - } - ui.screen.SetContent(x, y, r, nil, st) - x += runewidth.RuneWidth(r) - } - - if w <= x { - return - } - ui.screen.SetContent(x, y, ' ', nil, st) - x++ - if w <= x { - return - } - ui.screen.SetContent(x, y, 'a', nil, st) - x++ - if w <= x { - return - } - ui.screen.SetContent(x, y, 'n', nil, st) - x++ - if w <= x { - return - } - ui.screen.SetContent(x, y, 'd', nil, st) - x++ - if w <= x { - return - } - ui.screen.SetContent(x, y, ' ', nil, st) - x++ - } - - for _, r := range nicks[len(nicks)-1] { - if w <= x { - return - } - ui.screen.SetContent(x, y, r, nil, st) - x += runewidth.RuneWidth(r) - } - - verb := " are typing..." - if len(nicks) == 1 { - verb = " is typing..." - } - - for _, r := range verb { - if w <= x { - return - } - ui.screen.SetContent(x, y, r, nil, st) - x += runewidth.RuneWidth(r) - } - - for ; x < w; x++ { - ui.screen.SetContent(x, y, ' ', nil, st) - } - - ui.screen.Show() -} - -func (ui *UI) drawBuffer() { - st := tcell.StyleDefault - w, h := ui.screen.Size() - if h < 3 { - return - } - - for x := 0; x < w; x++ { - for y := 0; y < h-3; y++ { - ui.screen.SetContent(x, y, ' ', nil, st) - } - } - - b := ui.bufferList.List[ui.bufferList.Current] - - if len(b.Content) == 0 { - ui.scrollAtTop = true - return - } - - var bold, italic, underline bool - var colorState int - var fgColor, bgColor int - - yEnd := h - 3 - y0 := ui.scrollAmt + h - 3 - - for i := len(b.Content) - 1; 0 <= i; i-- { - line := &b.Content[i] - - if y0 < 0 { - break - } - - lineHeight := line.RenderedHeight(w) - y0 -= lineHeight - if yEnd <= y0 { - continue - } - - rs := []rune(line.Content) - x := 0 - y := y0 - var lastSP Point - spIdx := 0 - - for i, r := range rs { - if i == line.SplitPoints[spIdx].I { - lastSP = line.SplitPoints[spIdx] - spIdx++ - - l := line.SplitPoints[spIdx].X - lastSP.X - - if w < l { - } else if w == l { - if x == 0 { - y++ - } - } else if w < x+l { - y++ - x = 0 - } - } - if !line.SplitPoints[spIdx].Split && x == 0 { - continue - } - if w <= x { - y++ - x = 0 - } - - if colorState == 1 { - fgColor = 0 - bgColor = 0 - if '0' <= r && r <= '9' { - fgColor = fgColor*10 + int(r-'0') - colorState = 2 - continue - } - st = st.Foreground(tcell.ColorDefault) - st = st.Background(tcell.ColorDefault) - colorState = 0 - } else if colorState == 2 { - if '0' <= r && r <= '9' { - fgColor = fgColor*10 + int(r-'0') - colorState = 3 - continue - } - if r == ',' { - colorState = 4 - continue - } - c := colorFromCode(fgColor) - st = st.Foreground(c) - colorState = 0 - } else if colorState == 3 { - if r == ',' { - colorState = 4 - continue - } - c := colorFromCode(fgColor) - st = st.Foreground(c) - colorState = 0 - } else if colorState == 4 { - if '0' <= r && r <= '9' { - bgColor = bgColor*10 + int(r-'0') - colorState = 5 - continue - } - - c := colorFromCode(fgColor) - st = st.Foreground(c) - colorState = 0 - - ui.screen.SetContent(x, y, ',', nil, st) - x++ - if w <= x { - y++ - x = 0 - } - } else if colorState == 5 { - colorState = 0 - st = st.Foreground(colorFromCode(fgColor)) - - if '0' <= r && r <= '9' { - bgColor = bgColor*10 + int(r-'0') - st = st.Background(colorFromCode(bgColor)) - continue - } - - st = st.Background(colorFromCode(bgColor)) - } - - if r == 0x00 || r == 0x0F { - bold = false - italic = false - underline = false - colorState = 0 - st = tcell.StyleDefault - continue - } - if r == 0x02 { - bold = !bold - st = st.Bold(bold) - continue - } - if r == 0x03 { - colorState = 1 - continue - } - if r == 0x1D { - italic = !italic - //st = st.Italic(italic) - continue - } - if r == 0x1F { - underline = !underline - st = st.Underline(underline) - continue - } - - if 0 <= y { - ui.screen.SetContent(x, y, r, nil, st) - } - x += runewidth.RuneWidth(r) - } - - st = tcell.StyleDefault - bold = false - italic = false - underline = false - colorState = 0 - } - - ui.scrollAtTop = 0 <= y0 + ui.bs.Draw(ui.screen) ui.screen.Show() } +/* func (ui *UI) drawStatus() { st := tcell.StyleDefault w, h := ui.screen.Size() @@ -618,45 +243,4 @@ func (ui *UI) drawStatus() { ui.screen.Show() } - -func colorFromCode(code int) (color tcell.Color) { - switch code { - case 0: - color = tcell.ColorWhite - case 1: - color = tcell.ColorBlack - case 2: - color = tcell.ColorBlue - case 3: - color = tcell.ColorGreen - case 4: - color = tcell.ColorRed - case 5: - color = tcell.ColorBrown - case 6: - color = tcell.ColorPurple - case 7: - color = tcell.ColorOrange - case 8: - color = tcell.ColorYellow - case 9: - color = tcell.ColorLightGreen - case 10: - color = tcell.ColorTeal - case 11: - color = tcell.ColorFuchsia - case 12: - color = tcell.ColorLightBlue - case 13: - color = tcell.ColorPink - case 14: - color = tcell.ColorGrey - case 15: - color = tcell.ColorLightGrey - case 99: - color = tcell.ColorDefault - default: - color = tcell.Color(code) - } - return -} +// */ diff --git a/ui/width.go b/ui/width.go deleted file mode 100644 index 6634993..0000000 --- a/ui/width.go +++ /dev/null @@ -1,75 +0,0 @@ -package ui - -import ( - "github.com/mattn/go-runewidth" -) - -type widthBuffer struct { - width int - color int - comma bool -} - -func (wb *widthBuffer) Width() int { - return wb.width -} - -func (wb *widthBuffer) WriteString(s string) { - for _, r := range s { - wb.WriteRune(r) - } -} - -func (wb *widthBuffer) WriteRune(r rune) { - if wb.color == 1 { - if '0' <= r && r <= '9' { - wb.color = 2 - return - } - wb.color = 0 - } else if wb.color == 2 { - if '0' <= r && r <= '9' { - wb.color = 3 - return - } - if r == ',' { - wb.color = 4 - return - } - wb.color = 0 - } else if wb.color == 3 { - if r == ',' { - wb.color = 4 - return - } - wb.color = 0 - } else if wb.color == 4 { - if '0' <= r && r <= '9' { - wb.color = 5 - return - } - - wb.width++ - wb.color = 0 - } else if wb.color == 5 { - wb.color = 0 - if '0' <= r && r <= '9' { - return - } - } - - if r == 0x03 { - wb.color = 1 - return - } - - wb.width += runewidth.RuneWidth(r) -} - -func StringWidth(s string) int { - var wb widthBuffer - - wb.WriteString(s) - - return wb.Width() -} |