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
}