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}