package ui import ( "fmt" "math" "strings" "time" "github.com/gdamore/tcell/v2" ) const Overlay = "/overlay" func IsSplitRune(r rune) bool { return r == ' ' || r == '\t' } type point struct { X, I int Split bool } type NotifyType int const ( NotifyNone NotifyType = iota NotifyUnread NotifyHighlight ) type Line struct { At time.Time Head string Body StyledString HeadColor tcell.Color Notify NotifyType Highlight bool Readable bool Mergeable bool Data interface{} splitPoints []point width int newLines []int HeadTag string } func (l *Line) IsZero() bool { return l.Body.string == "" } func (l *Line) computeSplitPoints() { if l.splitPoints == nil { l.splitPoints = []point{} } width := 0 lastWasSplit := false l.splitPoints = l.splitPoints[:0] for i, r := range l.Body.string { curIsSplit := IsSplitRune(r) if i == 0 || lastWasSplit != curIsSplit { l.splitPoints = append(l.splitPoints, point{ X: width, I: i, Split: curIsSplit, }) } lastWasSplit = curIsSplit width += runeWidth(r) } if !lastWasSplit { l.splitPoints = append(l.splitPoints, point{ X: width, I: len(l.Body.string), Split: true, }) } } 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! if l.width == width { return l.newLines } if l.newLines == nil { l.newLines = []int{} } l.newLines = l.newLines[:0] l.width = width x := 0 for i := 1; i < len(l.splitPoints); i++ { // Iterate through the split points 2 by 2. Split points are placed at // the beginning of whitespace (see IsSplitRune) and at the beginning // of non-whitespace. Iterating on 2 points each time, sp1 and sp2, // allows 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 beginning 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 && 0 < len(l.newLines) && 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 sp1.Split && width < sp2.X-sp1.X { // Some whitespace occupies a width larger than the terminal's. x = 0 l.newLines = append(l.newLines, sp1.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. // TODO handle multi-codepoint graphemes?? :( wordWidth := 0 h := 1 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 + wordWidth) % width if 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 { // 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 } } } 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] } return l.newLines } type buffer struct { netID string netName string title string highlights int unread bool read time.Time openedOnce bool lines []Line topic string scrollAmt int isAtTop bool } type BufferList struct { colors ConfigColors list []buffer overlay *buffer current int clicked int tlInnerWidth int tlHeight int textWidth int showBufferNumbers bool doMergeLine func(former *Line, addition Line) } // NewBufferList returns a new BufferList. // Call Resize() once before using it. func NewBufferList(colors ConfigColors, mergeLine func(*Line, Line)) BufferList { return BufferList{ colors: colors, list: []buffer{}, clicked: -1, doMergeLine: mergeLine, } } func (bs *BufferList) ResizeTimeline(tlInnerWidth, tlHeight, textWidth int) { bs.tlInnerWidth = tlInnerWidth bs.tlHeight = tlHeight - 2 bs.textWidth = textWidth } func (bs *BufferList) OpenOverlay() { bs.overlay = &buffer{ netID: "", netName: "", title: Overlay, } } func (bs *BufferList) CloseOverlay() { bs.overlay = nil } func (bs *BufferList) HasOverlay() bool { return bs.overlay != nil } func (bs *BufferList) To(i int) bool { bs.overlay = nil if i == bs.current { return false } if 0 <= i { bs.current = i if len(bs.list) <= bs.current { bs.current = len(bs.list) - 1 } bs.list[bs.current].highlights = 0 bs.list[bs.current].unread = false return true } return false } func (bs *BufferList) ShowBufferNumbers(enabled bool) { bs.showBufferNumbers = enabled } func (bs *BufferList) Next() { bs.overlay = nil bs.current = (bs.current + 1) % len(bs.list) b := bs.cur() b.highlights = 0 b.unread = false } func (bs *BufferList) Previous() { bs.overlay = nil bs.current = (bs.current - 1 + len(bs.list)) % len(bs.list) b := bs.cur() b.highlights = 0 b.unread = false } func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) { i = 0 lTitle := strings.ToLower(title) for bi, b := range bs.list { if netName == "" && b.netID == netID { netName = b.netName } if netName == "" || b.netName < netName { i = bi + 1 continue } if b.netName > netName { break } lbTitle := strings.ToLower(b.title) if lbTitle < lTitle { i = bi + 1 continue } if lbTitle == lTitle { return i, false } break } if i <= bs.current && bs.current < len(bs.list) { bs.current++ } b := buffer{ netID: netID, netName: netName, title: title, } if i == len(bs.list) { bs.list = append(bs.list, b) } else { bs.list = append(bs.list[:i+1], bs.list[i:]...) bs.list[i] = b } return i, true } func (bs *BufferList) Remove(netID, title string) bool { idx, b := bs.at(netID, title) if b == bs.overlay { bs.overlay = nil return false } if idx < 0 { return false } bs.list = append(bs.list[:idx], bs.list[idx+1:]...) if len(bs.list) <= bs.current { bs.current-- } return true } func (bs *BufferList) RemoveNetwork(netID string) { for idx := 0; idx < len(bs.list); idx++ { b := &bs.list[idx] if b.netID != netID { continue } bs.list = append(bs.list[:idx], bs.list[idx+1:]...) if len(bs.list) <= bs.current { bs.current-- } idx-- } } func (bs *BufferList) mergeLine(former *Line, addition Line) (keepLine bool) { bs.doMergeLine(former, addition) if former.Body.string == "" { return false } former.width = 0 former.computeSplitPoints() return true } func (bs *BufferList) AddLine(netID, title string, line Line) { _, b := bs.at(netID, title) if b == nil { return } current := bs.cur() n := len(b.lines) line.At = line.At.UTC() if !line.Mergeable && current.openedOnce { line.Body = line.Body.ParseURLs() } if line.Mergeable && n != 0 && b.lines[n-1].Mergeable { l := &b.lines[n-1] if !bs.mergeLine(l, line) { b.lines = b.lines[:n-1] } // TODO change b.scrollAmt if it's not 0 and bs.current is idx. } else { if n != 0 { l := &b.lines[n-1] if l.Head == line.Head || l.HeadTag == line.Head { line.HeadTag = line.Head line.Head = "" } } line.computeSplitPoints() b.lines = append(b.lines, line) if b == current && 0 < b.scrollAmt { b.scrollAmt += len(line.NewLines(bs.textWidth)) + 1 } } if line.Notify != NotifyNone && b != current { b.unread = true } if line.Notify == NotifyHighlight && b != current { b.highlights++ } } func (bs *BufferList) AddLines(netID, title string, before, after []Line) { _, b := bs.at(netID, title) if b == nil { return } lines := make([]Line, 0, len(before)+len(b.lines)+len(after)) for _, buf := range []*[]Line{&before, &b.lines, &after} { for _, line := range *buf { if line.Mergeable && len(lines) > 0 && lines[len(lines)-1].Mergeable { l := &lines[len(lines)-1] if !bs.mergeLine(l, line) { lines = lines[:len(lines)-1] } } else { if buf != &b.lines { if b.openedOnce { line.Body = line.Body.ParseURLs() } line.computeSplitPoints() } if len(lines) > 0 { l := &lines[len(lines)-1] if l.Head == line.Head || l.HeadTag == line.Head { line.HeadTag = line.Head line.Head = "" } } lines = append(lines, line) } } } b.lines = lines } func (bs *BufferList) SetTopic(netID, title string, topic string) { _, b := bs.at(netID, title) if b == nil { return } b.topic = topic } func (bs *BufferList) SetRead(netID, title string, timestamp time.Time) { _, b := bs.at(netID, title) if b == nil { return } clearRead := true for i := len(b.lines) - 1; i >= 0; i-- { line := &b.lines[i] if !line.At.After(timestamp) { break } if line.Readable && line.Notify != NotifyNone { clearRead = false break } } if clearRead { b.highlights = 0 b.unread = false } if b.read.Before(timestamp) { b.read = timestamp } } func (bs *BufferList) UpdateRead() (netID, title string, timestamp time.Time) { b := bs.cur() var line *Line y := 0 for i := len(b.lines) - 1; 0 <= i; i-- { line = &b.lines[i] if y >= b.scrollAmt && line.Readable { break } y += len(line.NewLines(bs.textWidth)) + 1 } if line != nil && line.At.After(b.read) { b.read = line.At return b.netID, b.title, b.read } return "", "", time.Time{} } func (bs *BufferList) Current() (netID, title string) { b := &bs.list[bs.current] return b.netID, b.title } func (bs *BufferList) ScrollUp(n int) { b := bs.cur() if b.isAtTop { return } b.scrollAmt += n } func (bs *BufferList) ScrollDown(n int) { b := bs.cur() b.scrollAmt -= n if b.scrollAmt < 0 { b.scrollAmt = 0 } } func (bs *BufferList) ScrollUpHighlight() bool { b := bs.cur() ymin := b.scrollAmt + bs.tlHeight y := 0 for i := len(b.lines) - 1; 0 <= i; i-- { line := &b.lines[i] if ymin <= y && line.Highlight { b.scrollAmt = y - bs.tlHeight + 1 return true } y += len(line.NewLines(bs.textWidth)) + 1 } return false } func (bs *BufferList) ScrollDownHighlight() bool { b := bs.cur() yLastHighlight := 0 y := 0 for i := len(b.lines) - 1; 0 <= i && y < b.scrollAmt; i-- { line := &b.lines[i] if line.Highlight { yLastHighlight = y } y += len(line.NewLines(bs.textWidth)) + 1 } b.scrollAmt = yLastHighlight return b.scrollAmt != 0 } func (bs *BufferList) IsAtTop() bool { b := bs.cur() return b.isAtTop } func (bs *BufferList) at(netID, title string) (int, *buffer) { if netID == "" && title == Overlay { return -1, bs.overlay } lTitle := strings.ToLower(title) for i, b := range bs.list { if b.netID == netID && strings.ToLower(b.title) == lTitle { return i, &bs.list[i] } } return -1, nil } func (bs *BufferList) cur() *buffer { if bs.overlay != nil { return bs.overlay } return &bs.list[bs.current] } func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width, height int, offset *int) { if y0+len(bs.list)-*offset < height { *offset = y0 + len(bs.list) - height if *offset < 0 { *offset = 0 } } width-- drawVerticalLine(screen, x0+width, y0, height) clearArea(screen, x0, y0, width, height) indexPadding := 1 + int(math.Ceil(math.Log10(float64(len(bs.list))))) for i, b := range bs.list[*offset:] { bi := *offset + i x := x0 y := y0 + i st := tcell.StyleDefault if b.unread { st = st.Bold(true).Foreground(bs.colors.Unread) } if bi == bs.current || bi == bs.clicked { st = st.Reverse(true) } if bs.showBufferNumbers { indexSt := st.Foreground(tcell.ColorGray) indexText := fmt.Sprintf("%d:", bi) printString(screen, &x, y, Styled(indexText, indexSt)) x = x0 + indexPadding } var title string if b.title == "" { title = b.netName } else { if bi == bs.current || bi == bs.clicked { screen.SetContent(x, y, ' ', nil, tcell.StyleDefault.Reverse(true)) screen.SetContent(x+1, y, ' ', nil, tcell.StyleDefault.Reverse(true)) } x += 2 title = b.title } title = truncate(title, width-(x-x0), "\u2026") printString(screen, &x, y, Styled(title, st)) if bi == bs.current || bi == bs.clicked { st := tcell.StyleDefault.Reverse(true) for ; x < x0+width; x++ { screen.SetContent(x, y, ' ', nil, st) } screen.SetContent(x, y, 0x2590, nil, st) } if b.highlights != 0 { highlightSt := st.Foreground(tcell.ColorRed).Reverse(true) highlightText := fmt.Sprintf(" %d ", b.highlights) x = x0 + width - len(highlightText) printString(screen, &x, y, Styled(highlightText, highlightSt)) } } } func (bs *BufferList) HorizontalBufferOffset(x int, offset int) int { for i, b := range bs.list[offset:] { if i > 0 { x-- if x < 0 { return -1 } } x -= bufferWidth(&b) if x < 0 { return offset + i } } return -1 } func (bs *BufferList) GetLeftMost(screenWidth int) int { if len(bs.list) == 0 { return 0 } width := 0 var leftMost int for leftMost = bs.current; leftMost >= 0; leftMost-- { if leftMost < bs.current { width++ } width += bufferWidth(&bs.list[leftMost]) if width > screenWidth { return leftMost + 1 // Went offscreen, need to go one step back } } return 0 } func bufferWidth(b *buffer) int { width := 0 if b.title == "" { width += stringWidth(b.netName) } else { width += stringWidth(b.title) } if 0 < b.highlights { width += 2 + len(fmt.Sprintf("%d", b.highlights)) } return width } func (bs *BufferList) DrawHorizontalBufferList(screen tcell.Screen, x0, y0, width int, offset *int) { x := width for i := len(bs.list) - 1; i >= 0; i-- { b := &bs.list[i] x-- x -= bufferWidth(b) if x <= 10 { break } if *offset > i { *offset = i } } x = x0 for i, b := range bs.list[*offset:] { i := i + *offset if width <= x-x0 { break } st := tcell.StyleDefault if b.unread { st = st.Bold(true).Foreground(bs.colors.Unread) } else if i == bs.current { st = st.Underline(true) } if i == bs.clicked { st = st.Reverse(true) } var title string if b.title == "" { st = st.Dim(true) title = b.netName } else { title = b.title } title = truncate(title, width-x, "\u2026") printString(screen, &x, y0, Styled(title, st)) if 0 < b.highlights { st = st.Foreground(tcell.ColorRed).Reverse(true) screen.SetContent(x, y0, ' ', nil, st) x++ printNumber(screen, &x, y0, st, b.highlights) screen.SetContent(x, y0, ' ', nil, st) x++ } screen.SetContent(x, y0, ' ', nil, tcell.StyleDefault) x++ } for x < width { screen.SetContent(x, y0, ' ', nil, tcell.StyleDefault) x++ } } func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int) { clearArea(screen, x0, y0, bs.tlInnerWidth+nickColWidth+9, bs.tlHeight+2) b := bs.cur() if !b.openedOnce { b.openedOnce = true for i := 0; i < len(b.lines); i++ { b.lines[i].Body = b.lines[i].Body.ParseURLs() } } xTopic := x0 printString(screen, &xTopic, y0, Styled(b.topic, tcell.StyleDefault)) y0++ for x := x0; x < x0+bs.tlInnerWidth+nickColWidth+9; x++ { st := tcell.StyleDefault.Foreground(tcell.ColorGray) screen.SetContent(x, y0, 0x2500, nil, st) } y0++ if bs.textWidth < bs.tlInnerWidth { x0 += (bs.tlInnerWidth - bs.textWidth) / 2 } yi := b.scrollAmt + y0 + bs.tlHeight for i := len(b.lines) - 1; 0 <= i; i-- { if yi < y0 { break } x1 := x0 + 9 + nickColWidth line := &b.lines[i] nls := line.NewLines(bs.textWidth) yi -= len(nls) + 1 if y0+bs.tlHeight <= yi { continue } var showDate bool if i == 0 || yi <= y0 { showDate = true } else { yb, mb, dd := b.lines[i-1].At.Local().Date() ya, ma, da := b.lines[i].At.Local().Date() showDate = yb != ya || mb != ma || dd != da } if showDate { st := tcell.StyleDefault.Bold(true) // as a special case, always draw the first visible message date, even if it is a continuation line yd := yi if yd < y0 { yd = y0 } printDate(screen, x0, yd, st, line.At.Local()) } else if b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) && yi >= y0 { st := tcell.StyleDefault.Foreground(tcell.ColorGray) printTime(screen, x0, yi, st, line.At.Local()) } if yi >= y0 { 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 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++ nls = nls[1:] if y0+bs.tlHeight <= y { break } } if y != yi && x == x1 && IsSplitRune(r) { continue } if y >= y0 { screen.SetContent(x, y, r, nil, style) } x += runeWidth(r) } } b.isAtTop = y0 <= yi }