1package ui
2
3import (
4 "fmt"
5 "os"
6 "reflect"
7 "runtime"
8 "strings"
9 "sync/atomic"
10 "time"
11
12 "git.sr.ht/~rockorager/vaxis"
13 "github.com/containerd/console"
14
15 "git.sr.ht/~delthas/senpai/events"
16 "git.sr.ht/~delthas/senpai/irc"
17)
18
19type Config struct {
20 NickColWidth int
21 ChanColWidth int
22 ChanColEnabled bool
23 MemberColWidth int
24 MemberColEnabled bool
25 TextMaxWidth int
26 AutoComplete func(cursorIdx int, text []rune) []Completion
27 Mouse bool
28 Buttons bool
29 MergeLine func(former *Line, addition Line)
30 Colors ConfigColors
31 LocalIntegrations bool
32 WithConsole console.Console
33 WithTTY string
34}
35
36type ConfigColors struct {
37 Gray vaxis.Color
38 Status vaxis.Color
39 Prompt vaxis.Color
40 Unread vaxis.Color
41 Nicks ColorScheme
42}
43
44type Vaxis struct {
45 *vaxis.Vaxis
46 window vaxis.Window
47 xPixel int
48 yPixel int
49}
50
51type NotifyEvent struct {
52 NetID string
53 Buffer string
54}
55
56type ScreenshotEvent struct {
57 Path string
58}
59
60type clickEvent struct {
61 xb int
62 xe int
63 y int
64 event interface{}
65}
66
67type UI struct {
68 vx *Vaxis
69 Events chan any
70 exit atomic.Value // bool
71 config Config
72
73 bs BufferList
74 e Editor
75 prompt StyledString
76 status string
77 title string
78 overlayHint string
79
80 channelOffset int
81 memberClicked int
82 memberOffset int
83 hideTopic bool
84
85 channelWidth int
86 memberWidth int
87
88 channelColClicked bool
89 memberColClicked bool
90
91 clickEvents []clickEvent
92
93 mouseLinks bool
94
95 colorThemeMode vaxis.ColorThemeMode
96}
97
98func New(config Config) (ui *UI, colors ConfigColors, err error) {
99 ui = &UI{
100 config: config,
101 clickEvents: make([]clickEvent, 0, 128),
102 memberClicked: -1,
103 }
104 if config.ChanColEnabled {
105 ui.channelWidth = config.ChanColWidth
106 }
107 if config.MemberColEnabled {
108 ui.memberWidth = config.MemberColWidth
109 }
110
111 if runtime.GOOS == "windows" {
112 // Work around broken RGB colors on Windows Terminal.
113 // Sadly the Windows Terminal does not support TerminalID, so we rely on GOOS here.
114 if os.Getenv("COLORTERM") == "" {
115 os.Setenv("COLORTERM", "truecolor")
116 }
117 if os.Getenv("VAXIS_FORCE_LEGACY_SGR") == "" {
118 os.Setenv("VAXIS_FORCE_LEGACY_SGR", "true")
119 }
120 }
121 var vx *vaxis.Vaxis
122 opts := vaxis.Options{
123 DisableMouse: !config.Mouse,
124 CSIuBitMask: vaxis.CSIuDisambiguate | vaxis.CSIuReportEvents | vaxis.CSIuAlternateKeys | vaxis.CSIuAssociatedText, // | vaxis.CSIuAllKeys, // ,
125 WithTTY: config.WithTTY,
126 WithConsole: config.WithConsole,
127 }
128 vx, err = vaxis.New(opts)
129 if err != nil {
130 return
131 }
132 restart := true
133 switch {
134 case strings.HasPrefix(vx.TerminalID(), "iTerm2"):
135 // see: https://gitlab.com/gnachman/iterm2/-/issues/12177
136 opts.CSIuBitMask = vaxis.CSIuDisambiguate | vaxis.CSIuReportEvents | vaxis.CSIuAlternateKeys
137 case strings.HasPrefix(vx.TerminalID(), "ghostty"):
138 // see: https://github.com/ghostty-org/ghostty/discussions/10026
139 opts.CSIuBitMask = vaxis.CSIuDisambiguate | vaxis.CSIuReportEvents | vaxis.CSIuAlternateKeys | vaxis.CSIuAssociatedText
140 default:
141 restart = false
142 }
143 if restart {
144 vx.Close()
145 vx, err = vaxis.New(opts)
146 if err != nil {
147 return
148 }
149 }
150 ui.vx = &Vaxis{
151 Vaxis: vx,
152 window: vx.Window(),
153 }
154
155 bg := ui.vx.QueryBackground().Params()
156 if len(bg) == 3 {
157 if (int(bg[0])+int(bg[1])+int(bg[2]))/3 > 127 {
158 ui.colorThemeMode = vaxis.LightMode
159 } else {
160 ui.colorThemeMode = vaxis.DarkMode
161 }
162 } else {
163 ui.colorThemeMode = vaxis.DarkMode
164 }
165
166 ui.config.Colors.Gray = vaxis.IndexColor(8)
167 black := ui.vx.QueryColor(vaxis.IndexColor(uint8(0))).Params()
168 gray := ui.vx.QueryColor(vaxis.IndexColor(uint8(8))).Params()
169 white := ui.vx.QueryColor(vaxis.IndexColor(uint8(15))).Params()
170 fg := ui.vx.QueryForeground().Params()
171 if len(bg) == 3 && len(fg) == 3 && ui.vx.CanRGB() {
172 // Interpolate gray from fg and bg to make it slightly more readable against the background than default gray.
173 p := make([]uint8, 3)
174 for i := range p {
175 p[i] = uint8((int(bg[i])*3 + int(fg[i])*2) / 5)
176 }
177 ui.config.Colors.Gray = vaxis.RGBColor(p[0], p[1], p[2])
178 } else if len(bg) == 3 && len(gray) == 3 && reflect.DeepEqual(bg, gray) {
179 // RGB is not supported.
180 // Color theme with background set to gray: gray would be invisible.
181 if len(black) == 3 && !reflect.DeepEqual(bg, black) {
182 // Black is distinct from background: use that as gray.
183 ui.config.Colors.Gray = vaxis.IndexColor(0)
184 } else if len(white) == 3 && !reflect.DeepEqual(bg, white) {
185 // White is distinct from background: use that as white.
186 ui.config.Colors.Gray = vaxis.IndexColor(15)
187 } else {
188 // Black == gray == background == white. Give up.
189 ui.config.Colors.Gray = ColorDefault
190 }
191 }
192 if ui.config.Colors.Status == ColorDefault {
193 ui.config.Colors.Status = ui.config.Colors.Gray
194 }
195
196 ui.vx.SetTitle("senpai")
197 ui.vx.SetAppID("senpai")
198
199 _, h := ui.vx.window.Size()
200 ui.vx.window.Clear()
201 ui.vx.ShowCursor(0, h-2, vaxis.CursorBeam)
202
203 ui.mouseLinks = ui.config.LocalIntegrations && strings.HasPrefix(ui.vx.TerminalID(), "foot")
204
205 ui.exit.Store(false)
206
207 ui.Events = make(chan any, 128)
208 go func() {
209 for !ui.ShouldExit() {
210 ev := ui.vx.PollEvent()
211 if _, ok := ev.(vaxis.QuitEvent); ok {
212 ui.Exit()
213 break
214 }
215 ui.Events <- ev
216 }
217 close(ui.Events)
218 }()
219
220 ui.bs = NewBufferList(ui)
221 ui.e = NewEditor(ui)
222 ui.Resize()
223
224 return ui, ui.config.Colors, nil
225}
226
227func (ui *UI) ShouldExit() bool {
228 return ui.exit.Load().(bool)
229}
230
231func (ui *UI) Exit() {
232 ui.exit.Store(true)
233}
234
235func (ui *UI) Close() {
236 ui.vx.Refresh() // TODO is this needed?
237 ui.vx.Close()
238}
239
240func (ui *UI) Buffer(i int) (netID, title string, ok bool) {
241 return ui.bs.Buffer(i)
242}
243
244func (ui *UI) CurrentBuffer() (netID, title string) {
245 return ui.bs.Current()
246}
247
248func (ui *UI) NextBuffer() {
249 ui.bs.Next()
250 ui.memberOffset = 0
251}
252
253func (ui *UI) PreviousBuffer() {
254 ui.bs.Previous()
255 ui.memberOffset = 0
256}
257
258func (ui *UI) NextUnreadBuffer() {
259 ui.bs.NextUnread()
260 ui.memberOffset = 0
261}
262
263func (ui *UI) PreviousUnreadBuffer() {
264 ui.bs.PreviousUnread()
265 ui.memberOffset = 0
266}
267
268func (ui *UI) ClickedBuffer() int {
269 return ui.bs.clicked
270}
271
272func (ui *UI) ClickBuffer(i int) {
273 ui.bs.clicked = i
274}
275
276func (ui *UI) ClickChannelCol(v bool) {
277 ui.channelColClicked = v
278}
279
280func (ui *UI) ChannelColClicked() bool {
281 return ui.channelColClicked
282}
283
284func (ui *UI) ResizeChannelCol(x int) {
285 if x < 6 {
286 x = 6
287 } else if x > 24 {
288 x = 24
289 }
290 if ui.channelWidth == x {
291 return
292 }
293 ui.channelWidth = x
294 ui.Resize()
295}
296
297func (ui *UI) ClickMemberCol(v bool) {
298 ui.memberColClicked = v
299}
300
301func (ui *UI) MemberColClicked() bool {
302 return ui.memberColClicked
303}
304
305func (ui *UI) ResizeMemberCol(x int) {
306 if x < 6 {
307 x = 6
308 } else if x > 24 {
309 x = 24
310 }
311 if ui.memberWidth == x {
312 return
313 }
314 ui.memberWidth = x
315 ui.Resize()
316}
317
318func (ui *UI) GoToBufferNo(i int) {
319 if ui.bs.To(i) {
320 ui.memberOffset = 0
321 ui.ScrollToBuffer()
322 }
323}
324
325func (ui *UI) FilterBuffers(enable bool, query string) {
326 ui.bs.FilterBuffers(enable, query)
327}
328
329func (ui *UI) ClickedMember() int {
330 return ui.memberClicked
331}
332
333func (ui *UI) ClickMember(i int) {
334 ui.memberClicked = i
335}
336
337func (ui *UI) SelectMessagePrevious() {
338 ui.bs.SelectPrevious()
339}
340
341func (ui *UI) SelectMessageNext() {
342 ui.bs.SelectNext()
343}
344
345func (ui *UI) ClearMessageSelection() {
346 ui.bs.ClearSelection()
347}
348
349func (ui *UI) SelectMessageAt(y int) {
350 ui.bs.SelectAt(0, y)
351}
352
353func (ui *UI) SelectedMessage() *Line {
354 return ui.bs.Selected()
355}
356
357func (ui *UI) CopySelectedMessage() {
358 if line := ui.bs.Selected(); line != nil {
359 ui.vx.ClipboardPush(line.Body.string)
360 }
361}
362
363func (ui *UI) Click(x, y int, event vaxis.Mouse) {
364 for _, ev := range ui.clickEvents {
365 if x >= ev.xb && x < ev.xe && y == ev.y {
366 e := ev.event
367 e.(events.EventClickSetEvent).SetEvent(event)
368 ui.Events <- e
369 break
370 }
371 }
372}
373
374func (ui *UI) HasEvent(x, y int) bool {
375 for _, ev := range ui.clickEvents {
376 if x >= ev.xb && x < ev.xe && y == ev.y {
377 return true
378 }
379 }
380 return false
381}
382
383func (ui *UI) ScrollUp() {
384 ui.bs.ScrollUp(ui.bs.tlHeight / 2)
385}
386
387func (ui *UI) ScrollDown() {
388 ui.bs.ScrollDown(ui.bs.tlHeight / 2)
389}
390
391func (ui *UI) ScrollUpBy(n int) {
392 ui.bs.ScrollUp(n)
393}
394
395func (ui *UI) ScrollDownBy(n int) {
396 ui.bs.ScrollDown(n)
397}
398
399func (ui *UI) ScrollUpHighlight() bool {
400 return ui.bs.ScrollUpHighlight()
401}
402
403func (ui *UI) ScrollDownHighlight() bool {
404 return ui.bs.ScrollDownHighlight()
405}
406
407func (ui *UI) ScrollChannelUpBy(n int) {
408 ui.channelOffset -= n
409 if ui.channelOffset < 0 {
410 ui.channelOffset = 0
411 }
412}
413
414func (ui *UI) ScrollChannelDownBy(n int) {
415 ui.channelOffset += n
416 if ui.channelOffset > len(ui.bs.list) {
417 ui.channelOffset = len(ui.bs.list)
418 }
419}
420
421func (ui *UI) HorizontalBufferOffset(x int) int {
422 return ui.bs.HorizontalBufferOffset(x, ui.channelOffset)
423}
424
425func (ui *UI) VerticalBufferOffset(y int) int {
426 return ui.bs.VerticalBufferOffset(y, ui.channelOffset)
427}
428
429func (ui *UI) MemberOffset() int {
430 return ui.memberOffset
431}
432
433func (ui *UI) ChannelWidth() int {
434 return ui.channelWidth
435}
436
437func (ui *UI) MemberWidth() int {
438 return ui.memberWidth
439}
440
441func (ui *UI) ToggleTopic() {
442 ui.hideTopic = !ui.hideTopic
443 ui.Resize()
444}
445
446func (ui *UI) ToggleChannelList() {
447 if ui.channelWidth == 0 {
448 ui.channelWidth = ui.config.ChanColWidth
449 } else {
450 ui.channelWidth = 0
451 }
452 ui.Resize()
453}
454
455func (ui *UI) ToggleMemberList() {
456 if ui.memberWidth == 0 {
457 ui.memberWidth = ui.config.MemberColWidth
458 } else {
459 ui.memberWidth = 0
460 }
461 ui.Resize()
462}
463
464func (ui *UI) ScrollMemberUpBy(n int) {
465 ui.memberOffset -= n
466 if ui.memberOffset < 0 {
467 ui.memberOffset = 0
468 }
469}
470
471func (ui *UI) ScrollMemberDownBy(n int) {
472 ui.memberOffset += n
473}
474
475func (ui *UI) ScrollTopicLeftBy(n int) {
476 ui.bs.ScrollTopicLeft(n)
477}
478
479func (ui *UI) ScrollTopicRightBy(n int) {
480 ui.bs.ScrollTopicRight(n)
481}
482
483func (ui *UI) LinesAboveOffset() int {
484 return ui.bs.LinesAboveOffset()
485}
486
487func (ui *UI) OpenOverlay(hint string) {
488 ui.bs.OpenOverlay()
489 ui.overlayHint = hint
490}
491
492func (ui *UI) CloseOverlay() {
493 ui.bs.CloseOverlay()
494}
495
496func (ui *UI) HasOverlay() bool {
497 return ui.bs.HasOverlay()
498}
499
500func (ui *UI) AddBuffer(netID, netName, title string) (i int, added bool) {
501 i, added = ui.bs.Add(netID, netName, title)
502 if added {
503 ui.ScrollToBuffer()
504 }
505 return
506}
507
508func (ui *UI) RemoveBuffer(netID, title string) {
509 _ = ui.bs.Remove(netID, title)
510 ui.memberOffset = 0
511}
512
513func (ui *UI) RemoveNetworkBuffers(netID string) {
514 ui.bs.RemoveNetwork(netID)
515 ui.memberOffset = 0
516}
517
518func (ui *UI) AddLine(netID, buffer string, line Line) {
519 ui.bs.AddLine(netID, buffer, line)
520
521 curNetID, curBuffer := ui.bs.Current()
522 _, b := ui.bs.at(netID, buffer)
523 focused := ui.bs.focused && curNetID == netID && curBuffer == buffer
524 if b != nil && line.Notify == NotifyHighlight && !focused {
525 var header string
526 if buffer != line.Head.String() {
527 header = fmt.Sprintf("%s — %s", buffer, line.Head.String())
528 } else {
529 header = line.Head.String()
530 }
531 id := ui.notify(NotifyEvent{
532 NetID: netID,
533 Buffer: buffer,
534 }, header, line.Body.String())
535 if id >= 0 {
536 b.notifications = append(b.notifications, id)
537 }
538 }
539}
540
541func (ui *UI) AddLines(netID, buffer string, before, after []Line) {
542 ui.bs.AddLines(netID, buffer, before, after)
543}
544
545func (ui *UI) JumpBuffer(sub string) bool {
546 subLower := strings.ToLower(sub)
547 for i, b := range ui.bs.list {
548 var title string
549 if b.title == "" {
550 title = b.netName
551 } else {
552 title = b.title
553 }
554 if strings.Contains(strings.ToLower(title), subLower) {
555 if ui.bs.To(i) {
556 ui.memberOffset = 0
557 }
558 return true
559 }
560 }
561
562 return false
563}
564
565func (ui *UI) JumpBufferIndex(i int) bool {
566 if i >= 0 && i < len(ui.bs.list) {
567 if ui.bs.To(i) {
568 ui.memberOffset = 0
569 }
570 return true
571 }
572 return false
573}
574
575func (ui *UI) JumpBufferNetwork(netID, buffer string) bool {
576 for i, b := range ui.bs.list {
577 if b.netID == netID && strings.ToLower(b.title) == strings.ToLower(buffer) {
578 if ui.bs.To(i) {
579 ui.memberOffset = 0
580 }
581 return true
582 }
583 }
584 return false
585}
586
587func (ui *UI) Focused() bool {
588 return ui.bs.Focused()
589}
590
591func (ui *UI) SetFocused(focused bool) {
592 ui.bs.SetFocused(focused)
593}
594
595func (ui *UI) SetTopic(netID, buffer string, topic StyledString) {
596 ui.bs.SetTopic(netID, buffer, topic)
597}
598
599func (ui *UI) GetPinned(netID, buffer string) bool {
600 return ui.bs.GetPinned(netID, buffer)
601}
602
603func (ui *UI) SetPinned(netID, buffer string, pinned bool) int {
604 return ui.bs.SetPinned(netID, buffer, pinned)
605}
606
607func (ui *UI) GetMuted(netID, buffer string) bool {
608 return ui.bs.GetMuted(netID, buffer)
609}
610
611func (ui *UI) SetMuted(netID, buffer string, muted bool) int {
612 return ui.bs.SetMuted(netID, buffer, muted)
613}
614
615func (ui *UI) SetRead(netID, buffer string, timestamp time.Time) {
616 ui.bs.SetRead(netID, buffer, timestamp)
617}
618
619func (ui *UI) ApplyReact(netID, buffer, id, user, react string, removal bool) {
620 ui.bs.ApplyReact(netID, buffer, id, user, react, removal)
621}
622
623func (ui *UI) UpdateRead() (netID, buffer string, timestamp time.Time) {
624 return ui.bs.UpdateRead()
625}
626
627func (ui *UI) SetStatus(status string) {
628 ui.status = status
629}
630
631func (ui *UI) SetPrompt(prompt StyledString) {
632 ui.prompt = prompt
633}
634
635func (ui *UI) SetTitle(title string) {
636 if ui.title == title {
637 return
638 }
639 ui.title = title
640 ui.vx.SetTitle(title)
641}
642
643func (ui *UI) SetMouseShape(shape vaxis.MouseShape) {
644 ui.vx.SetMouseShape(shape)
645}
646
647func (ui *UI) SetColorTheme(mode vaxis.ColorThemeMode) {
648 ui.colorThemeMode = mode
649}
650
651// InputContent result must not be modified.
652func (ui *UI) InputContent() []rune {
653 return ui.e.Content()
654}
655
656func (ui *UI) InputRune(r rune) {
657 ui.e.PutRune(r)
658}
659
660// InputEnter returns true if the event was eaten
661func (ui *UI) InputEnter() bool {
662 return ui.e.Enter()
663}
664
665func (ui *UI) InputRight() {
666 ui.e.Right()
667}
668
669func (ui *UI) InputRightWord() {
670 ui.e.RightWord()
671}
672
673func (ui *UI) InputLeft() {
674 ui.e.Left()
675}
676
677func (ui *UI) InputLeftWord() {
678 ui.e.LeftWord()
679}
680
681func (ui *UI) InputHome() {
682 ui.e.Home()
683}
684
685func (ui *UI) InputEnd() {
686 ui.e.End()
687}
688
689func (ui *UI) InputUp() {
690 ui.e.Up()
691}
692
693func (ui *UI) InputDown() {
694 ui.e.Down()
695}
696
697func (ui *UI) InputBackspace() (ok bool) {
698 return ui.e.RemCluster()
699}
700
701func (ui *UI) InputDelete() (ok bool) {
702 return ui.e.RemClusterForward()
703}
704
705func (ui *UI) InputDeleteBefore() (ok bool) {
706 return ui.e.RemBefore()
707}
708
709func (ui *UI) InputDeleteAfter() (ok bool) {
710 return ui.e.RemAfter()
711}
712
713func (ui *UI) InputDeleteWord() (ok bool) {
714 return ui.e.RemWord()
715}
716
717func (ui *UI) InputDeleteNextWord() (ok bool) {
718 return ui.e.RemWordForward()
719}
720
721func (ui *UI) InputAutoComplete() (ok bool) {
722 return ui.e.AutoComplete()
723}
724
725func (ui *UI) InputFlush() (content string) {
726 return ui.e.Flush()
727}
728
729func (ui *UI) InputClear() bool {
730 return ui.e.Clear()
731}
732
733func (ui *UI) InputSet(text string) {
734 ui.e.Set(text)
735}
736
737func (ui *UI) InputBackSearch() {
738 ui.e.BackSearch()
739}
740
741func (ui *UI) SetWinPixels(xPixel int, yPixel int) {
742 ui.vx.xPixel = xPixel
743 ui.vx.yPixel = yPixel
744}
745
746func (ui *UI) Resize() {
747 ui.vx.window = ui.vx.Window() // Refresh window size
748 w, h := ui.vx.window.Size()
749 innerWidth := w - 9 - ui.channelWidth - ui.config.NickColWidth - ui.memberWidth
750 if innerWidth <= 0 {
751 innerWidth = 1 // will break display somewhat, but this is an edge case
752 }
753 ui.e.Resize(innerWidth)
754 textWidth := innerWidth
755 if ui.config.TextMaxWidth > 0 && ui.config.TextMaxWidth < textWidth {
756 textWidth = ui.config.TextMaxWidth
757 }
758 if ui.channelWidth == 0 {
759 ui.bs.ResizeTimeline(innerWidth, h-3, textWidth)
760 } else {
761 ui.bs.ResizeTimeline(innerWidth, h-2, textWidth)
762 }
763 ui.ScrollToBuffer()
764 ui.vx.Refresh()
765}
766
767func (ui *UI) Size() (int, int) {
768 return ui.vx.window.Size()
769}
770
771func (ui *UI) Beep() {
772 ui.vx.Bell()
773}
774
775func (ui *UI) Notify(title string, body string) {
776 ui.vx.Notify(title, body)
777}
778
779func (ui *UI) Highlights() int {
780 return ui.bs.Highlights()
781}
782
783func (ui *UI) AsyncCompletions(id int, cs []Completion) {
784 ui.e.AsyncCompletions(id, cs)
785}
786
787func (ui *UI) Draw(members []irc.Member) {
788 ui.clickEvents = ui.clickEvents[:0]
789
790 w, h := ui.vx.window.Size()
791
792 ui.bs.DrawTimeline(ui, ui.channelWidth, 0, ui.config.NickColWidth)
793 if ui.channelWidth == 0 {
794 ui.bs.DrawHorizontalBufferList(ui.vx, 0, h-1, w-ui.memberWidth, &ui.channelOffset)
795 } else {
796 ui.bs.DrawVerticalBufferList(ui.vx, 0, 0, ui.channelWidth, h, &ui.channelOffset)
797 }
798 if ui.memberWidth != 0 {
799 ui.drawVerticalMemberList(ui.vx, w-ui.memberWidth, 0, ui.memberWidth, h, ui.bs.cur(), members, &ui.memberOffset)
800 }
801 if ui.channelWidth == 0 {
802 ui.drawStatusBar(ui.channelWidth, h-3, w-ui.memberWidth)
803 } else {
804 ui.drawStatusBar(ui.channelWidth, h-2, w-ui.channelWidth-ui.memberWidth)
805 }
806
807 prompt := ui.prompt
808 if ui.bs.HasOverlay() && ui.e.Empty() {
809 prompt = Styled(">", vaxis.Style{
810 Foreground: ui.config.Colors.Prompt,
811 })
812 }
813 if ui.channelWidth == 0 {
814 for x := 0; x < 9+ui.config.NickColWidth; x++ {
815 setCell(ui.vx, x, h-2, ' ', vaxis.Style{})
816 }
817 printIdent(ui.vx, 7, h-2, ui.config.NickColWidth, prompt)
818 } else {
819 for x := ui.channelWidth; x < 9+ui.channelWidth+ui.config.NickColWidth; x++ {
820 setCell(ui.vx, x, h-1, ' ', vaxis.Style{})
821 }
822 printIdent(ui.vx, ui.channelWidth+7, h-1, ui.config.NickColWidth, prompt)
823 }
824
825 var hint string
826 if ui.bs.HasOverlay() {
827 hint = ui.overlayHint
828 }
829 if ui.channelWidth == 0 {
830 ui.e.Draw(ui.vx, 9+ui.config.NickColWidth, h-2, hint)
831 } else {
832 ui.e.Draw(ui.vx, 9+ui.channelWidth+ui.config.NickColWidth, h-1, hint)
833 }
834
835 ui.vx.Render()
836}
837
838func (ui *UI) ScrollToBuffer() {
839 if ui.bs.current < ui.channelOffset {
840 ui.channelOffset = ui.bs.current
841 return
842 }
843
844 w, h := ui.vx.window.Size()
845 var first int
846 if ui.channelWidth > 0 {
847 first = ui.bs.current - h + 1
848 } else {
849 first = ui.bs.GetLeftMost(w - ui.memberWidth)
850 }
851 if ui.channelOffset < first {
852 ui.channelOffset = first
853 }
854}
855
856func (ui *UI) drawHorizontalLine(vx *Vaxis, x0, y, width int) {
857 for x := x0; x < x0+width; x++ {
858 setCell(vx, x, y, '─', vaxis.Style{
859 Foreground: ui.config.Colors.Gray,
860 })
861 }
862}
863
864func (ui *UI) drawVerticalLine(vx *Vaxis, x, y0, height int) {
865 for y := y0; y < y0+height; y++ {
866 setCell(vx, x, y, '│', vaxis.Style{})
867 }
868}
869
870func (ui *UI) drawStatusBar(x0, y, width int) {
871 clearArea(ui.vx, x0, y, width, 1)
872
873 if ui.status == "" {
874 return
875 }
876
877 var s StyledStringBuilder
878 s.SetStyle(vaxis.Style{
879 Foreground: ui.config.Colors.Gray,
880 })
881 s.WriteString("--")
882
883 x := x0 + 5 + ui.config.NickColWidth
884 printString(ui.vx, &x, y, s.StyledString())
885 x += 2
886
887 s.Reset()
888 s.SetStyle(vaxis.Style{
889 Foreground: ui.config.Colors.Gray,
890 })
891 s.WriteString(ui.status)
892
893 printString(ui.vx, &x, y, s.StyledString())
894}
895
896func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, b *buffer, members []irc.Member, offset *int) {
897 ui.drawVerticalLine(vx, x0, y0, height)
898 x0++
899 width--
900 clearArea(vx, x0, y0, width, height)
901
902 if _, channel := ui.bs.Current(); channel == "" && ui.config.Mouse && ui.config.Buttons {
903 x := x0 + 1
904 printString(vx, &x, y0, Styled("Help", vaxis.Style{
905 Foreground: ui.config.Colors.Status,
906 }))
907 ui.drawHorizontalLine(vx, x0, y0+1, width)
908 y0 += 2
909
910 lines := []string{
911 "→Add network",
912 "→Join channel",
913 "→Message user",
914 }
915 for i, line := range lines {
916 var st vaxis.Style
917 if i*2 == ui.memberClicked {
918 st.Attribute |= vaxis.AttrReverse
919 }
920 x := x0
921 printString(vx, &x, y0, Styled(line, st))
922 ui.drawHorizontalLine(vx, x0, y0+1, width)
923 y0 += 2
924 }
925 return
926 }
927
928 if len(members) > 0 {
929 var memberString string
930 if len(members) > 1 {
931 memberString = fmt.Sprintf("%d members", len(members))
932 } else {
933 memberString = fmt.Sprintf("%d member", len(members))
934 }
935 memberString = truncate(vx, memberString, width-1, "\u2026")
936 xMembers := x0 + 1
937 printString(vx, &xMembers, y0, Styled(memberString, vaxis.Style{
938 Foreground: ui.config.Colors.Status,
939 }))
940 }
941 y0++
942 height--
943 ui.drawHorizontalLine(vx, x0, y0, width)
944 y0++
945 height--
946
947 if ui.config.Mouse && ui.config.Buttons {
948 var actions []string
949 if b.muted {
950 actions = append(actions, "→ Unmute")
951 } else {
952 actions = append(actions, "→ Mute")
953 }
954 if b.pinned {
955 actions = append(actions, "→ Unpin")
956 } else {
957 actions = append(actions, "→ Pin")
958 }
959 actions = append(actions, "→ Leave")
960 for i, action := range actions {
961 x := x0
962 ui.drawHorizontalLine(vx, x0, y0+height-(len(actions)-i)*2, width)
963 printString(vx, &x, y0+height-(len(actions)-i)*2+1, Styled(action, vaxis.Style{}))
964 }
965 height -= 2 * len(actions)
966 }
967
968 padding := 1
969 for _, m := range members {
970 if m.Disconnected {
971 padding = runeWidth(vx, 0x274C)
972 break
973 }
974 }
975
976 if y0+len(members)-*offset < height {
977 *offset = y0 + len(members) - height
978 if *offset < 0 {
979 *offset = 0
980 }
981 }
982
983 for i, m := range members[*offset:] {
984 if i >= height {
985 break
986 }
987 var attr vaxis.AttributeMask
988 if i+*offset == ui.memberClicked {
989 attr |= vaxis.AttrReverse
990 }
991 x := x0
992 y := y0 + i
993 if m.Disconnected {
994 disconnectedSt := vaxis.Style{
995 Foreground: ColorRed,
996 Attribute: attr,
997 }
998 printString(vx, &x, y, Styled("\u274C", disconnectedSt))
999 } else if m.PowerLevel != "" {
1000 x += padding - 1
1001 powerLevelText := m.PowerLevel[:1]
1002 powerLevelSt := vaxis.Style{
1003 Foreground: ColorGreen,
1004 Attribute: attr,
1005 }
1006 printString(vx, &x, y, Styled(powerLevelText, powerLevelSt))
1007 } else {
1008 x += padding
1009 }
1010
1011 var name StyledString
1012 nameText := truncate(vx, m.Name.Name, width-1, "\u2026")
1013 if m.Away {
1014 name = Styled(nameText, vaxis.Style{
1015 Foreground: ui.config.Colors.Gray,
1016 Attribute: attr,
1017 })
1018 } else {
1019 color := ui.IdentColor(ui.config.Colors.Nicks, m.Name.Name, m.Self)
1020 name = Styled(nameText, vaxis.Style{
1021 Foreground: color,
1022 Attribute: attr,
1023 })
1024 }
1025
1026 printString(vx, &x, y, name)
1027 }
1028}