package components import ( "fmt" "sort" "strings" "time" "github.com/erroneousboat/termui" runewidth "github.com/mattn/go-runewidth" "github.com/erroneousboat/slack-term/config" ) // Chat is the definition of a Chat component type Chat struct { List *termui.List Messages map[string]Message Offset int } // CreateChatComponent is the constructor for the Chat struct func CreateChatComponent(inputHeight int) *Chat { chat := &Chat{ List: termui.NewList(), Messages: make(map[string]Message), Offset: 0, } chat.List.Height = termui.TermHeight() - inputHeight chat.List.Overflow = "wrap" return chat } // Buffer implements interface termui.Bufferer func (c *Chat) Buffer() termui.Buffer { // Convert Messages into termui.Cell cells := c.MessagesToCells(c.Messages) // We will create an array of Line structs, this allows us // to more easily render the items in a list. We will range // over the cells we've created and create a Line within // the bounds of the Chat pane type Line struct { cells []termui.Cell } lines := []Line{} line := Line{} // When we encounter a newline or, are at the bounds of the chat view we // stop iterating over the cells and add the line to the line array x := 0 for _, cell := range cells { // When we encounter a newline we add the line to the array if cell.Ch == '\n' { lines = append(lines, line) // Reset for new line line = Line{} x = 0 continue } if x+cell.Width() > c.List.InnerBounds().Dx() { lines = append(lines, line) // Reset for new line line = Line{} x = 0 } line.cells = append(line.cells, cell) x += cell.Width() } // Append the last line to the array when we didn't encounter any // newlines or were at the bounds of the chat view lines = append(lines, line) // We will print lines bottom up, it will loop over the lines // backwards and for every line it'll set the cell in that line. // Offset is the number which allows us to begin printing the // line above the last line. buf := c.List.Buffer() linesHeight := len(lines) paneMinY := c.List.InnerBounds().Min.Y paneMaxY := c.List.InnerBounds().Max.Y currentY := paneMaxY - 1 for i := (linesHeight - 1) - c.Offset; i >= 0; i-- { if currentY < paneMinY { break } x := c.List.InnerBounds().Min.X for _, cell := range lines[i].cells { buf.Set(x, currentY, cell) x += cell.Width() } // When we're not at the end of the pane, fill it up // with empty characters for x < c.List.InnerBounds().Max.X { buf.Set( x, currentY, termui.Cell{ Ch: ' ', Fg: c.List.ItemFgColor, Bg: c.List.ItemBgColor, }, ) x += runewidth.RuneWidth(' ') } currentY-- } // If the space above currentY is empty we need to fill // it up with blank lines, otherwise the List object will // render the items top down, and the result will mix. for currentY >= paneMinY { x := c.List.InnerBounds().Min.X for x < c.List.InnerBounds().Max.X { buf.Set( x, currentY, termui.Cell{ Ch: ' ', Fg: c.List.ItemFgColor, Bg: c.List.ItemBgColor, }, ) x += runewidth.RuneWidth(' ') } currentY-- } return buf } // GetHeight implements interface termui.GridBufferer func (c *Chat) GetHeight() int { return c.List.Block.GetHeight() } // SetWidth implements interface termui.GridBufferer func (c *Chat) SetWidth(w int) { c.List.SetWidth(w) } // SetX implements interface termui.GridBufferer func (c *Chat) SetX(x int) { c.List.SetX(x) } // SetY implements interface termui.GridBufferer func (c *Chat) SetY(y int) { c.List.SetY(y) } // GetMaxItems return the maximal amount of items can fit in the Chat // component func (c *Chat) GetMaxItems() int { return c.List.InnerBounds().Max.Y - c.List.InnerBounds().Min.Y } // SetMessages will put the provided messages into the Messages field of the // Chat view func (c *Chat) SetMessages(messages []Message) { // Reset offset first, when scrolling in view and changing channels we // want the offset to be 0 when loading new messages c.Offset = 0 for _, msg := range messages { c.Messages[msg.ID] = msg } } // AddMessage adds a single message to Messages func (c *Chat) AddMessage(message Message) { c.Messages[message.ID] = message } // AddReply adds a single reply to a parent thread, it also sets // the thread separator func (c *Chat) AddReply(parentID string, message Message) { // It is possible that a message is received but the parent is not // present in the chat view if _, ok := c.Messages[parentID]; ok { message.Thread = " " c.Messages[parentID].Messages[message.ID] = message } else { c.AddMessage(message) } } // IsNewThread check whether a message that is going to be added as // a child to a parent message, is the first one or not func (c *Chat) IsNewThread(parentID string) bool { if parent, ok := c.Messages[parentID]; ok { if len(parent.Messages) > 0 { return true } } return false } // ClearMessages clear the c.Messages func (c *Chat) ClearMessages() { c.Messages = make(map[string]Message) } // ScrollUp will render the chat messages based on the Offset of the Chat // pane. // // Offset is 0 when scrolled down. (we loop backwards over the array, so we // start with rendering last item in the list at the maximum y of the Chat // pane). Increasing the Offset will thus result in substracting the offset // from the len(Chat.Messages). func (c *Chat) ScrollUp() { c.Offset = c.Offset + 10 // Protect overscrolling if c.Offset > len(c.Messages) { c.Offset = len(c.Messages) } } // ScrollDown will render the chat messages based on the Offset of the Chat // pane. // // Offset is 0 when scrolled down. (we loop backwards over the array, so we // start with rendering last item in the list at the maximum y of the Chat // pane). Increasing the Offset will thus result in substracting the offset // from the len(Chat.Messages). func (c *Chat) ScrollDown() { c.Offset = c.Offset - 10 // Protect overscrolling if c.Offset < 0 { c.Offset = 0 } } // SetBorderLabel will set Label of the Chat pane to the specified string func (c *Chat) SetBorderLabel(channelName string) { c.List.BorderLabel = channelName } // MessagesToCells is a wrapper around MessageToCells to use for a slice of // of type Message func (c *Chat) MessagesToCells(msgs map[string]Message) []termui.Cell { cells := make([]termui.Cell, 0) sortedMessages := SortMessages(msgs) for i, msg := range sortedMessages { cells = append(cells, c.MessageToCells(msg)...) if len(msg.Messages) > 0 { cells = append(cells, termui.Cell{Ch: '\n'}) cells = append(cells, c.MessagesToCells(msg.Messages)...) } // Add a newline after every message if i < len(sortedMessages)-1 { cells = append(cells, termui.Cell{Ch: '\n'}) } } return cells } // MessageToCells will convert a Message struct to termui.Cell // // We're building parts of the message individually, or else DefaultTxBuilder // will interpret potential markdown usage in a message as well. func (c *Chat) MessageToCells(msg Message) []termui.Cell { cells := make([]termui.Cell, 0) // When msg.Time and msg.Name are empty (in the case of attachments) // don't add the time and name parts. if (msg.Time != time.Time{} && msg.Name != "") { // Time cells = append(cells, termui.DefaultTxBuilder.Build( msg.GetTime(), termui.ColorDefault, termui.ColorDefault)..., ) // Thread cells = append(cells, termui.DefaultTxBuilder.Build( msg.GetThread(), termui.ColorDefault, termui.ColorDefault)..., ) // Name cells = append(cells, termui.DefaultTxBuilder.Build( msg.GetName(), termui.ColorDefault, termui.ColorDefault)..., ) } // Hack, in order to get the correct fg and bg attributes. This is // because the readAttr function in termui is unexported. txCells := termui.DefaultTxBuilder.Build( msg.GetContent(), termui.ColorDefault, termui.ColorDefault, ) // Text for _, r := range msg.Content { cells = append( cells, termui.Cell{ Ch: r, Fg: txCells[0].Fg, Bg: txCells[0].Bg, }, ) } return cells } // Help shows the usage and key bindings in the chat pane func (c *Chat) Help(usage string, cfg *config.Config) { msgUsage := Message{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), Content: usage, } c.Messages[msgUsage.ID] = msgUsage for mode, mapping := range cfg.KeyMap { msgMode := Message{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), Content: fmt.Sprintf("%s", strings.ToUpper(mode)), } c.Messages[msgMode.ID] = msgMode msgNewline := Message{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), Content: "", } c.Messages[msgNewline.ID] = msgNewline var keys []string for k := range mapping { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { msgKey := Message{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), Content: fmt.Sprintf(" %-12s%-15s", k, mapping[k]), } c.Messages[msgKey.ID] = msgKey } msgNewline.ID = fmt.Sprintf("%d", time.Now().UnixNano()) c.Messages[msgNewline.ID] = msgNewline } }