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}