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