master sewn/kohai / ui / buffers.go
   1package ui
   2
   3import (
   4	"fmt"
   5	"math"
   6	"sort"
   7	"strconv"
   8	"strings"
   9	"time"
  10
  11	"git.sr.ht/~rockorager/vaxis"
  12
  13	"git.sr.ht/~delthas/senpai/events"
  14)
  15
  16const Overlay = "/overlay"
  17
  18func IsSplitRune(r rune) bool {
  19	return r == ' ' || r == '\t'
  20}
  21
  22type point struct {
  23	X     int // in cells
  24	I     int // in bytes
  25	Split bool
  26}
  27
  28type NotifyType int
  29
  30const (
  31	NotifyNone NotifyType = iota
  32	NotifyUnread
  33	NotifyHighlight
  34)
  35
  36type optional int
  37
  38const (
  39	optionalUnset optional = iota
  40	optionalFalse
  41	optionalTrue
  42)
  43
  44type Line struct {
  45	At        time.Time
  46	Head      StyledString
  47	Body      StyledString
  48	Notify    NotifyType
  49	Highlight bool
  50	Readable  bool
  51	Mergeable bool
  52	Data      interface{}
  53
  54	ID      string
  55	ReplyTo string
  56	Reply   *Reply
  57	Reacts  []React
  58
  59	splitPoints []point
  60	width       int
  61	newLines    []int
  62}
  63
  64type Reply struct {
  65	Nick StyledString
  66	Body StyledString
  67}
  68
  69type React struct {
  70	React string
  71	Users []string
  72}
  73
  74func (l *Line) IsZero() bool {
  75	return l.Body.string == ""
  76}
  77
  78func (l *Line) computeSplitPoints(vx *Vaxis) {
  79	if l.splitPoints == nil {
  80		l.splitPoints = []point{}
  81	}
  82
  83	width := 0
  84	lastWasSplit := false
  85	l.splitPoints = l.splitPoints[:0]
  86
  87	for i, r := range l.Body.string {
  88		curIsSplit := IsSplitRune(r)
  89
  90		if i == 0 || lastWasSplit != curIsSplit {
  91			l.splitPoints = append(l.splitPoints, point{
  92				X:     width,
  93				I:     i,
  94				Split: curIsSplit,
  95			})
  96		}
  97
  98		lastWasSplit = curIsSplit
  99		width += runeWidth(vx, r)
 100	}
 101
 102	if !lastWasSplit {
 103		l.splitPoints = append(l.splitPoints, point{
 104			X:     width,
 105			I:     len(l.Body.string),
 106			Split: true,
 107		})
 108	}
 109}
 110
 111// NewLines returns the offsets, in bytes, where the line should be split.
 112func (l *Line) NewLines(vx *Vaxis, width int) []int {
 113	// Beware! This function was made by your local Test Driven Developper™ who
 114	// doesn't understand one bit of this function and how it works (though it
 115	// might not work that well if you're here...).  The code below is thus very
 116	// cryptic and not well-structured.  However, I'm going to try to explain
 117	// some of those lines!
 118
 119	if l.width == width {
 120		return l.newLines
 121	}
 122	if l.newLines == nil {
 123		l.newLines = []int{}
 124	}
 125	l.newLines = l.newLines[:0]
 126	l.width = width
 127
 128	x := 0
 129	for i := 1; i < len(l.splitPoints); i++ {
 130		// Iterate through the split points 2 by 2.  Split points are placed at
 131		// the beginning of whitespace (see IsSplitRune) and at the beginning
 132		// of non-whitespace. Iterating on 2 points each time, sp1 and sp2,
 133		// allows consideration of a "word" of (non-)whitespace.
 134		// Split points have the index I in the string and the width X of the
 135		// screen.  Finally, the Split field is set to true if the split point
 136		// is at the beginning of a whitespace.
 137
 138		// Below, "row" means a line in the terminal, while "line" means (l *Line).
 139
 140		sp1 := l.splitPoints[i-1]
 141		sp2 := l.splitPoints[i]
 142
 143		if 0 < len(l.newLines) && x == 0 && sp1.Split {
 144			// Except for the first row, let's skip the whitespace at the start
 145			// of the row.
 146		} else if !sp1.Split && sp2.X-sp1.X == width {
 147			// Some word occupies the width of the terminal, lets place a
 148			// newline at the PREVIOUS split point (i-2, which is whitespace)
 149			// ONLY if there isn't already one.
 150			if 1 < i && (0 == len(l.newLines) || (l.newLines[len(l.newLines)-1] != l.splitPoints[i-2].I && l.newLines[len(l.newLines)-1] != l.splitPoints[i-1].I)) {
 151				l.newLines = append(l.newLines, l.splitPoints[i-2].I)
 152			}
 153			// and also place a newline after the word.
 154			x = 0
 155			l.newLines = append(l.newLines, sp2.I)
 156		} else if sp2.X-sp1.X+x < width {
 157			// It fits.  Advance the X coordinate with the width of the word.
 158			x += sp2.X - sp1.X
 159		} else if sp2.X-sp1.X+x == width {
 160			// It fits, but there is no more space in the row.
 161			x = 0
 162			l.newLines = append(l.newLines, sp2.I)
 163		} else if sp1.Split && width < sp2.X-sp1.X {
 164			// Some whitespace occupies a width larger than the terminal's.
 165			x = 0
 166			l.newLines = append(l.newLines, sp1.I)
 167		} else if width < sp2.X-sp1.X {
 168			// It doesn't fit at all.  The word is longer than the width of the
 169			// terminal.  In this case, no newline is placed before (like in the
 170			// 2nd if-else branch).  The for loop is used to place newlines in
 171			// the word.
 172			s := l.Body.string[sp1.I:sp2.I]
 173			j := 0
 174			for s != "" {
 175				c, wordWidth := firstCluster(vx, []rune(s))
 176				if width < x+wordWidth {
 177					x = 0
 178					l.newLines = append(l.newLines, sp1.I+j)
 179				}
 180				x += wordWidth
 181				j += len(c)
 182				s = s[len(c):]
 183			}
 184			if x == width {
 185				// The placement of the word is such that it ends right at the
 186				// end of the row.
 187				x = 0
 188				l.newLines = append(l.newLines, sp2.I)
 189			}
 190		} else {
 191			// So... IIUC this branch would be the same as
 192			//     else if width < sp2.X-sp1.X+x
 193			// IE. It doesn't fit, but the word can still be placed on the next
 194			// row.
 195			l.newLines = append(l.newLines, sp1.I)
 196			if sp1.Split {
 197				x = 0
 198			} else {
 199				x = sp2.X - sp1.X
 200			}
 201		}
 202	}
 203
 204	if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body.string) {
 205		// DROP any newline that is placed at the end of the string because we
 206		// don't care about those.
 207		l.newLines = l.newLines[:len(l.newLines)-1]
 208	}
 209
 210	return l.newLines
 211}
 212
 213// Rows returns the number of terminal rows l occupies at the given width,
 214// including its reply preview row, if any.
 215func (l *Line) Rows(vx *Vaxis, width int) int {
 216	n := len(l.NewLines(vx, width)) + 1
 217	if l.Reply != nil {
 218		n++
 219	}
 220	if len(l.Reacts) > 0 {
 221		n++
 222	}
 223	return n
 224}
 225
 226type buffer struct {
 227	netID         string
 228	netName       string
 229	title         string
 230	highlights    int
 231	notifications []int
 232	unread        bool
 233	read          time.Time
 234	openedOnce    bool
 235
 236	pinned bool
 237	muted  bool
 238
 239	// This is the "last read" timestamp when the buffer was last focused.
 240	// If the "last read" timestamp changes while the buffer is focused,
 241	// the ruler should not move.
 242	unreadRuler time.Time
 243	// Whether to draw the unread bar for the current buffer.
 244	// The goal is to draw the unread bar iff there was at least one unread
 245	// message when the buffer was opened.
 246	// The unreadSkip value starts off as optionalUnset, then gets set to
 247	// either optionalFalse or optionalTrue when a message is received.
 248	unreadSkip optional
 249
 250	lines []Line
 251	topic StyledString
 252
 253	scrollAmt   int // offset in lines from the bottom
 254	topicOffset int // offset in clusters that are skipped when rendering topic text
 255	isAtTop     bool
 256
 257	selected int
 258}
 259
 260type BufferList struct {
 261	ui *UI
 262
 263	list    []buffer
 264	overlay *buffer
 265	current int
 266	clicked int
 267	focused bool
 268
 269	tlInnerWidth int
 270	tlHeight     int
 271	textWidth    int
 272
 273	filterBuffers      bool
 274	filterBuffersQuery string // lowercased
 275}
 276
 277// NewBufferList returns a new BufferList.
 278// Call Resize() once before using it.
 279func NewBufferList(ui *UI) BufferList {
 280	return BufferList{
 281		ui:      ui,
 282		list:    []buffer{},
 283		clicked: -1,
 284		focused: true,
 285	}
 286}
 287
 288func (bs *BufferList) ResizeTimeline(tlInnerWidth, tlHeight, textWidth int) {
 289	bs.tlInnerWidth = tlInnerWidth
 290	bs.tlHeight = tlHeight
 291	if !bs.ui.hideTopic {
 292		bs.tlHeight -= 2
 293	}
 294	bs.textWidth = textWidth
 295}
 296
 297func (bs *BufferList) OpenOverlay() {
 298	bs.overlay = &buffer{
 299		netID:    "",
 300		netName:  "",
 301		title:    Overlay,
 302		selected: -1,
 303	}
 304}
 305
 306func (bs *BufferList) CloseOverlay() {
 307	bs.overlay = nil
 308}
 309
 310func (bs *BufferList) HasOverlay() bool {
 311	return bs.overlay != nil
 312}
 313
 314func (bs *BufferList) To(i int) bool {
 315	bs.overlay = nil
 316	if i == bs.current {
 317		return false
 318	}
 319	if 0 <= i {
 320		bs.current = i
 321		if len(bs.list) <= bs.current {
 322			bs.current = len(bs.list) - 1
 323		}
 324		bs.clearRead(bs.current)
 325		b := bs.list[bs.current]
 326		b.unreadRuler = b.read
 327		if len(b.lines) > 0 {
 328			l := b.lines[len(b.lines)-1]
 329			if !l.At.After(b.unreadRuler) {
 330				b.unreadSkip = optionalTrue
 331			} else {
 332				b.unreadSkip = optionalFalse
 333			}
 334		} else {
 335			b.unreadSkip = optionalUnset
 336		}
 337		bs.list[bs.current] = b
 338		return true
 339	}
 340	return false
 341}
 342
 343func (bs *BufferList) FilterBuffers(enable bool, query string) {
 344	bs.filterBuffers = enable
 345	bs.filterBuffersQuery = strings.ToLower(query)
 346}
 347
 348func (bs *BufferList) Next() {
 349	c := (bs.current + 1) % len(bs.list)
 350	bs.To(c)
 351}
 352
 353func (bs *BufferList) Previous() {
 354	c := (bs.current - 1 + len(bs.list)) % len(bs.list)
 355	bs.To(c)
 356}
 357
 358func (bs *BufferList) NextUnread() {
 359	for i := 0; i < len(bs.list); i++ {
 360		c := (bs.current + i) % len(bs.list)
 361		if bs.list[c].unread && !bs.list[c].muted {
 362			bs.To(c)
 363			return
 364		}
 365	}
 366}
 367
 368func (bs *BufferList) PreviousUnread() {
 369	for i := 0; i < len(bs.list); i++ {
 370		c := (bs.current - i + len(bs.list)) % len(bs.list)
 371		if bs.list[c].unread && !bs.list[c].muted {
 372			bs.To(c)
 373			return
 374		}
 375	}
 376}
 377
 378func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) {
 379	for _, b := range bs.list {
 380		if netName == "" && b.netID == netID {
 381			netName = b.netName
 382			break
 383		}
 384	}
 385	if netName != "" {
 386		if i, b := bs.at(netID, title); b != nil {
 387			return i, false
 388		}
 389	}
 390
 391	i = 0
 392	lTitle := strings.ToLower(title)
 393	for bi, b := range bs.list {
 394		if b.pinned || b.netName < netName {
 395			i = bi + 1
 396			continue
 397		}
 398		if b.muted || b.netName > netName {
 399			break
 400		}
 401		lbTitle := strings.ToLower(b.title)
 402		if lbTitle < lTitle {
 403			i = bi + 1
 404			continue
 405		}
 406		break
 407	}
 408
 409	if i <= bs.current && bs.current < len(bs.list) {
 410		bs.current++
 411	}
 412
 413	b := buffer{
 414		netID:    netID,
 415		netName:  netName,
 416		title:    title,
 417		selected: -1,
 418	}
 419	if i == len(bs.list) {
 420		bs.list = append(bs.list, b)
 421	} else {
 422		bs.list = append(bs.list[:i+1], bs.list[i:]...)
 423		bs.list[i] = b
 424	}
 425	return i, true
 426}
 427
 428func (bs *BufferList) Remove(netID, title string) bool {
 429	idx, b := bs.at(netID, title)
 430	if b == bs.overlay {
 431		bs.overlay = nil
 432		return false
 433	}
 434	if idx < 0 {
 435		return false
 436	}
 437	updated := bs.current == idx
 438
 439	bs.clearRead(idx)
 440	bs.list = append(bs.list[:idx], bs.list[idx+1:]...)
 441	if bs.current >= idx {
 442		bs.current--
 443	}
 444	if updated {
 445		// Force refresh current buffer
 446		c := bs.current
 447		bs.current = -1
 448		bs.To(c)
 449	}
 450	return true
 451}
 452
 453func (bs *BufferList) RemoveNetwork(netID string) {
 454	updated := false
 455	for idx := 0; idx < len(bs.list); idx++ {
 456		b := &bs.list[idx]
 457		if b.netID != netID {
 458			continue
 459		}
 460		if idx == bs.current {
 461			updated = true
 462		}
 463		bs.clearRead(idx)
 464		bs.list = append(bs.list[:idx], bs.list[idx+1:]...)
 465		if bs.current >= idx {
 466			bs.current--
 467		}
 468		idx--
 469	}
 470	if updated {
 471		// Force refresh current buffer
 472		c := bs.current
 473		bs.current = -1
 474		bs.To(c)
 475	}
 476}
 477
 478func (bs *BufferList) reorder() {
 479	netID, title := bs.Current()
 480	sort.Slice(bs.list, func(i, j int) bool {
 481		bi := &bs.list[i]
 482		bj := &bs.list[j]
 483		if bi.netID == "" && bj.netID != "" {
 484			return true
 485		}
 486		if bi.netID != "" && bj.netID == "" {
 487			return false
 488		}
 489		if bi.pinned && !bj.pinned {
 490			return true
 491		}
 492		if !bi.pinned && bj.pinned {
 493			return false
 494		}
 495		if c := strings.Compare(bi.netName, bj.netName); c != 0 {
 496			return c == -1
 497		}
 498		if bi.title == "" && bj.title != "" {
 499			return true
 500		}
 501		if bi.title != "" && bj.title == "" {
 502			return false
 503		}
 504		if bi.muted && !bj.muted {
 505			return false
 506		}
 507		if !bi.muted && bj.muted {
 508			return true
 509		}
 510		bti := strings.ToLower(bi.title)
 511		btj := strings.ToLower(bj.title)
 512		return strings.Compare(bti, btj) == -1
 513	})
 514	i, _ := bs.at(netID, title)
 515	if i >= 0 {
 516		bs.current = i
 517	}
 518}
 519
 520func (bs *BufferList) mergeLine(former *Line, addition Line) (keepLine bool) {
 521	bs.ui.config.MergeLine(former, addition)
 522	if former.Body.string == "" {
 523		return false
 524	}
 525	former.width = 0
 526	former.computeSplitPoints(bs.ui.vx)
 527	return true
 528}
 529
 530func (bs *BufferList) AddLine(netID, title string, line Line) {
 531	_, b := bs.at(netID, title)
 532	if b == nil {
 533		return
 534	}
 535	current := bs.cur()
 536
 537	if line.ReplyTo != "" {
 538		line.Reply = bs.resolveReply(netID, title, line.ReplyTo)
 539	}
 540
 541	n := len(b.lines)
 542	line.At = line.At.UTC()
 543
 544	if !line.Mergeable && b.openedOnce {
 545		line.Body = line.Body.ParseURLs()
 546	}
 547
 548	if line.Mergeable && n != 0 && b.lines[n-1].Mergeable {
 549		l := &b.lines[n-1]
 550		if !bs.mergeLine(l, line) {
 551			b.lines = b.lines[:n-1]
 552		}
 553		// TODO change b.scrollAmt if it's not 0 and bs.current is idx.
 554	} else {
 555		line.computeSplitPoints(bs.ui.vx)
 556		b.lines = append(b.lines, line)
 557		if b == current && 0 < b.scrollAmt {
 558			b.scrollAmt += line.Rows(bs.ui.vx, bs.textWidth)
 559		}
 560	}
 561
 562	if b.selected >= len(b.lines) {
 563		b.selected = -1
 564	}
 565
 566	if line.Notify != NotifyNone && (!bs.focused || b != current) {
 567		b.unread = true
 568	}
 569	if line.Notify == NotifyHighlight && (!bs.focused || b != current) {
 570		b.highlights++
 571	}
 572	if b == current && b.unreadSkip == optionalUnset && len(b.lines) > 0 {
 573		if b.unreadRuler.IsZero() || !b.lines[len(b.lines)-1].At.After(b.unreadRuler) {
 574			b.unreadSkip = optionalTrue
 575		} else {
 576			b.unreadSkip = optionalFalse
 577		}
 578	}
 579}
 580
 581func (l *Line) ApplyReact(user, react string, removal bool) {
 582	for i := range l.Reacts {
 583		if l.Reacts[i].React != react {
 584			continue
 585		}
 586
 587		us := l.Reacts[i].Users
 588		for i := range us {
 589			if us[i] != user {
 590				continue
 591			}
 592
 593			if removal {
 594				us = append(us[:i], us[i+1:]...)
 595			} else {
 596				// Already reacted
 597				return
 598			}
 599			break
 600		}
 601		if !removal {
 602			us = append(us, user)
 603		}
 604		l.Reacts[i].Users = us
 605		return
 606	}
 607
 608	// Reaction does not exist in this message
 609	if removal {
 610		return
 611	}
 612	l.Reacts = append(l.Reacts, React{React: react, Users: []string{user}})
 613}
 614
 615func (bs *BufferList) ApplyReact(netID, title, id, user, react string, removal bool) {
 616	_, b := bs.at(netID, title)
 617	if b == nil {
 618		return
 619	}
 620	for i := len(b.lines) - 1; i >= 0; i-- {
 621		if b.lines[i].ID == id {
 622			b.lines[i].ApplyReact(user, react, removal)
 623			return
 624		}
 625	}
 626}
 627
 628func (bs *BufferList) resolveReply(netID, title, replyTo string) *Reply {
 629	_, b := bs.at(netID, title)
 630	if b == nil {
 631		return nil
 632	}
 633	for i := len(b.lines) - 1; i >= 0; i-- {
 634		if b.lines[i].ID == replyTo {
 635			return &Reply{
 636				Nick: b.lines[i].Head,
 637				Body: b.lines[i].Body,
 638			}
 639		}
 640	}
 641	return nil
 642}
 643
 644func (bs *BufferList) replyPreviewText(reply *Reply) StyledString {
 645	var sb StyledStringBuilder
 646	sb.WriteStyledString(ColorString("╭─ ", bs.ui.config.Colors.Gray))
 647
 648	if reply == nil {
 649		sb.WriteStyledString(ColorString("(message not found)", bs.ui.config.Colors.Gray))
 650		return sb.StyledString()
 651	}
 652
 653	sb.WriteStyledString(reply.Nick)
 654	sb.WriteStyledString(ColorString(": ", bs.ui.config.Colors.Gray))
 655
 656	snippet := strings.Map(func(r rune) rune {
 657		if r == '\n' || r == '\r' {
 658			return ' '
 659		}
 660		return r
 661	}, reply.Body.string)
 662	snippet = truncate(bs.ui.vx, snippet, max(bs.textWidth, 48), "…")
 663	sb.WriteStyledString(ColorString(snippet, bs.ui.config.Colors.Gray))
 664
 665	return sb.StyledString()
 666}
 667
 668func (bs *BufferList) reactsText(reacts []React, selected bool) StyledString {
 669	var sb StyledStringBuilder
 670	for i, r := range reacts {
 671		if i > 0 {
 672			sb.WriteStyledString(PlainString(" "))
 673		}
 674		sb.WriteStyledString(ColorString("[", bs.ui.config.Colors.Gray))
 675		sb.WriteStyledString(PlainString(r.React))
 676		sb.WriteStyledString(PlainString(" "))
 677		if selected {
 678			sb.WriteStyledString(ColorString(strings.Join(r.Users, ", "), bs.ui.config.Colors.Gray))
 679		} else {
 680			sb.WriteStyledString(ColorString(strconv.Itoa(len(r.Users)), bs.ui.config.Colors.Gray))
 681		}
 682		sb.WriteStyledString(ColorString("]", bs.ui.config.Colors.Gray))
 683	}
 684	return sb.StyledString()
 685}
 686
 687func (bs *BufferList) AddLines(netID, title string, before, after []Line) {
 688	_, b := bs.at(netID, title)
 689	if b == nil {
 690		return
 691	}
 692	updateRead := (!bs.focused || b != bs.cur()) && !b.read.IsZero()
 693
 694	lines := make([]Line, 0, len(before)+len(b.lines)+len(after))
 695	for _, buf := range []*[]Line{&before, &b.lines, &after} {
 696		for _, line := range *buf {
 697			if line.Mergeable && len(lines) > 0 && lines[len(lines)-1].Mergeable {
 698				l := &lines[len(lines)-1]
 699				if !bs.mergeLine(l, line) {
 700					lines = lines[:len(lines)-1]
 701				}
 702			} else {
 703				if buf != &b.lines {
 704					if b.openedOnce {
 705						line.Body = line.Body.ParseURLs()
 706					}
 707					line.computeSplitPoints(bs.ui.vx)
 708				}
 709				if line.ReplyTo != "" && line.Reply == nil {
 710					for j := len(lines) - 1; j >= 0; j-- {
 711						if lines[j].ID == line.ReplyTo {
 712							line.Reply = &Reply{Nick: lines[j].Head, Body: lines[j].Body}
 713							break
 714						}
 715					}
 716					if line.Reply == nil {
 717						line.Reply = bs.resolveReply(netID, title, line.ReplyTo)
 718					}
 719				}
 720				lines = append(lines, line)
 721			}
 722
 723			if updateRead && line.At.After(b.read) {
 724				if line.Notify != NotifyNone {
 725					b.unread = true
 726				}
 727				if line.Notify == NotifyHighlight {
 728					b.highlights++
 729				}
 730			}
 731		}
 732	}
 733	b.lines = lines
 734	if b.selected >= len(b.lines) {
 735		b.selected = -1
 736	}
 737	if b == bs.cur() && b.unreadSkip == optionalUnset && len(b.lines) > 0 {
 738		if b.unreadRuler.IsZero() || !b.lines[len(b.lines)-1].At.After(b.unreadRuler) {
 739			b.unreadSkip = optionalTrue
 740		} else {
 741			b.unreadSkip = optionalFalse
 742		}
 743	}
 744}
 745
 746func (bs *BufferList) Focused() bool {
 747	return bs.focused
 748}
 749
 750func (bs *BufferList) SetFocused(focused bool) {
 751	bs.focused = focused
 752	if focused {
 753		bs.clearRead(bs.current)
 754	}
 755}
 756
 757func (bs *BufferList) SetTopic(netID, title string, topic StyledString) {
 758	_, b := bs.at(netID, title)
 759	if b == nil {
 760		return
 761	}
 762	b.topic = topic
 763}
 764
 765func (bs *BufferList) GetPinned(netID, title string) bool {
 766	_, b := bs.at(netID, title)
 767	if b == nil {
 768		return false
 769	}
 770	return b.pinned
 771}
 772
 773func (bs *BufferList) SetPinned(netID, title string, pinned bool) int {
 774	_, b := bs.at(netID, title)
 775	if b == nil {
 776		return -1
 777	}
 778	b.pinned = pinned
 779	bs.reorder()
 780	i, _ := bs.at(netID, title)
 781	return i
 782}
 783
 784func (bs *BufferList) GetMuted(netID, title string) bool {
 785	_, b := bs.at(netID, title)
 786	if b == nil {
 787		return false
 788	}
 789	return b.muted
 790}
 791
 792func (bs *BufferList) SetMuted(netID, title string, muted bool) int {
 793	_, b := bs.at(netID, title)
 794	if b == nil {
 795		return -1
 796	}
 797	b.muted = muted
 798	bs.reorder()
 799	i, _ := bs.at(netID, title)
 800	return i
 801}
 802
 803func (bs *BufferList) clearRead(i int) {
 804	b := &bs.list[i]
 805	b.highlights = 0
 806	b.unread = false
 807	if len(b.notifications) > 0 {
 808		for _, id := range b.notifications {
 809			notifyClose(id)
 810		}
 811		b.notifications = b.notifications[:0]
 812	}
 813}
 814
 815func (bs *BufferList) SetRead(netID, title string, timestamp time.Time) {
 816	i, b := bs.at(netID, title)
 817	if b == nil || i < 0 {
 818		return
 819	}
 820	clearRead := true
 821	for i := len(b.lines) - 1; i >= 0; i-- {
 822		line := &b.lines[i]
 823		if !line.At.After(timestamp) {
 824			break
 825		}
 826		if line.Readable && line.Notify != NotifyNone {
 827			clearRead = false
 828			break
 829		}
 830	}
 831	if clearRead {
 832		bs.clearRead(i)
 833	}
 834	if b.read.Before(timestamp) {
 835		b.read = timestamp
 836		// For buffers that were focused _before_ we receive any "last read" date.
 837		if b.unreadRuler.IsZero() {
 838			b.unreadRuler = b.read
 839		}
 840	}
 841}
 842
 843func (bs *BufferList) UpdateRead() (netID, title string, timestamp time.Time) {
 844	b := bs.cur()
 845	var l *Line
 846	bs.forEachLine(b, func(line *Line, i, y int) bool {
 847		l = line
 848		if y >= b.scrollAmt && line.Readable {
 849			return true
 850		}
 851		return false
 852	})
 853	if l != nil && l.At.After(b.read) {
 854		b.read = l.At
 855		return b.netID, b.title, b.read
 856	}
 857	return "", "", time.Time{}
 858}
 859
 860func (bs *BufferList) Buffer(i int) (netID, title string, ok bool) {
 861	if i < 0 || i >= len(bs.list) {
 862		return
 863	}
 864	b := &bs.list[i]
 865	return b.netID, b.title, true
 866}
 867
 868func (bs *BufferList) Current() (netID, title string) {
 869	b := &bs.list[bs.current]
 870	return b.netID, b.title
 871}
 872
 873func (bs *BufferList) ScrollUp(n int) {
 874	b := bs.cur()
 875	if b.isAtTop {
 876		return
 877	}
 878	b.scrollAmt += n
 879}
 880
 881func (bs *BufferList) ScrollDown(n int) {
 882	b := bs.cur()
 883	b.scrollAmt -= n
 884
 885	if b.scrollAmt < 0 {
 886		b.scrollAmt = 0
 887	}
 888}
 889
 890func (bs *BufferList) ScrollUpHighlight() bool {
 891	b := bs.cur()
 892	ymin := b.scrollAmt + bs.tlHeight
 893	return bs.forEachLine(b, func(line *Line, i, y int) bool {
 894		if ymin <= y && line.Highlight {
 895			b.scrollAmt = y - bs.tlHeight + 1
 896			return true
 897		}
 898		return false
 899	})
 900}
 901
 902func (bs *BufferList) ScrollDownHighlight() bool {
 903	b := bs.cur()
 904	yLastHighlight := 0
 905	bs.forEachLine(b, func(line *Line, i, y int) bool {
 906		if y >= b.scrollAmt {
 907			return true
 908		}
 909		if line.Highlight {
 910			yLastHighlight = y
 911		}
 912		return false
 913	})
 914	b.scrollAmt = yLastHighlight
 915	return b.scrollAmt != 0
 916}
 917
 918func (bs *BufferList) SelectPrevious() {
 919	b := bs.cur()
 920	if len(b.lines) == 0 {
 921		return
 922	}
 923	if b.selected < 0 {
 924		b.selected = len(b.lines) - 1
 925	} else if b.selected > 0 {
 926		b.selected--
 927	}
 928	bs.scrollToSelected()
 929}
 930
 931func (bs *BufferList) SelectNext() {
 932	b := bs.cur()
 933	if b.selected < 0 {
 934		return
 935	}
 936	b.selected++
 937	if b.selected >= len(b.lines) {
 938		b.selected = -1
 939	}
 940	bs.scrollToSelected()
 941}
 942
 943func (bs *BufferList) scrollToSelected() {
 944	b := bs.cur()
 945	if b.selected < 0 {
 946		return
 947	}
 948	bs.forEachLine(b, func(line *Line, i, y int) bool {
 949		if i != b.selected {
 950			return false
 951		}
 952		rows := line.Rows(bs.ui.vx, bs.textWidth)
 953		top := y + rows - 1
 954		if top >= b.scrollAmt+bs.tlHeight {
 955			b.scrollAmt = top - bs.tlHeight + 1
 956		} else if y < b.scrollAmt {
 957			b.scrollAmt = y
 958		}
 959		return true
 960	})
 961}
 962
 963func (bs *BufferList) ClearSelection() {
 964	bs.cur().selected = -1
 965}
 966
 967func (bs *BufferList) Selected() *Line {
 968	b := bs.cur()
 969	if b.selected < 0 || b.selected >= len(b.lines) {
 970		return nil
 971	}
 972	return &b.lines[b.selected]
 973}
 974
 975func (bs *BufferList) SelectAt(y0, screenY int) {
 976	b := bs.cur()
 977	if !bs.ui.hideTopic {
 978		y0 += 2
 979	}
 980	target := b.scrollAmt + y0 + bs.tlHeight - screenY - 1
 981	idx := -1
 982	bs.forEachLine(b, func(line *Line, i, y int) bool {
 983		rows := line.Rows(bs.ui.vx, bs.textWidth)
 984		if target >= y && target < y+rows {
 985			idx = i
 986			return true
 987		}
 988		return false
 989	})
 990	if idx >= 0 {
 991		bs.cur().selected = idx
 992	}
 993}
 994
 995func (bs *BufferList) ScrollTopicLeft(n int) {
 996	b := bs.cur()
 997	b.topicOffset -= n
 998
 999	if b.topicOffset < 0 {
1000		b.topicOffset = 0
1001	}
1002}
1003
1004func (bs *BufferList) ScrollTopicRight(n int) {
1005	b := bs.cur()
1006	b.topicOffset += n
1007}
1008
1009// LinesAboveOffset returns a rough approximate of the number of lines
1010// above the offset (that is, starting from the bottom of the screen,
1011// up to the first line).
1012func (bs *BufferList) LinesAboveOffset() int {
1013	b := bs.cur()
1014	return len(b.lines) - b.scrollAmt
1015}
1016
1017func (bs *BufferList) Highlights() int {
1018	n := 0
1019	for _, b := range bs.list {
1020		n += b.highlights
1021	}
1022	return n
1023}
1024
1025func (bs *BufferList) at(netID, title string) (int, *buffer) {
1026	if netID == "" && title == Overlay {
1027		return -1, bs.overlay
1028	}
1029	lTitle := strings.ToLower(title)
1030	for i, b := range bs.list {
1031		if b.netID == netID && strings.ToLower(b.title) == lTitle {
1032			return i, &bs.list[i]
1033		}
1034	}
1035	return -1, nil
1036}
1037
1038func (bs *BufferList) cur() *buffer {
1039	if bs.overlay != nil {
1040		return bs.overlay
1041	}
1042	return &bs.list[bs.current]
1043}
1044
1045func (bs *BufferList) forEachLine(b *buffer, f func(line *Line, i, y int) bool) bool {
1046	rulerDrawn := b.unreadSkip != optionalFalse || b.unreadRuler.IsZero() || b.title == ""
1047	y := 0
1048	for i := len(b.lines) - 1; 0 <= i; i-- {
1049		line := &b.lines[i]
1050		if !rulerDrawn && !line.At.After(b.unreadRuler) {
1051			rulerDrawn = true
1052			y++
1053		}
1054		if f(line, i, y) {
1055			return true
1056		}
1057		y += line.Rows(bs.ui.vx, bs.textWidth)
1058	}
1059	return false
1060}
1061
1062func (bs *BufferList) DrawVerticalBufferList(vx *Vaxis, x0, y0, width, height int, offset *int) {
1063	if y0+len(bs.list)-*offset < height {
1064		*offset = y0 + len(bs.list) - height
1065		if *offset < 0 {
1066			*offset = 0
1067		}
1068	}
1069	off := bs.VerticalBufferOffset(0, *offset)
1070	if off < 0 {
1071		off = len(bs.list)
1072	}
1073
1074	width--
1075	bs.ui.drawVerticalLine(vx, x0+width, y0, height)
1076	clearArea(vx, x0, y0, width, height)
1077
1078	indexPadding := 1 + int(math.Ceil(math.Log10(float64(len(bs.list)))))
1079	y := y0
1080	for i, b := range bs.list[off:] {
1081		bi := off + i
1082		x := x0
1083		var st vaxis.Style
1084		if b.unread && !b.muted {
1085			st.Attribute |= vaxis.AttrBold
1086			st.Foreground = bs.ui.config.Colors.Unread
1087		}
1088		if bi == bs.current || bi == bs.clicked {
1089			st.Attribute |= vaxis.AttrReverse
1090		} else if b.muted {
1091			st.Foreground = bs.ui.config.Colors.Gray
1092		}
1093
1094		var title string
1095		if b.title == "" {
1096			title = b.netName
1097		} else {
1098			title = b.title
1099		}
1100
1101		if bs.filterBuffers {
1102			if !strings.Contains(strings.ToLower(title), bs.filterBuffersQuery) {
1103				continue
1104			}
1105			indexSt := st
1106			indexSt.Foreground = bs.ui.config.Colors.Gray
1107			indexText := fmt.Sprintf("%d:", bi+1)
1108			printString(vx, &x, y, Styled(indexText, indexSt))
1109			x = x0 + indexPadding
1110		}
1111
1112		if b.title != "" {
1113			var st vaxis.Style
1114			if bi == bs.current || bi == bs.clicked {
1115				st.Attribute |= vaxis.AttrReverse
1116			}
1117			if b.pinned {
1118				st.Attribute |= vaxis.AttrBold
1119				setCell(vx, x, y, '⚲', st)
1120				setCell(vx, x+1, y, ' ', st)
1121			} else {
1122				setCell(vx, x, y, ' ', st)
1123				setCell(vx, x+1, y, ' ', st)
1124			}
1125			x += 2
1126		}
1127		title = truncate(vx, title, width-(x-x0), "\u2026")
1128		printString(vx, &x, y, Styled(title, st))
1129
1130		if bi == bs.current || bi == bs.clicked {
1131			st := vaxis.Style{
1132				Attribute: vaxis.AttrReverse,
1133			}
1134			for ; x < x0+width; x++ {
1135				setCell(vx, x, y, ' ', st)
1136			}
1137			setCell(vx, x, y, ' ', st)
1138			setCell(vx, x, y, '▐', st)
1139		}
1140
1141		if b.highlights != 0 {
1142			highlightSt := st
1143			highlightSt.Foreground = ColorRed
1144			highlightSt.Attribute |= vaxis.AttrReverse
1145			highlightText := fmt.Sprintf(" %d ", b.highlights)
1146			x = x0 + width - len(highlightText)
1147			printString(vx, &x, y, Styled(highlightText, highlightSt))
1148		}
1149
1150		y++
1151	}
1152}
1153
1154func (bs *BufferList) HorizontalBufferOffset(x int, offset int) int {
1155	if bs.filterBuffers {
1156		offset = 0
1157	}
1158	i := 0
1159	for bi, b := range bs.list[offset:] {
1160		if bs.filterBuffers {
1161			var title string
1162			if b.title == "" {
1163				title = b.netName
1164			} else {
1165				title = b.title
1166			}
1167			if !strings.Contains(strings.ToLower(title), bs.filterBuffersQuery) {
1168				continue
1169			}
1170		}
1171		if i > 0 {
1172			x--
1173			if x < 0 {
1174				return -1
1175			}
1176		}
1177		x -= bs.bufferWidth(&b)
1178		if x < 0 {
1179			return offset + bi
1180		}
1181		i++
1182	}
1183	return -1
1184}
1185
1186func (bs *BufferList) VerticalBufferOffset(y int, offset int) int {
1187	if !bs.filterBuffers {
1188		return offset + y
1189	}
1190
1191	for i, b := range bs.list {
1192		var title string
1193		if b.title == "" {
1194			title = b.netName
1195		} else {
1196			title = b.title
1197		}
1198
1199		if bs.filterBuffers {
1200			if !strings.Contains(strings.ToLower(title), bs.filterBuffersQuery) {
1201				continue
1202			}
1203		}
1204		if y == 0 {
1205			return i
1206		}
1207		y--
1208	}
1209	return -1
1210}
1211
1212func (bs *BufferList) GetLeftMost(screenWidth int) int {
1213	if len(bs.list) == 0 {
1214		return 0
1215	}
1216
1217	width := 0
1218	var leftMost int
1219
1220	for leftMost = bs.current; leftMost >= 0; leftMost-- {
1221		if leftMost < bs.current {
1222			width++
1223		}
1224		width += bs.bufferWidth(&bs.list[leftMost])
1225		if width > screenWidth {
1226			return leftMost + 1 // Went offscreen, need to go one step back
1227		}
1228	}
1229
1230	return 0
1231}
1232
1233func (bs *BufferList) bufferWidth(b *buffer) int {
1234	width := 0
1235	if b.title == "" {
1236		width += stringWidth(bs.ui.vx, b.netName)
1237	} else {
1238		width += stringWidth(bs.ui.vx, b.title)
1239	}
1240	if 0 < b.highlights {
1241		width += 2 + len(fmt.Sprintf("%d", b.highlights))
1242	}
1243	return width
1244}
1245
1246func (bs *BufferList) shouldShowDate(b *buffer, i int, yi int, y0 int) bool {
1247	if i == 0 || yi <= y0 {
1248		return true
1249	}
1250	yb, mb, dd := b.lines[i-1].At.Local().Date()
1251	ya, ma, da := b.lines[i].At.Local().Date()
1252	return yb != ya || mb != ma || dd != da
1253}
1254
1255func (bs *BufferList) DrawHorizontalBufferList(vx *Vaxis, x0, y0, width int, offset *int) {
1256	x := width
1257	for i := len(bs.list) - 1; i >= 0; i-- {
1258		b := &bs.list[i]
1259		x--
1260		x -= bs.bufferWidth(b)
1261		if x <= 10 {
1262			break
1263		}
1264		if *offset > i {
1265			*offset = i
1266		}
1267	}
1268	x = x0
1269
1270	off := bs.HorizontalBufferOffset(0, *offset)
1271	if off < 0 {
1272		off = len(bs.list)
1273	}
1274
1275	for i, b := range bs.list[off:] {
1276		i := i + off
1277		if width <= x-x0 {
1278			break
1279		}
1280		var st vaxis.Style
1281		if b.unread && !b.muted {
1282			st.Attribute |= vaxis.AttrBold
1283			st.Foreground = bs.ui.config.Colors.Unread
1284		} else if i == bs.current {
1285			st.UnderlineStyle = vaxis.UnderlineSingle
1286		}
1287		if i == bs.clicked {
1288			st.Attribute |= vaxis.AttrReverse
1289		} else if b.muted {
1290			st.Foreground = bs.ui.config.Colors.Gray
1291		}
1292
1293		var title string
1294		if b.title == "" {
1295			st.Attribute |= vaxis.AttrDim
1296			title = b.netName
1297		} else {
1298			title = b.title
1299		}
1300
1301		if bs.filterBuffers {
1302			if !strings.Contains(strings.ToLower(title), bs.filterBuffersQuery) {
1303				continue
1304			}
1305		}
1306
1307		title = truncate(vx, title, width-x, "\u2026")
1308		printString(vx, &x, y0, Styled(title, st))
1309
1310		if 0 < b.highlights {
1311			st.Foreground = ColorRed
1312			st.Attribute |= vaxis.AttrReverse
1313			setCell(vx, x, y0, ' ', st)
1314			x++
1315			printNumber(vx, &x, y0, st, b.highlights)
1316			setCell(vx, x, y0, ' ', st)
1317			x++
1318		}
1319		setCell(vx, x, y0, ' ', vaxis.Style{})
1320		x++
1321	}
1322	for x < width {
1323		setCell(vx, x, y0, ' ', vaxis.Style{})
1324		x++
1325	}
1326}
1327
1328func (bs *BufferList) DrawTopic(ui *UI, x0, y0 int) {
1329	// TODO: factorize this (same code for drawing timeline)
1330	vx := ui.vx
1331	b := bs.cur()
1332
1333	var st vaxis.Style
1334	nextStyles := b.topic.styles
1335
1336	sr := []rune(b.topic.string)
1337	ri := 0
1338	for i := 0; i < b.topicOffset; i++ {
1339		s, _ := firstCluster(bs.ui.vx, sr[ri:])
1340		ri += len([]rune(s))
1341	}
1342	i := len(string(sr[:ri]))
1343	sr = sr[ri:]
1344	for len(sr) > 0 {
1345		if 0 < len(nextStyles) && nextStyles[0].Start == i {
1346			st = nextStyles[0].Style
1347			nextStyles = nextStyles[1:]
1348
1349			if (bs.ui.mouseLinks || st.Hyperlink == "") && st.HyperlinkParams != "" && st.UnderlineStyle == 0 {
1350				st.UnderlineStyle = vaxis.UnderlineDotted
1351			}
1352		}
1353		dx, di := printCluster(vx, x0, y0, -1, sr, st)
1354		x0 += dx
1355		i += len(string(sr[:di]))
1356		sr = sr[di:]
1357
1358		if st.Hyperlink != "" {
1359			ui.clickEvents = append(ui.clickEvents, clickEvent{
1360				xb: x0 - dx,
1361				xe: x0,
1362				y:  y0,
1363				event: &events.EventClickLink{
1364					EventClick: events.EventClick{
1365						NetID:  b.netID,
1366						Buffer: b.title,
1367					},
1368					Link:  st.Hyperlink,
1369					Mouse: ui.mouseLinks,
1370				},
1371			})
1372		} else if _, channel, ok := strings.Cut(st.HyperlinkParams, "="); ok {
1373			ui.clickEvents = append(ui.clickEvents, clickEvent{
1374				xb: x0 - dx,
1375				xe: x0,
1376				y:  y0,
1377				event: &events.EventClickChannel{
1378					EventClick: events.EventClick{
1379						NetID:  b.netID,
1380						Buffer: b.title,
1381					},
1382					Channel: channel,
1383				},
1384			})
1385		}
1386	}
1387}
1388
1389func (bs *BufferList) DrawTimeline(ui *UI, x0, y0, nickColWidth int) {
1390	vx := ui.vx
1391	clearArea(vx, x0, y0, bs.tlInnerWidth+nickColWidth+9, bs.tlHeight+2)
1392
1393	b := bs.cur()
1394	if !b.openedOnce {
1395		b.openedOnce = true
1396		for i := 0; i < len(b.lines); i++ {
1397			b.lines[i].Body = b.lines[i].Body.ParseURLs()
1398		}
1399	}
1400
1401	for b.topicOffset > 0 {
1402		sr := []rune(b.topic.string)
1403		ri := 0
1404		for i := 0; i < b.topicOffset; i++ {
1405			s, _ := firstCluster(bs.ui.vx, sr[ri:])
1406			ri += len([]rune(s))
1407		}
1408		w := stringWidth(bs.ui.vx, string(sr[ri:]))
1409		if w <= bs.tlInnerWidth+nickColWidth+9-16 {
1410			b.topicOffset -= 12
1411			if b.topicOffset < 0 {
1412				b.topicOffset = 0
1413			}
1414		} else {
1415			break
1416		}
1417	}
1418
1419	if !ui.hideTopic {
1420		bs.DrawTopic(ui, x0, y0)
1421		y0++
1422		bs.ui.drawHorizontalLine(vx, x0, y0, bs.tlInnerWidth+nickColWidth+9)
1423		y0++
1424	}
1425
1426	if bs.textWidth < bs.tlInnerWidth {
1427		x0 += (bs.tlInnerWidth - bs.textWidth) / 2
1428	}
1429
1430	yi := b.scrollAmt + y0 + bs.tlHeight
1431	rulerDrawn := b.unreadSkip != optionalFalse || b.unreadRuler.IsZero() || b.title == ""
1432	for i := len(b.lines) - 1; 0 <= i; i-- {
1433		if yi < y0 {
1434			break
1435		}
1436
1437		x1 := x0 + 9 + nickColWidth
1438
1439		line := &b.lines[i]
1440		nls := line.NewLines(bs.ui.vx, bs.textWidth)
1441		selected := i == b.selected
1442
1443		if !rulerDrawn {
1444			isRead := !line.At.After(b.unreadRuler)
1445			if isRead && yi > y0 {
1446				yi--
1447				st := vaxis.Style{
1448					Foreground: bs.ui.config.Colors.Gray,
1449				}
1450				printIdent(vx, x0+7, yi, nickColWidth, Styled("--", st))
1451				bs.ui.drawHorizontalLine(vx, x0, yi, 9+nickColWidth+bs.tlInnerWidth)
1452				rulerDrawn = true
1453			}
1454		}
1455
1456		yi -= len(nls) + 1
1457		if line.Reply != nil {
1458			yi--
1459		}
1460		if len(line.Reacts) > 0 {
1461			yi--
1462		}
1463		if y0+bs.tlHeight <= yi {
1464			continue
1465		}
1466
1467		cY := yi
1468		if line.Reply != nil {
1469			cY++
1470			if yi >= y0 {
1471				preview := bs.replyPreviewText(line.Reply)
1472				preview.string = truncate(bs.ui.vx, preview.string,
1473					nickColWidth+2+bs.textWidth, "…")
1474				x := x0 + nickColWidth + 6
1475				printString(vx, &x, yi, preview)
1476			}
1477		}
1478
1479		showDate := bs.shouldShowDate(b, i, cY, y0)
1480		if showDate {
1481			st := vaxis.Style{
1482				Attribute: vaxis.AttrBold,
1483			}
1484			if selected {
1485				st.Attribute |= vaxis.AttrReverse
1486			}
1487			// as a special case, always draw the first visible message date, even if it is a continuation line
1488			yd := cY
1489			if yd < y0 {
1490				yd = y0
1491			}
1492			printDate(vx, x0, yd, st, line.At.Local())
1493		} else {
1494			showTime := b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) && cY >= y0
1495			if !showTime {
1496				// also try to show the time if we previously drew the date
1497				yp := cY - b.lines[i-1].Rows(bs.ui.vx, bs.textWidth)
1498				showTime = i == 0 || bs.shouldShowDate(b, i-1, yp, y0)
1499			}
1500			if showTime || selected {
1501				st := vaxis.Style{
1502					Foreground: bs.ui.config.Colors.Gray,
1503				}
1504				if selected {
1505					st.Attribute |= vaxis.AttrReverse
1506				}
1507				printTime(vx, x0, cY, st, line.At.Local())
1508			}
1509		}
1510
1511		if cY >= y0 {
1512			head := line.Head
1513			if line.Highlight && len(line.Head.styles) > 0 {
1514				var sb StyledStringBuilder
1515				sb.WriteString(head.string)
1516				for _, st := range line.Head.styles {
1517					s := st.Style
1518					s.Attribute |= vaxis.AttrReverse
1519					sb.AddStyle(st.Start, s)
1520				}
1521				head = sb.StyledString()
1522			}
1523			xb, xe := printIdent(vx, x0+7, cY, nickColWidth, head)
1524
1525			lastHead := line.Head.string
1526			if len(line.Head.styles) > 0 {
1527				lastHead = lastHead[line.Head.styles[len(line.Head.styles)-1].Start:]
1528			}
1529
1530			if !strings.HasSuffix(lastHead, "--") && !strings.HasSuffix(lastHead, "!!") && lastHead != "*" {
1531				ui.clickEvents = append(ui.clickEvents, clickEvent{
1532					xb: xb,
1533					xe: xe,
1534					y:  cY,
1535					event: &events.EventClickNick{
1536						EventClick: events.EventClick{
1537							NetID:  b.netID,
1538							Buffer: b.title,
1539						},
1540						Nick: lastHead,
1541					},
1542				})
1543			}
1544		}
1545
1546		x := x1
1547		y := cY
1548		var style vaxis.Style
1549		nextStyles := line.Body.styles
1550
1551		lbi := 0
1552		l := []rune(line.Body.string)
1553		for len(l) > 0 {
1554			if 0 < len(nextStyles) && nextStyles[0].Start == lbi {
1555				style = nextStyles[0].Style
1556				nextStyles = nextStyles[1:]
1557
1558				if (bs.ui.mouseLinks || style.Hyperlink == "") && style.HyperlinkParams != "" && style.UnderlineStyle == 0 {
1559					style.UnderlineStyle = vaxis.UnderlineDotted
1560				}
1561			}
1562			if 0 < len(nls) && lbi == nls[0] {
1563				x = x1
1564				y++
1565				nls = nls[1:]
1566				if y0+bs.tlHeight <= y {
1567					break
1568				}
1569			}
1570
1571			if y != cY && x == x1 && IsSplitRune(l[0]) {
1572				lbi += len(string(l[0]))
1573				l = l[1:]
1574				continue
1575			}
1576
1577			xb := x
1578			if y >= y0 {
1579				drawStyle := style
1580				if selected {
1581					drawStyle.Attribute |= vaxis.AttrReverse
1582				}
1583				dx, di := printCluster(vx, x, y, -1, l, drawStyle)
1584				x += dx
1585				lbi += len(string(l[:di]))
1586				l = l[di:]
1587			} else {
1588				c, cw := firstCluster(vx, l)
1589				x += cw
1590				lbi += len(c)
1591				l = l[len([]rune(c)):]
1592			}
1593
1594			if style.Hyperlink != "" {
1595				ui.clickEvents = append(ui.clickEvents, clickEvent{
1596					xb: xb,
1597					xe: x,
1598					y:  y,
1599					event: &events.EventClickLink{
1600						EventClick: events.EventClick{
1601							NetID:  b.netID,
1602							Buffer: b.title,
1603						},
1604						Link:  style.Hyperlink,
1605						Mouse: ui.mouseLinks,
1606					},
1607				})
1608			} else if _, channel, ok := strings.Cut(style.HyperlinkParams, "="); ok {
1609				ui.clickEvents = append(ui.clickEvents, clickEvent{
1610					xb: xb,
1611					xe: x,
1612					y:  y,
1613					event: &events.EventClickChannel{
1614						EventClick: events.EventClick{
1615							NetID:  b.netID,
1616							Buffer: b.title,
1617						},
1618						Channel: channel,
1619					},
1620				})
1621			}
1622		}
1623
1624		if len(line.Reacts) == 0 {
1625			continue
1626		}
1627		y++
1628		if y >= y0 && y < y0+bs.tlHeight {
1629			reacts := bs.reactsText(line.Reacts, selected)
1630			reacts.string = truncate(bs.ui.vx, reacts.string, bs.textWidth, "…")
1631			x := x1
1632			printString(vx, &x, y, reacts)
1633		}
1634	}
1635
1636	b.isAtTop = y0 <= yi
1637}