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	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}