From 21b81ccb2716d73cde4eda805cf1f5ea1642412e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 7 Apr 2019 03:22:51 +0300 Subject: Initial changes to do #91 --- ui/messages/htmlmessage.go | 187 ++++++++++++++++++++++++++++++++ ui/messages/message.go | 1 - ui/messages/parser/htmlparser.go | 226 ++++++++++++++++++++------------------- ui/messages/parser/parser.go | 3 +- 4 files changed, 303 insertions(+), 114 deletions(-) create mode 100644 ui/messages/htmlmessage.go (limited to 'ui/messages') diff --git a/ui/messages/htmlmessage.go b/ui/messages/htmlmessage.go new file mode 100644 index 0000000..de4b30c --- /dev/null +++ b/ui/messages/htmlmessage.go @@ -0,0 +1,187 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2019 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "time" + + "github.com/mattn/go-runewidth" + + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/mautrix" + "maunium.net/go/mauview" + "maunium.net/go/tcell" +) + +type HTMLMessage struct { + BaseMessage + + Root *HTMLEntity +} + +func NewHTMLMessage(id, sender, displayname string, msgtype mautrix.MessageType, root *HTMLEntity, timestamp time.Time) UIMessage { + return &HTMLMessage{ + BaseMessage: newBaseMessage(id, sender, displayname, msgtype, timestamp), + Root: root, + } +} +func (hw *HTMLMessage) Draw(screen mauview.Screen) { + hw.Root.Draw(screen) +} + +func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool { + return false +} + +func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool { + return false +} + +func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool { + return false +} + +func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int) { + // TODO account for bare messages in initial startX + startX := 0 + hw.Root.calculateBuffer(width, startX, preferences.BareMessageView) +} + +func (hw *HTMLMessage) Height() int { + return hw.Root.height +} + +func (hw *HTMLMessage) PlainText() string { + return "Plaintext unavailable" +} + +func (hw *HTMLMessage) NotificationContent() string { + return "Notification content unavailable" +} + +type HTMLEntity struct { + // Permanent variables + Tag string + Text string + Style tcell.Style + Children []*HTMLEntity + Block bool + Indent int + + // Non-permanent variables (calculated buffer data) + buffer []string + prevWidth int + startX int + height int +} + +func (he *HTMLEntity) AdjustStyle(fn func(tcell.Style) tcell.Style) *HTMLEntity { + for _, child := range he.Children { + child.AdjustStyle(fn) + } + he.Style = fn(he.Style) + return he +} + +func (he *HTMLEntity) Draw(screen mauview.Screen) { + width, _ := screen.Size() + if len(he.buffer) > 0 { + x := he.startX + for y, line := range he.buffer { + widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, he.Style) + x = 0 + } + } + if len(he.Children) > 0 { + proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: he.Indent, Width: width - he.Indent} + for _, entity := range he.Children { + if entity.Block { + proxyScreen.OffsetY++ + } + proxyScreen.Height = entity.height + entity.Draw(proxyScreen) + proxyScreen.OffsetY += entity.height - 1 + } + } +} + +func (he *HTMLEntity) calculateBuffer(width, startX int, bare bool) int { + if len(he.Children) > 0 { + childStartX := 0 + for _, entity := range he.Children { + childStartX = entity.calculateBuffer(width-he.Indent, childStartX, bare) + he.height += entity.height - 1 + } + } + if len(he.Text) > 0 && width != he.prevWidth { + he.prevWidth = width + he.buffer = make([]string, 0, 1) + text := he.Text + if !he.Block { + he.startX = startX + } else { + startX = 0 + } + for { + extract := runewidth.Truncate(text, width-startX, "") + extract = trim(extract, text, bare) + he.buffer = append(he.buffer, extract) + text = text[len(extract):] + startX = 0 + if len(text) == 0 { + he.height += len(he.buffer) + // This entity is over, return the startX for the next entity + if he.Block { + // ...except if it's a block entity + return 0 + } + return runewidth.StringWidth(extract) + } + } + } + return 0 +} + +// Regular expressions used to split lines when calculating the buffer. +/*var ( + boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) + bareBoundaryPattern = regexp.MustCompile(`(\s+)`) + spacePattern = regexp.MustCompile(`\s+`) +)*/ + +func trim(extract, full string, bare bool) string { + if len(extract) == len(full) { + return extract + } + if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 { + extract = full[:len(extract)+spaces[1]] + } + regex := boundaryPattern + if bare { + regex = bareBoundaryPattern + } + matches := regex.FindAllStringIndex(extract, -1) + if len(matches) > 0 { + if match := matches[len(matches)-1]; len(match) >= 2 { + if until := match[1]; until < len(extract) { + extract = extract[:until] + } + } + } + return extract +} diff --git a/ui/messages/message.go b/ui/messages/message.go index 076cd87..e1888e6 100644 --- a/ui/messages/message.go +++ b/ui/messages/message.go @@ -27,7 +27,6 @@ type UIMessage interface { ifc.Message CalculateBuffer(preferences config.UserPreferences, width int) - RecalculateBuffer() Buffer() []tstring.TString Height() int PlainText() string diff --git a/ui/messages/parser/htmlparser.go b/ui/messages/parser/htmlparser.go index f01d3cb..3d1548b 100644 --- a/ui/messages/parser/htmlparser.go +++ b/ui/messages/parser/htmlparser.go @@ -26,11 +26,11 @@ import ( "github.com/lucasb-eyer/go-colorful" "golang.org/x/net/html" + "maunium.net/go/gomuks/ui/messages" "maunium.net/go/mautrix" "maunium.net/go/tcell" "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages/tstring" "maunium.net/go/gomuks/ui/widget" ) @@ -40,11 +40,6 @@ type htmlParser struct { room *rooms.Room } -type taggedTString struct { - tstring.TString - tag string -} - func AdjustStyleBold(style tcell.Style) tcell.Style { return style.Bold(true) } @@ -89,9 +84,9 @@ func digits(num int) int { return int(math.Floor(math.Log10(float64(num))) + 1) } -func (parser *htmlParser) listToTString(node *html.Node, stripLinebreak bool) tstring.TString { +func (parser *htmlParser) listToTString(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { ordered := node.Data == "ol" - taggedChildren := parser.nodeToTaggedTStrings(node.FirstChild, stripLinebreak) + listItems := parser.nodeToEntities(node.FirstChild, stripLinebreak) counter := 1 indentLength := 0 if ordered { @@ -100,13 +95,12 @@ func (parser *htmlParser) listToTString(node *html.Node, stripLinebreak bool) ts counter, _ = strconv.Atoi(start) } - longestIndex := (counter - 1) + len(taggedChildren) + longestIndex := (counter - 1) + len(listItems) indentLength = digits(longestIndex) } - indent := strings.Repeat(" ", indentLength+2) - var children []tstring.TString - for _, child := range taggedChildren { - if child.tag != "li" { + var children []*messages.HTMLEntity + for _, child := range listItems { + if child.Tag != "li" { continue } var prefix string @@ -116,31 +110,47 @@ func (parser *htmlParser) listToTString(node *html.Node, stripLinebreak bool) ts } else { prefix = "● " } - str := child.TString.Prepend(prefix) + child.Text = prefix + child.Text + child.Block = true + child.Indent = indentLength + 2 + children = append(children, child) counter++ - parts := str.Split('\n') - for i, part := range parts[1:] { - parts[i+1] = part.Prepend(indent) - } - str = tstring.Join(parts, "\n") - children = append(children, str) } - return tstring.Join(children, "\n") + return &messages.HTMLEntity{ + Tag: node.Data, + Text: "", + Style: tcell.StyleDefault, + Children: children, + Block: true, + Indent: 0, + } } -func (parser *htmlParser) basicFormatToTString(node *html.Node, stripLinebreak bool) tstring.TString { - str := parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak) +func (parser *htmlParser) basicFormatToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { + entity := &messages.HTMLEntity{ + Tag: node.Data, + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + } switch node.Data { case "b", "strong": - str.AdjustStyleFull(AdjustStyleBold) + entity.AdjustStyle(AdjustStyleBold) case "i", "em": - str.AdjustStyleFull(AdjustStyleItalic) + entity.AdjustStyle(AdjustStyleItalic) case "s", "del": - str.AdjustStyleFull(AdjustStyleStrikethrough) + entity.AdjustStyle(AdjustStyleStrikethrough) case "u", "ins": - str.AdjustStyleFull(AdjustStyleUnderline) + entity.AdjustStyle(AdjustStyleUnderline) + case "font": + fgColor, ok := parser.parseColor(node, "data-mx-color", "color") + if ok { + entity.AdjustStyle(AdjustStyleTextColor(fgColor)) + } + bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color") + if ok { + entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor)) + } } - return str + return entity } func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) { @@ -165,98 +175,112 @@ func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true } -func (parser *htmlParser) fontToTString(node *html.Node, stripLinebreak bool) tstring.TString { - str := parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak) - fgColor, ok := parser.parseColor(node, "data-mx-color", "color") - if ok { - str.AdjustStyleFull(AdjustStyleTextColor(fgColor)) - } - bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color") - if ok { - str.AdjustStyleFull(AdjustStyleBackgroundColor(bgColor)) - } - return str -} - -func (parser *htmlParser) headerToTString(node *html.Node, stripLinebreak bool) tstring.TString { - children := parser.nodeToTStrings(node.FirstChild, stripLinebreak) +func (parser *htmlParser) headerToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { length := int(node.Data[1] - '0') prefix := strings.Repeat("#", length) + " " - return tstring.Join(children, "").Prepend(prefix) + return (&messages.HTMLEntity{ + Tag: node.Data, + Text: prefix, + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + }).AdjustStyle(AdjustStyleBold) } -func (parser *htmlParser) blockquoteToTString(node *html.Node, stripLinebreak bool) tstring.TString { - str := parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak) - childrenArr := str.TrimSpace().Split('\n') - for index, child := range childrenArr { - childrenArr[index] = child.Prepend("> ") +func (parser *htmlParser) blockquoteToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { + return &messages.HTMLEntity{ + Tag: "blockquote", + Text: ">", + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + Block: true, + Indent: 2, } - return tstring.Join(childrenArr, "\n") } -func (parser *htmlParser) linkToTString(node *html.Node, stripLinebreak bool) tstring.TString { - str := parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak) +func (parser *htmlParser) linkToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { + entity := &messages.HTMLEntity{ + Tag: "a", + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + } href := parser.getAttribute(node, "href") if len(href) == 0 { - return str + return entity } match := matrixToURL.FindStringSubmatch(href) if len(match) == 2 { + entity.Children = nil pillTarget := match[1] + entity.Text = pillTarget if pillTarget[0] == '@' { if member := parser.room.GetMember(pillTarget); member != nil { - return tstring.NewColorTString(member.Displayname, widget.GetHashColor(pillTarget)) + entity.Text = member.Displayname + entity.Style = entity.Style.Foreground(widget.GetHashColor(pillTarget)) } } - return tstring.NewTString(pillTarget) } - return str.Append(fmt.Sprintf(" (%s)", href)) + // TODO add click action for links + return entity } -func (parser *htmlParser) tagToTString(node *html.Node, stripLinebreak bool) tstring.TString { +func (parser *htmlParser) codeblockToEntity(node *html.Node) *messages.HTMLEntity { + return &messages.HTMLEntity{ + Tag: "pre", + Children: parser.nodeToEntities(node.FirstChild, false), + Block: true, + } +} + +func (parser *htmlParser) tagNodeToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { switch node.Data { case "blockquote": - return parser.blockquoteToTString(node, stripLinebreak) + return parser.blockquoteToEntity(node, stripLinebreak) case "ol", "ul": return parser.listToTString(node, stripLinebreak) case "h1", "h2", "h3", "h4", "h5", "h6": - return parser.headerToTString(node, stripLinebreak) + return parser.headerToEntity(node, stripLinebreak) case "br": - return tstring.NewTString("\n") - case "b", "strong", "i", "em", "s", "del", "u", "ins": - return parser.basicFormatToTString(node, stripLinebreak) - case "font": - return parser.fontToTString(node, stripLinebreak) + return &messages.HTMLEntity{Tag: "br", Block: true} + case "b", "strong", "i", "em", "s", "del", "u", "ins", "font": + return parser.basicFormatToEntity(node, stripLinebreak) case "a": - return parser.linkToTString(node, stripLinebreak) - case "p": - return parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak).Append("\n") + return parser.linkToEntity(node, stripLinebreak) case "pre": - return parser.nodeToTString(node.FirstChild, false) + return parser.codeblockToEntity(node) default: - return parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak) + return &messages.HTMLEntity{ + Tag: node.Data, + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + Block: parser.isBlockTag(node.Data), + } } } -func (parser *htmlParser) singleNodeToTString(node *html.Node, stripLinebreak bool) taggedTString { +func (parser *htmlParser) singleNodeToEntity(node *html.Node, stripLinebreak bool) *messages.HTMLEntity { switch node.Type { case html.TextNode: if stripLinebreak { node.Data = strings.Replace(node.Data, "\n", "", -1) } - return taggedTString{tstring.NewTString(node.Data), "text"} + return &messages.HTMLEntity{ + Tag: "text", + Text: node.Data, + } case html.ElementNode: - return taggedTString{parser.tagToTString(node, stripLinebreak), node.Data} + return parser.tagNodeToEntity(node, stripLinebreak) case html.DocumentNode: - return taggedTString{parser.nodeToTagAwareTString(node.FirstChild, stripLinebreak), "html"} + return &messages.HTMLEntity{ + Tag: "html", + Children: parser.nodeToEntities(node.FirstChild, stripLinebreak), + Block: true, + } default: - return taggedTString{tstring.NewBlankTString(), "unknown"} + return nil } } -func (parser *htmlParser) nodeToTaggedTStrings(node *html.Node, stripLinebreak bool) (strs []taggedTString) { +func (parser *htmlParser) nodeToEntities(node *html.Node, stripLinebreak bool) (entities []*messages.HTMLEntity) { for ; node != nil; node = node.NextSibling { - strs = append(strs, parser.singleNodeToTString(node, stripLinebreak)) + if entity := parser.singleNodeToEntity(node, stripLinebreak); entity != nil { + entities = append(entities, entity) + } } return } @@ -272,51 +296,31 @@ func (parser *htmlParser) isBlockTag(tag string) bool { return false } -func (parser *htmlParser) nodeToTagAwareTString(node *html.Node, stripLinebreak bool) tstring.TString { - strs := parser.nodeToTaggedTStrings(node, stripLinebreak) - output := tstring.NewBlankTString() - for _, str := range strs { - tstr := str.TString - if parser.isBlockTag(str.tag) { - tstr = tstr.Prepend("\n").Append("\n") - } - output = output.AppendTString(tstr) - } - return output.TrimSpace() -} - -func (parser *htmlParser) nodeToTStrings(node *html.Node, stripLinebreak bool) (strs []tstring.TString) { - for ; node != nil; node = node.NextSibling { - strs = append(strs, parser.singleNodeToTString(node, stripLinebreak).TString) - } - return -} - -func (parser *htmlParser) nodeToTString(node *html.Node, stripLinebreak bool) tstring.TString { - return tstring.Join(parser.nodeToTStrings(node, stripLinebreak), "") -} - -func (parser *htmlParser) Parse(htmlData string) tstring.TString { +func (parser *htmlParser) Parse(htmlData string) *messages.HTMLEntity { node, _ := html.Parse(strings.NewReader(htmlData)) - return parser.nodeToTagAwareTString(node, true) + return parser.singleNodeToEntity(node, true) } // ParseHTMLMessage parses a HTML-formatted Matrix event into a UIMessage. -func ParseHTMLMessage(room *rooms.Room, evt *mautrix.Event, senderDisplayname string) tstring.TString { +func ParseHTMLMessage(room *rooms.Room, evt *mautrix.Event, senderDisplayname string) *messages.HTMLEntity { htmlData := evt.Content.FormattedBody htmlData = strings.Replace(htmlData, "\t", " ", -1) parser := htmlParser{room} - str := parser.Parse(htmlData) + root := parser.Parse(htmlData) + root.Block = false if evt.Content.MsgType == mautrix.MsgEmote { - str = tstring.Join([]tstring.TString{ - tstring.NewTString("* "), - tstring.NewColorTString(senderDisplayname, widget.GetHashColor(evt.Sender)), - tstring.NewTString(" "), - str, - }, "") + root = &messages.HTMLEntity{ + Tag: "emote", + Children: []*messages.HTMLEntity{ + {Text: "* "}, + {Text: senderDisplayname, Style: tcell.StyleDefault.Foreground(widget.GetHashColor(evt.Sender))}, + {Text: " "}, + root, + }, + } } - return str + return root } diff --git a/ui/messages/parser/parser.go b/ui/messages/parser/parser.go index 79e628a..4181c09 100644 --- a/ui/messages/parser/parser.go +++ b/ui/messages/parser/parser.go @@ -125,8 +125,7 @@ func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Eve switch evt.Content.MsgType { case "m.text", "m.notice", "m.emote": if evt.Content.Format == mautrix.FormatHTML { - text := ParseHTMLMessage(room, evt, displayname) - return messages.NewExpandedTextMessage(evt.ID, evt.Sender, displayname, evt.Content.MsgType, text, ts) + return messages.NewHTMLMessage(evt.ID, evt.Sender, displayname, evt.Content.MsgType, ParseHTMLMessage(room, evt, displayname), ts) } evt.Content.Body = strings.Replace(evt.Content.Body, "\t", " ", -1) return messages.NewTextMessage(evt.ID, evt.Sender, displayname, evt.Content.MsgType, evt.Content.Body, ts) -- cgit v1.2.3