master sewn/kohai / ui / ui.go
   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}