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