package ui import ( "strings" "sync/atomic" "time" "git.sr.ht/~taiite/senpai/irc" "github.com/gdamore/tcell/v2" ) type Config struct { NickColWidth int ChanColWidth int ChanColEnabled bool MemberColWidth int MemberColEnabled bool TextMaxWidth int AutoComplete func(cursorIdx int, text []rune) []Completion Mouse bool MergeLine func(former *Line, addition Line) Colors ConfigColors } type ConfigColors struct { Unread tcell.Color Nicks ColorScheme } type UI struct { screen tcell.Screen Events chan tcell.Event exit atomic.Value // bool config Config bs BufferList e Editor prompt StyledString status string channelOffset int memberClicked int memberOffset int channelWidth int memberWidth int } func New(config Config) (ui *UI, err error) { ui = &UI{ config: config, } if config.ChanColEnabled { ui.channelWidth = config.ChanColWidth } if config.MemberColEnabled { ui.memberWidth = config.MemberColWidth } ui.screen, err = tcell.NewScreen() if err != nil { return } err = ui.screen.Init() if err != nil { return } if ui.screen.HasMouse() && config.Mouse { ui.screen.EnableMouse() } ui.screen.EnablePaste() _, h := ui.screen.Size() ui.screen.Clear() ui.screen.ShowCursor(0, h-2) ui.exit.Store(false) ui.Events = make(chan tcell.Event, 128) go func() { for !ui.ShouldExit() { ev := ui.screen.PollEvent() if ev == nil { ui.Exit() break } ui.Events <- ev } close(ui.Events) }() ui.bs = NewBufferList(config.Colors, ui.config.MergeLine) ui.e = NewEditor(ui.config.AutoComplete) ui.Resize() return } func (ui *UI) ShouldExit() bool { return ui.exit.Load().(bool) } func (ui *UI) Exit() { ui.exit.Store(true) } func (ui *UI) Close() { ui.screen.Fini() } func (ui *UI) CurrentBuffer() (netID, title string) { return ui.bs.Current() } func (ui *UI) NextBuffer() { ui.bs.Next() ui.memberOffset = 0 } func (ui *UI) PreviousBuffer() { ui.bs.Previous() ui.memberOffset = 0 } func (ui *UI) ClickedBuffer() int { return ui.bs.clicked } func (ui *UI) ClickBuffer(i int) { if i < len(ui.bs.list) { ui.bs.clicked = i } } func (ui *UI) GoToBufferNo(i int) { if ui.bs.To(i) { ui.memberOffset = 0 } } func (ui *UI) ShowBufferNumbers(enable bool) { ui.bs.ShowBufferNumbers(enable) } func (ui *UI) ClickedMember() int { return ui.memberClicked } func (ui *UI) ClickMember(i int) { ui.memberClicked = i } func (ui *UI) ScrollUp() { ui.bs.ScrollUp(ui.bs.tlHeight / 2) } func (ui *UI) ScrollDown() { ui.bs.ScrollDown(ui.bs.tlHeight / 2) } func (ui *UI) ScrollUpBy(n int) { ui.bs.ScrollUp(n) } func (ui *UI) ScrollDownBy(n int) { ui.bs.ScrollDown(n) } func (ui *UI) ScrollUpHighlight() bool { return ui.bs.ScrollUpHighlight() } func (ui *UI) ScrollDownHighlight() bool { return ui.bs.ScrollDownHighlight() } func (ui *UI) ScrollChannelUpBy(n int) { ui.channelOffset -= n if ui.channelOffset < 0 { ui.channelOffset = 0 } } func (ui *UI) ScrollChannelDownBy(n int) { ui.channelOffset += n } func (ui *UI) HorizontalBufferOffset(x int) int { return ui.bs.HorizontalBufferOffset(x, ui.channelOffset) } func (ui *UI) ChannelOffset() int { return ui.channelOffset } func (ui *UI) MemberOffset() int { return ui.memberOffset } func (ui *UI) ChannelWidth() int { return ui.channelWidth } func (ui *UI) MemberWidth() int { return ui.memberWidth } func (ui *UI) ToggleChannelList() { if ui.channelWidth == 0 { ui.channelWidth = ui.config.ChanColWidth } else { ui.channelWidth = 0 } ui.Resize() } func (ui *UI) ToggleMemberList() { if ui.memberWidth == 0 { ui.memberWidth = ui.config.MemberColWidth } else { ui.memberWidth = 0 } ui.Resize() } func (ui *UI) ScrollMemberUpBy(n int) { ui.memberOffset -= n if ui.memberOffset < 0 { ui.memberOffset = 0 } } func (ui *UI) ScrollMemberDownBy(n int) { ui.memberOffset += n } func (ui *UI) IsAtTop() bool { return ui.bs.IsAtTop() } func (ui *UI) OpenOverlay() { ui.bs.OpenOverlay() } func (ui *UI) CloseOverlay() { ui.bs.CloseOverlay() } func (ui *UI) HasOverlay() bool { return ui.bs.HasOverlay() } func (ui *UI) AddBuffer(netID, netName, title string) (i int, added bool) { i, added = ui.bs.Add(netID, netName, title) if added { ui.HorizontalBufferScrollTo() } return } func (ui *UI) RemoveBuffer(netID, title string) { _ = ui.bs.Remove(netID, title) ui.memberOffset = 0 } func (ui *UI) RemoveNetworkBuffers(netID string) { ui.bs.RemoveNetwork(netID) ui.memberOffset = 0 } func (ui *UI) AddLine(netID, buffer string, line Line) { ui.bs.AddLine(netID, buffer, line) } func (ui *UI) AddLines(netID, buffer string, before, after []Line) { ui.bs.AddLines(netID, buffer, before, after) } func (ui *UI) JumpBuffer(sub string) bool { subLower := strings.ToLower(sub) for i, b := range ui.bs.list { if strings.Contains(strings.ToLower(b.title), subLower) { if ui.bs.To(i) { ui.memberOffset = 0 } return true } } return false } func (ui *UI) JumpBufferIndex(i int) bool { if i >= 0 && i < len(ui.bs.list) { if ui.bs.To(i) { ui.memberOffset = 0 } return true } return false } func (ui *UI) JumpBufferNetwork(netID, sub string) bool { subLower := strings.ToLower(sub) for i, b := range ui.bs.list { if b.netID == netID && strings.Contains(strings.ToLower(b.title), subLower) { if ui.bs.To(i) { ui.memberOffset = 0 } return true } } return false } func (ui *UI) SetTopic(netID, buffer string, topic string) { ui.bs.SetTopic(netID, buffer, topic) } func (ui *UI) SetRead(netID, buffer string, timestamp time.Time) { ui.bs.SetRead(netID, buffer, timestamp) } func (ui *UI) UpdateRead() (netID, buffer string, timestamp time.Time) { return ui.bs.UpdateRead() } func (ui *UI) SetStatus(status string) { ui.status = status } func (ui *UI) SetPrompt(prompt StyledString) { ui.prompt = prompt } // InputContent result must not be modified. func (ui *UI) InputContent() []rune { return ui.e.Content() } func (ui *UI) InputRune(r rune) { ui.e.PutRune(r) } func (ui *UI) InputRight() { ui.e.Right() } func (ui *UI) InputRightWord() { ui.e.RightWord() } func (ui *UI) InputLeft() { ui.e.Left() } func (ui *UI) InputLeftWord() { ui.e.LeftWord() } func (ui *UI) InputHome() { ui.e.Home() } func (ui *UI) InputEnd() { ui.e.End() } func (ui *UI) InputUp() { ui.e.Up() } func (ui *UI) InputDown() { ui.e.Down() } func (ui *UI) InputBackspace() (ok bool) { return ui.e.RemRune() } func (ui *UI) InputDelete() (ok bool) { return ui.e.RemRuneForward() } func (ui *UI) InputDeleteWord() (ok bool) { return ui.e.RemWord() } func (ui *UI) InputAutoComplete(offset int) (ok bool) { return ui.e.AutoComplete(offset) } func (ui *UI) InputEnter() (content string) { return ui.e.Flush() } func (ui *UI) InputClear() bool { return ui.e.Clear() } func (ui *UI) InputSet(text string) { ui.e.Set(text) } func (ui *UI) InputBackSearch() { ui.e.BackSearch() } func (ui *UI) Resize() { w, h := ui.screen.Size() innerWidth := w - 9 - ui.channelWidth - ui.config.NickColWidth - ui.memberWidth if innerWidth <= 0 { innerWidth = 1 // will break display somewhat, but this is an edge case } ui.e.Resize(innerWidth) textWidth := innerWidth if ui.config.TextMaxWidth > 0 && ui.config.TextMaxWidth < textWidth { textWidth = ui.config.TextMaxWidth } if ui.channelWidth == 0 { ui.bs.ResizeTimeline(innerWidth, h-3, textWidth) } else { ui.bs.ResizeTimeline(innerWidth, h-2, textWidth) } ui.HorizontalBufferScrollTo() ui.screen.Sync() } func (ui *UI) Size() (int, int) { return ui.screen.Size() } func (ui *UI) Beep() { ui.screen.Beep() } func (ui *UI) Draw(members []irc.Member) { w, h := ui.screen.Size() if ui.channelWidth == 0 { ui.e.Draw(ui.screen, 9+ui.config.NickColWidth, h-2) } else { ui.e.Draw(ui.screen, 9+ui.channelWidth+ui.config.NickColWidth, h-1) } ui.bs.DrawTimeline(ui.screen, ui.channelWidth, 0, ui.config.NickColWidth) if ui.channelWidth == 0 { ui.bs.DrawHorizontalBufferList(ui.screen, 0, h-1, w-ui.memberWidth, &ui.channelOffset) } else { ui.bs.DrawVerticalBufferList(ui.screen, 0, 0, ui.channelWidth, h, &ui.channelOffset) } if ui.memberWidth != 0 { ui.drawVerticalMemberList(ui.screen, w-ui.memberWidth, 0, ui.memberWidth, h, members, &ui.memberOffset) } if ui.channelWidth == 0 { ui.drawStatusBar(ui.channelWidth, h-3, w-ui.memberWidth) } else { ui.drawStatusBar(ui.channelWidth, h-2, w-ui.channelWidth-ui.memberWidth) } if ui.channelWidth == 0 { for x := 0; x < 9+ui.config.NickColWidth; x++ { ui.screen.SetContent(x, h-2, ' ', nil, tcell.StyleDefault) } printIdent(ui.screen, 7, h-2, ui.config.NickColWidth, ui.prompt) } else { for x := ui.channelWidth; x < 9+ui.channelWidth+ui.config.NickColWidth; x++ { ui.screen.SetContent(x, h-1, ' ', nil, tcell.StyleDefault) } printIdent(ui.screen, ui.channelWidth+7, h-1, ui.config.NickColWidth, ui.prompt) } ui.screen.Show() } func (ui *UI) HorizontalBufferScrollTo() { w, _ := ui.screen.Size() screenWidth := w - ui.memberWidth if ui.bs.current < ui.channelOffset { ui.channelOffset = ui.bs.current return } leftMost := ui.bs.GetLeftMost(screenWidth) if ui.channelOffset >= leftMost { return } ui.channelOffset = leftMost } func (ui *UI) drawStatusBar(x0, y, width int) { clearArea(ui.screen, x0, y, width, 1) if ui.status == "" { return } var s StyledStringBuilder s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) s.WriteString("--") x := x0 + 5 + ui.config.NickColWidth printString(ui.screen, &x, y, s.StyledString()) x += 2 s.Reset() s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) s.WriteString(ui.status) printString(ui.screen, &x, y, s.StyledString()) } func (ui *UI) drawVerticalMemberList(screen tcell.Screen, x0, y0, width, height int, members []irc.Member, offset *int) { if y0+len(members)-*offset < height { *offset = y0 + len(members) - height if *offset < 0 { *offset = 0 } } if ui.memberClicked >= len(members) { ui.memberClicked = len(members) - 1 } drawVerticalLine(screen, x0, y0, height) x0++ width-- clearArea(screen, x0, y0, width, height) padding := 1 for _, m := range members { if m.Disconnected { padding = runeWidth(0x274C) break } } for i, m := range members[*offset:] { reverse := i+*offset == ui.memberClicked x := x0 y := y0 + i if m.Disconnected { disconnectedSt := tcell.StyleDefault.Foreground(tcell.ColorRed).Reverse(reverse) printString(screen, &x, y, Styled("\u274C", disconnectedSt)) } else if m.PowerLevel != "" { x += padding - 1 powerLevelText := m.PowerLevel[:1] powerLevelSt := tcell.StyleDefault.Foreground(tcell.ColorGreen).Reverse(reverse) printString(screen, &x, y, Styled(powerLevelText, powerLevelSt)) } else { x += padding } var name StyledString nameText := truncate(m.Name.Name, width-1, "\u2026") if m.Away { name = Styled(nameText, tcell.StyleDefault.Foreground(tcell.ColorGray).Reverse(reverse)) } else { color := IdentColor(ui.config.Colors.Nicks, m.Name.Name) name = Styled(nameText, tcell.StyleDefault.Foreground(color).Reverse(reverse)) } printString(screen, &x, y, name) } }