diff --git a/ui/messages/html/blockquote.go b/ui/messages/html/blockquote.go new file mode 100644 index 0000000..30ce52e --- /dev/null +++ b/ui/messages/html/blockquote.go @@ -0,0 +1,49 @@ +// 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 html + +import ( + "fmt" + + "maunium.net/go/mauview" +) + +type BlockquoteEntity struct { + *BaseEntity +} + +const BlockQuoteChar = '>' + +func NewBlockquoteEntity(children []Entity) *BlockquoteEntity { + return &BlockquoteEntity{&BaseEntity{ + Tag: "blockquote", + Children: children, + Block: true, + Indent: 2, + }} +} + +func (be *BlockquoteEntity) Draw(screen mauview.Screen) { + be.BaseEntity.Draw(screen) + for y := 0; y < be.height; y++ { + screen.SetContent(0, y, BlockQuoteChar, nil, be.Style) + } +} + +func (be *BlockquoteEntity) String() string { + return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity) +} diff --git a/ui/messages/html/break.go b/ui/messages/html/break.go new file mode 100644 index 0000000..d400f00 --- /dev/null +++ b/ui/messages/html/break.go @@ -0,0 +1,28 @@ +// 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 html + +type BreakEntity struct { + *BaseEntity +} + +func NewBreakEntity() *BreakEntity { + return &BreakEntity{&BaseEntity{ + Tag: "br", + Block: true, + }} +} diff --git a/ui/messages/html/codeblock.go b/ui/messages/html/codeblock.go new file mode 100644 index 0000000..ec6181d --- /dev/null +++ b/ui/messages/html/codeblock.go @@ -0,0 +1,48 @@ +// 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 html + +import ( + "maunium.net/go/mauview" + "maunium.net/go/tcell" +) + +type CodeBlockEntity struct { + *BaseEntity + Background tcell.Style +} + +func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity { + return &CodeBlockEntity{ + BaseEntity: &BaseEntity{ + Tag: "pre", + Block: true, + Children: children, + }, + Background: background, + } +} + +func (ce *CodeBlockEntity) Draw(screen mauview.Screen) { + screen.Fill(' ', ce.Background) + ce.BaseEntity.Draw(screen) +} + +func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc) Entity { + // Don't allow adjusting code block style. + return ce +} diff --git a/ui/messages/parser/colormap.go b/ui/messages/html/colormap.go similarity index 99% rename from ui/messages/parser/colormap.go rename to ui/messages/html/colormap.go index 19cdf06..305309c 100644 --- a/ui/messages/parser/colormap.go +++ b/ui/messages/html/colormap.go @@ -1,11 +1,11 @@ // From https://github.com/golang/image/blob/master/colornames/colornames.go -package parser +package html import ( "image/color" ) -var ColorMap = map[string]color.RGBA{ +var colorMap = map[string]color.RGBA{ "aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255) "antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215) "aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) diff --git a/ui/messages/html/entity.go b/ui/messages/html/entity.go new file mode 100644 index 0000000..2ce37a8 --- /dev/null +++ b/ui/messages/html/entity.go @@ -0,0 +1,312 @@ +// 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 html + +import ( + "fmt" + "regexp" + "strings" + + "github.com/mattn/go-runewidth" + + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/mauview" + "maunium.net/go/tcell" +) + +// AdjustStyleFunc is a lambda function type to edit an existing tcell Style. +type AdjustStyleFunc func(tcell.Style) tcell.Style + +type Entity interface { + // AdjustStyle recursively changes the style of the entity and all its children. + AdjustStyle(AdjustStyleFunc) Entity + // Draw draws the entity onto the given mauview Screen. + Draw(screen mauview.Screen) + // IsBlock returns whether or not it's a block-type entity. + IsBlock() bool + // GetTag returns the HTML tag of the entity. + GetTag() string + // PlainText returns the plaintext content in the entity and all its children. + PlainText() string + // String returns a string representation of the entity struct. + String() string + // Clone creates a deep copy of the entity. + Clone() Entity + + // Height returns the render height of the entity. + Height() int + // CalculateBuffer prepares the entity and all its children for rendering with the given parameters + CalculateBuffer(width, startX int, bare bool) int + + getStartX() int +} + +type BaseEntity struct { + // The HTML tag of this entity. + Tag string + // Text in this entity. + Text string + // Style for this entity. + Style tcell.Style + // Child entities. + Children []Entity + // Whether or not this is a block-type entity. + Block bool + // Number of cells to indent children. + Indent int + + // Height to use for entity if both text and children are empty. + DefaultHeight int + + buffer []string + prevWidth int + startX int + height int +} + +// NewTextEntity creates a new text-only Entity. +func NewTextEntity(text string) *BaseEntity { + return &BaseEntity{ + Tag: "text", + Text: text, + } +} + +// AdjustStyle recursively changes the style of this entity and all its children. +func (he *BaseEntity) AdjustStyle(fn AdjustStyleFunc) Entity { + for _, child := range he.Children { + child.AdjustStyle(fn) + } + he.Style = fn(he.Style) + return he +} + +// IsBlock returns whether or not this is a block-type entity. +func (he *BaseEntity) IsBlock() bool { + return he.Block +} + +// GetTag returns the HTML tag of this entity. +func (he *BaseEntity) GetTag() string { + return he.Tag +} + +// Height returns the render height of this entity. +func (he *BaseEntity) Height() int { + return he.height +} + +func (he *BaseEntity) getStartX() int { + return he.startX +} + +// Clone creates a deep copy of this entity. +func (he *BaseEntity) Clone() Entity { + children := make([]Entity, len(he.Children)) + for i, child := range he.Children { + children[i] = child.Clone() + } + return &BaseEntity{ + Tag: he.Tag, + Text: he.Text, + Style: he.Style, + Children: children, + Block: he.Block, + Indent: he.Indent, + DefaultHeight: he.DefaultHeight, + } +} + +// String returns a textual representation of this BaseEntity struct. +func (he *BaseEntity) String() string { + var buf strings.Builder + buf.WriteString("&html.BaseEntity{\n") + _, _ = fmt.Fprintf(&buf, ` Tag="%s", Style=%d, Block=%t, Indent=%d, startX=%d, height=%d,`, + he.Tag, he.Style, he.Block, he.Indent, he.startX, he.height) + buf.WriteRune('\n') + _, _ = fmt.Fprintf(&buf, ` Buffer=["%s"]`, strings.Join(he.buffer, "\", \"")) + if len(he.Text) > 0 { + buf.WriteString(",\n") + _, _ = fmt.Fprintf(&buf, ` Text="%s"`, he.Text) + } + if len(he.Children) > 0 { + buf.WriteString(",\n") + buf.WriteString(" Children={") + for _, child := range he.Children { + buf.WriteString("\n ") + buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n ")) + } + buf.WriteString("\n },") + } + buf.WriteString("\n},\n") + return buf.String() +} + +// PlainText returns the plaintext content in this entity and all its children. +func (he *BaseEntity) PlainText() string { + if len(he.Children) == 0 { + return he.Text + } + var buf strings.Builder + buf.WriteString(he.Text) + newlined := false + for _, child := range he.Children { + if child.IsBlock() && !newlined { + buf.WriteRune('\n') + } + newlined = false + buf.WriteString(child.PlainText()) + if child.IsBlock() { + buf.WriteRune('\n') + newlined = true + } + } + return buf.String() +} + +// Draw draws this entity onto the given mauview Screen. +func (he *BaseEntity) 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 { + prevBreak := false + proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: he.Indent, Width: width - he.Indent, Style: he.Style} + for i, entity := range he.Children { + if i != 0 && entity.getStartX() == 0 { + proxyScreen.OffsetY++ + } + proxyScreen.Height = entity.Height() + entity.Draw(proxyScreen) + proxyScreen.SetStyle(he.Style) + proxyScreen.OffsetY += entity.Height() - 1 + _, isBreak := entity.(*BreakEntity) + if prevBreak && isBreak { + proxyScreen.OffsetY++ + } + prevBreak = isBreak + } + } +} + +// CalculateBuffer prepares this entity and all its children for rendering with the given parameters +func (he *BaseEntity) CalculateBuffer(width, startX int, bare bool) int { + he.startX = startX + if he.Block { + he.startX = 0 + } + he.height = 0 + if len(he.Children) > 0 { + childStartX := he.startX + prevBreak := false + for _, entity := range he.Children { + if entity.IsBlock() || childStartX == 0 || he.height == 0 { + he.height++ + } + childStartX = entity.CalculateBuffer(width-he.Indent, childStartX, bare) + he.height += entity.Height() - 1 + _, isBreak := entity.(*BreakEntity) + if prevBreak && isBreak { + he.height++ + } + prevBreak = isBreak + } + if len(he.Text) == 0 && !he.Block { + return childStartX + } + } + if len(he.Text) > 0 { + he.prevWidth = width + if he.buffer == nil { + he.buffer = []string{} + } + bufPtr := 0 + text := he.Text + textStartX := he.startX + for { + // TODO add option no wrap and character wrap options + extract := runewidth.Truncate(text, width-textStartX, "") + extract, wordWrapped := trim(extract, text, bare) + if !wordWrapped && textStartX > 0 { + if bufPtr < len(he.buffer) { + he.buffer[bufPtr] = "" + } else { + he.buffer = append(he.buffer, "") + } + bufPtr++ + textStartX = 0 + continue + } + if bufPtr < len(he.buffer) { + he.buffer[bufPtr] = extract + } else { + he.buffer = append(he.buffer, extract) + } + bufPtr++ + text = text[len(extract):] + if len(text) == 0 { + he.buffer = he.buffer[:bufPtr] + 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 textStartX + runewidth.StringWidth(extract) + } + textStartX = 0 + } + } + if len(he.Text) == 0 && len(he.Children) == 0 { + he.height = he.DefaultHeight + } + return he.startX +} + +var ( + boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) + bareBoundaryPattern = regexp.MustCompile(`(\s+)`) + spacePattern = regexp.MustCompile(`\s+`) +) + +func trim(extract, full string, bare bool) (string, bool) { + if len(extract) == len(full) { + return extract, true + } + 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, true + } + } + } + return extract, len(extract) > 0 && extract[len(extract)-1] == ' ' +} diff --git a/ui/messages/html/list.go b/ui/messages/html/list.go new file mode 100644 index 0000000..9f45b92 --- /dev/null +++ b/ui/messages/html/list.go @@ -0,0 +1,80 @@ +// 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 html + +import ( + "fmt" + "math" + "strings" + + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/mauview" +) + +type ListEntity struct { + *BaseEntity + Ordered bool + Start int +} + +func digits(num int) int { + if num <= 0 { + return 0 + } + return int(math.Floor(math.Log10(float64(num))) + 1) +} + +func NewListEntity(ordered bool, start int, children []Entity) *ListEntity { + entity := &ListEntity{ + BaseEntity: &BaseEntity{ + Tag: "ul", + Children: children, + Block: true, + Indent: 2, + }, + Ordered: ordered, + Start: start, + } + if ordered { + entity.Tag = "ol" + entity.Indent += digits(start + len(children) - 1) + } + return entity +} + +func (le *ListEntity) Draw(screen mauview.Screen) { + width, _ := screen.Size() + + proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style} + for i, entity := range le.Children { + proxyScreen.Height = entity.Height() + if le.Ordered { + number := le.Start + i + line := fmt.Sprintf("%d. %s", number, strings.Repeat(" ", le.Indent-2-digits(number))) + widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style) + } else { + screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style) + } + entity.Draw(proxyScreen) + proxyScreen.SetStyle(le.Style) + proxyScreen.OffsetY += entity.Height() + } +} + +func (le *ListEntity) String() string { + return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity) +} diff --git a/ui/messages/parser/htmlparser.go b/ui/messages/html/parser.go similarity index 78% rename from ui/messages/parser/htmlparser.go rename to ui/messages/html/parser.go index 728a201..dbd487e 100644 --- a/ui/messages/parser/htmlparser.go +++ b/ui/messages/html/parser.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package parser +package html import ( "regexp" @@ -31,7 +31,6 @@ import ( "maunium.net/go/tcell" "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" ) @@ -80,7 +79,7 @@ func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string return "" } -func (parser *htmlParser) listToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) listToEntity(node *html.Node) Entity { children := parser.nodeToEntities(node.FirstChild) ordered := node.Data == "ol" start := 1 @@ -99,11 +98,11 @@ func (parser *htmlParser) listToEntity(node *html.Node) messages.HTMLEntity { listItems = append(listItems, child) } } - return messages.NewListEntity(ordered, start, listItems) + return NewListEntity(ordered, start, listItems) } -func (parser *htmlParser) basicFormatToEntity(node *html.Node) messages.HTMLEntity { - entity := &messages.BaseHTMLEntity{ +func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity { + entity := &BaseEntity{ Tag: node.Data, Children: parser.nodeToEntities(node.FirstChild), } @@ -140,7 +139,7 @@ func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) cful, err := colorful.Hex(hex) if err != nil { - color2, found := ColorMap[strings.ToLower(hex)] + color2, found := colorMap[strings.ToLower(hex)] if !found { return } @@ -151,22 +150,22 @@ func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true } -func (parser *htmlParser) headerToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) headerToEntity(node *html.Node) Entity { length := int(node.Data[1] - '0') prefix := strings.Repeat("#", length) + " " - return (&messages.BaseHTMLEntity{ + return (&BaseEntity{ Tag: node.Data, Text: prefix, Children: parser.nodeToEntities(node.FirstChild), }).AdjustStyle(AdjustStyleBold) } -func (parser *htmlParser) blockquoteToEntity(node *html.Node) messages.HTMLEntity { - return messages.NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild)) +func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity { + return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild)) } -func (parser *htmlParser) linkToEntity(node *html.Node) messages.HTMLEntity { - entity := &messages.BaseHTMLEntity{ +func (parser *htmlParser) linkToEntity(node *html.Node) Entity { + entity := &BaseEntity{ Tag: "a", Children: parser.nodeToEntities(node.FirstChild), } @@ -190,7 +189,7 @@ func (parser *htmlParser) linkToEntity(node *html.Node) messages.HTMLEntity { return entity } -func (parser *htmlParser) imageToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) imageToEntity(node *html.Node) Entity { alt := parser.getAttribute(node, "alt") if len(alt) == 0 { alt = parser.getAttribute(node, "title") @@ -198,7 +197,7 @@ func (parser *htmlParser) imageToEntity(node *html.Node) messages.HTMLEntity { alt = "[inline image]" } } - entity := &messages.BaseHTMLEntity{ + entity := &BaseEntity{ Tag: "img", Text: alt, } @@ -222,7 +221,7 @@ func styleEntryToStyle(se chroma.StyleEntry) tcell.Style { Background(colourToColor(se.Background)) } -func (parser *htmlParser) syntaxHighlight(text, language string) messages.HTMLEntity { +func (parser *htmlParser) syntaxHighlight(text, language string) Entity { lexer := lexers.Get(strings.ToLower(language)) if lexer == nil { return nil @@ -235,12 +234,12 @@ func (parser *htmlParser) syntaxHighlight(text, language string) messages.HTMLEn style := styles.SolarizedDark tokens := iter.Tokens() - children := make([]messages.HTMLEntity, len(tokens)) + children := make([]Entity, len(tokens)) for i, token := range tokens { if token.Value == "\n" { - children[i] = &messages.BaseHTMLEntity{Block: true, Tag: "br"} + children[i] = &BaseEntity{Block: true, Tag: "br"} } else { - children[i] = &messages.BaseHTMLEntity{ + children[i] = &BaseEntity{ Tag: token.Type.String(), Text: token.Value, Style: styleEntryToStyle(style.Get(token.Type)), @@ -249,10 +248,10 @@ func (parser *htmlParser) syntaxHighlight(text, language string) messages.HTMLEn } } } - return messages.NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background))) + return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background))) } -func (parser *htmlParser) codeblockToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity { lang := "plaintext" // TODO allow disabling syntax highlighting if node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { @@ -266,14 +265,14 @@ func (parser *htmlParser) codeblockToEntity(node *html.Node) messages.HTMLEntity } } parser.keepLinebreak = true - text := (&messages.BaseHTMLEntity{ + text := (&BaseEntity{ Children: parser.nodeToEntities(node.FirstChild), }).PlainText() parser.keepLinebreak = false return parser.syntaxHighlight(text, lang) } -func (parser *htmlParser) tagNodeToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity { switch node.Data { case "blockquote": return parser.blockquoteToEntity(node) @@ -282,7 +281,7 @@ func (parser *htmlParser) tagNodeToEntity(node *html.Node) messages.HTMLEntity { case "h1", "h2", "h3", "h4", "h5", "h6": return parser.headerToEntity(node) case "br": - return messages.NewBreakEntity() + return NewBreakEntity() case "b", "strong", "i", "em", "s", "del", "u", "ins", "font": return parser.basicFormatToEntity(node) case "a": @@ -292,7 +291,7 @@ func (parser *htmlParser) tagNodeToEntity(node *html.Node) messages.HTMLEntity { case "pre": return parser.codeblockToEntity(node) default: - return &messages.BaseHTMLEntity{ + return &BaseEntity{ Tag: node.Data, Children: parser.nodeToEntities(node.FirstChild), Block: parser.isBlockTag(node.Data), @@ -300,13 +299,13 @@ func (parser *htmlParser) tagNodeToEntity(node *html.Node) messages.HTMLEntity { } } -func (parser *htmlParser) singleNodeToEntity(node *html.Node) messages.HTMLEntity { +func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity { switch node.Type { case html.TextNode: if !parser.keepLinebreak { node.Data = strings.ReplaceAll(node.Data, "\n", "") } - return &messages.BaseHTMLEntity{ + return &BaseEntity{ Tag: "text", Text: node.Data, } @@ -316,7 +315,7 @@ func (parser *htmlParser) singleNodeToEntity(node *html.Node) messages.HTMLEntit if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil { return parser.singleNodeToEntity(node.FirstChild) } - return &messages.BaseHTMLEntity{ + return &BaseEntity{ Tag: "html", Children: parser.nodeToEntities(node.FirstChild), Block: true, @@ -326,7 +325,7 @@ func (parser *htmlParser) singleNodeToEntity(node *html.Node) messages.HTMLEntit } } -func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []messages.HTMLEntity) { +func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) { for ; node != nil; node = node.NextSibling { if entity := parser.singleNodeToEntity(node); entity != nil { entities = append(entities, entity) @@ -346,27 +345,32 @@ func (parser *htmlParser) isBlockTag(tag string) bool { return false } -func (parser *htmlParser) Parse(htmlData string) messages.HTMLEntity { +func (parser *htmlParser) Parse(htmlData string) Entity { node, _ := html.Parse(strings.NewReader(htmlData)) return parser.singleNodeToEntity(node) } -// ParseHTMLMessage parses a HTML-formatted Matrix event into a UIMessage. -func ParseHTMLMessage(room *rooms.Room, evt *mautrix.Event, senderDisplayname string) messages.HTMLEntity { +const TabLength = 4 + +// Parse parses a HTML-formatted Matrix event into a UIMessage. +func Parse(room *rooms.Room, evt *mautrix.Event, senderDisplayname string) Entity { htmlData := evt.Content.FormattedBody - htmlData = strings.Replace(htmlData, "\t", " ", -1) + if evt.Content.Format != mautrix.FormatHTML { + htmlData = strings.ReplaceAll(html.EscapeString(evt.Content.Body), "\n", "
") + } + htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1) parser := htmlParser{room: room} root := parser.Parse(htmlData) - root.(*messages.BaseHTMLEntity).Block = false + root.(*BaseEntity).Block = false if evt.Content.MsgType == mautrix.MsgEmote { - root = &messages.BaseHTMLEntity{ + root = &BaseEntity{ Tag: "emote", - Children: []messages.HTMLEntity{ - messages.NewHTMLTextEntity("* "), - messages.NewHTMLTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender))), - messages.NewHTMLTextEntity(" "), + Children: []Entity{ + NewTextEntity("* "), + NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender))), + NewTextEntity(" "), root, }, } diff --git a/ui/messages/htmlmessage.go b/ui/messages/htmlmessage.go index eac0841..ed8b7c1 100644 --- a/ui/messages/htmlmessage.go +++ b/ui/messages/htmlmessage.go @@ -17,40 +17,33 @@ package messages import ( - "fmt" - "math" - "strings" - "time" - - "github.com/mattn/go-runewidth" + "maunium.net/go/gomuks/ui/messages/html" "maunium.net/go/mautrix" "maunium.net/go/mauview" "maunium.net/go/tcell" "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/ui/widget" ) type HTMLMessage struct { BaseMessage - Root HTMLEntity - - FocusedBackground tcell.Color - - focused bool + Root html.Entity + FocusedBg tcell.Color + focused bool } -func NewHTMLMessage(id, sender, displayname string, msgtype mautrix.MessageType, root HTMLEntity, timestamp time.Time) UIMessage { +func NewHTMLMessage(event *mautrix.Event, displayname string, root html.Entity) UIMessage { return &HTMLMessage{ - BaseMessage: newBaseMessage(id, sender, displayname, msgtype, timestamp), + BaseMessage: newBaseMessage(event.ID, event.Sender, displayname, event.Content.MsgType, unixToTime(event.Timestamp)), Root: root, } } + func (hw *HTMLMessage) Draw(screen mauview.Screen) { if hw.focused { - screen.SetStyle(tcell.StyleDefault.Background(hw.FocusedBackground)) + screen.SetStyle(tcell.StyleDefault.Background(hw.FocusedBg)) } screen.Clear() hw.Root.Draw(screen) @@ -82,7 +75,7 @@ func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width } // TODO account for bare messages in initial startX startX := 0 - hw.Root.calculateBuffer(width, startX, preferences.BareMessageView) + hw.Root.CalculateBuffer(width, startX, preferences.BareMessageView) } func (hw *HTMLMessage) Height() int { @@ -96,359 +89,3 @@ func (hw *HTMLMessage) PlainText() string { func (hw *HTMLMessage) NotificationContent() string { return hw.Root.PlainText() } - -type AdjustStyleFunc func(tcell.Style) tcell.Style - -type HTMLEntity interface { - AdjustStyle(AdjustStyleFunc) HTMLEntity - Draw(screen mauview.Screen) - IsBlock() bool - GetTag() string - PlainText() string - String() string - Height() int - - calculateBuffer(width, startX int, bare bool) int - getStartX() int -} - -type BlockquoteEntity struct { - *BaseHTMLEntity -} - -func NewBlockquoteEntity(children []HTMLEntity) *BlockquoteEntity { - return &BlockquoteEntity{&BaseHTMLEntity{ - Tag: "blockquote", - Children: children, - Block: true, - Indent: 2, - }} -} - -func (be *BlockquoteEntity) Draw(screen mauview.Screen) { - be.BaseHTMLEntity.Draw(screen) - for y := 0; y < be.height; y++ { - screen.SetContent(0, y, '>', nil, be.Style) - } -} - -func (be *BlockquoteEntity) String() string { - return fmt.Sprintf("&BlockquoteEntity{%s},\n", be.BaseHTMLEntity) -} - -type ListEntity struct { - *BaseHTMLEntity - Ordered bool - Start int -} - -func digits(num int) int { - if num <= 0 { - return 0 - } - return int(math.Floor(math.Log10(float64(num))) + 1) -} - -func NewListEntity(ordered bool, start int, children []HTMLEntity) *ListEntity { - entity := &ListEntity{ - BaseHTMLEntity: &BaseHTMLEntity{ - Tag: "ul", - Children: children, - Block: true, - Indent: 2, - }, - Ordered: ordered, - Start: start, - } - if ordered { - entity.Tag = "ol" - entity.Indent += digits(start + len(children) - 1) - } - return entity -} - -func (le *ListEntity) Draw(screen mauview.Screen) { - width, _ := screen.Size() - - proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style} - for i, entity := range le.Children { - proxyScreen.Height = entity.Height() - if le.Ordered { - number := le.Start + i - line := fmt.Sprintf("%d. %s", number, strings.Repeat(" ", le.Indent-2-digits(number))) - widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style) - } else { - screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style) - } - entity.Draw(proxyScreen) - proxyScreen.SetStyle(le.Style) - proxyScreen.OffsetY += entity.Height() - } -} - -func (le *ListEntity) String() string { - return fmt.Sprintf("&ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseHTMLEntity) -} - -type CodeBlockEntity struct { - *BaseHTMLEntity - Background tcell.Style -} - -func NewCodeBlockEntity(children []HTMLEntity, background tcell.Style) *CodeBlockEntity { - return &CodeBlockEntity{ - BaseHTMLEntity: &BaseHTMLEntity{ - Tag: "pre", - Block: true, - Children: children, - }, - Background: background, - } -} - -func (ce *CodeBlockEntity) Draw(screen mauview.Screen) { - screen.Fill(' ', ce.Background) - ce.BaseHTMLEntity.Draw(screen) -} - -func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc) HTMLEntity { - return ce -} - -type BreakEntity struct { - *BaseHTMLEntity -} - -func NewBreakEntity() *BreakEntity { - return &BreakEntity{&BaseHTMLEntity{ - Tag: "br", - Block: true, - }} -} - -type BaseHTMLEntity struct { - // Permanent variables - Tag string - Text string - Style tcell.Style - Children []HTMLEntity - Block bool - Indent int - - DefaultHeight int - - // Non-permanent variables (calculated buffer data) - buffer []string - prevWidth int - startX int - height int -} - -func NewHTMLTextEntity(text string) *BaseHTMLEntity { - return &BaseHTMLEntity{ - Tag: "text", - Text: text, - } -} - -func NewHTMLEntity(tag string, children []HTMLEntity, block bool) *BaseHTMLEntity { - return &BaseHTMLEntity{ - Tag: tag, - Children: children, - Block: block, - } -} - -func (he *BaseHTMLEntity) AdjustStyle(fn AdjustStyleFunc) HTMLEntity { - for _, child := range he.Children { - child.AdjustStyle(fn) - } - he.Style = fn(he.Style) - return he -} - -func (he *BaseHTMLEntity) IsBlock() bool { - return he.Block -} - -func (he *BaseHTMLEntity) GetTag() string { - return he.Tag -} - -func (he *BaseHTMLEntity) Height() int { - return he.height -} - -func (he *BaseHTMLEntity) getStartX() int { - return he.startX -} - -func (he *BaseHTMLEntity) String() string { - var buf strings.Builder - buf.WriteString("&BaseHTMLEntity{\n") - _, _ = fmt.Fprintf(&buf, ` Tag="%s", Style=%d, Block=%t, Indent=%d, startX=%d, height=%d,`, - he.Tag, he.Style, he.Block, he.Indent, he.startX, he.height) - buf.WriteRune('\n') - _, _ = fmt.Fprintf(&buf, ` Buffer=["%s"]`, strings.Join(he.buffer, "\", \"")) - if len(he.Text) > 0 { - buf.WriteString(",\n") - _, _ = fmt.Fprintf(&buf, ` Text="%s"`, he.Text) - } - if len(he.Children) > 0 { - buf.WriteString(",\n") - buf.WriteString(" Children={") - for _, child := range he.Children { - buf.WriteString("\n ") - buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n ")) - } - buf.WriteString("\n },") - } - buf.WriteString("\n},\n") - return buf.String() -} - -func (he *BaseHTMLEntity) PlainText() string { - if len(he.Children) == 0 { - return he.Text - } - var buf strings.Builder - buf.WriteString(he.Text) - newlined := false - for _, child := range he.Children { - if child.IsBlock() && !newlined { - buf.WriteRune('\n') - } - newlined = false - buf.WriteString(child.PlainText()) - if child.IsBlock() { - buf.WriteRune('\n') - newlined = true - } - } - return buf.String() -} - -func (he *BaseHTMLEntity) 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 { - prevBreak := false - proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: he.Indent, Width: width - he.Indent, Style: he.Style} - for i, entity := range he.Children { - if i != 0 && entity.getStartX() == 0 { - proxyScreen.OffsetY++ - } - proxyScreen.Height = entity.Height() - entity.Draw(proxyScreen) - proxyScreen.SetStyle(he.Style) - proxyScreen.OffsetY += entity.Height() - 1 - _, isBreak := entity.(*BreakEntity) - if prevBreak && isBreak { - proxyScreen.OffsetY++ - } - prevBreak = isBreak - } - } -} - -func (he *BaseHTMLEntity) calculateBuffer(width, startX int, bare bool) int { - he.startX = startX - if he.Block { - he.startX = 0 - } - he.height = 0 - if len(he.Children) > 0 { - childStartX := he.startX - prevBreak := false - for _, entity := range he.Children { - if entity.IsBlock() || childStartX == 0 || he.height == 0 { - he.height++ - } - childStartX = entity.calculateBuffer(width-he.Indent, childStartX, bare) - he.height += entity.Height() - 1 - _, isBreak := entity.(*BreakEntity) - if prevBreak && isBreak { - he.height++ - } - prevBreak = isBreak - } - if len(he.Text) == 0 && !he.Block { - return childStartX - } - } - if len(he.Text) > 0 { - he.prevWidth = width - if he.buffer == nil { - he.buffer = []string{} - } - bufPtr := 0 - text := he.Text - textStartX := he.startX - for { - // TODO add option no wrap and character wrap options - extract := runewidth.Truncate(text, width-textStartX, "") - extract, wordWrapped := trim(extract, text, bare) - if !wordWrapped && textStartX > 0 { - if bufPtr < len(he.buffer) { - he.buffer[bufPtr] = "" - } else { - he.buffer = append(he.buffer, "") - } - bufPtr++ - textStartX = 0 - continue - } - if bufPtr < len(he.buffer) { - he.buffer[bufPtr] = extract - } else { - he.buffer = append(he.buffer, extract) - } - bufPtr++ - text = text[len(extract):] - if len(text) == 0 { - he.buffer = he.buffer[:bufPtr] - 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 textStartX + runewidth.StringWidth(extract) - } - textStartX = 0 - } - } - if len(he.Text) == 0 && len(he.Children) == 0 { - he.height = he.DefaultHeight - } - return he.startX -} - -func trim(extract, full string, bare bool) (string, bool) { - if len(extract) == len(full) { - return extract, true - } - 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, true - } - } - } - return extract, len(extract) > 0 && extract[len(extract)-1] == ' ' -} diff --git a/ui/messages/parser/parser.go b/ui/messages/parser.go similarity index 92% rename from ui/messages/parser/parser.go rename to ui/messages/parser.go index e48bd5f..ae0606d 100644 --- a/ui/messages/parser/parser.go +++ b/ui/messages/parser.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package parser +package messages import ( "fmt" @@ -22,17 +22,18 @@ import ( "strings" "time" + "maunium.net/go/mautrix" + "maunium.net/go/tcell" + "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/messages/tstring" "maunium.net/go/gomuks/ui/widget" - "maunium.net/go/mautrix" - "maunium.net/go/tcell" + htmlp "maunium.net/go/gomuks/ui/messages/html" ) -func ParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) messages.UIMessage { +func ParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) UIMessage { switch evt.Type { case mautrix.EventSticker: evt.Content.MsgType = mautrix.MsgImage @@ -55,7 +56,7 @@ func unixToTime(unix int64) time.Time { return timestamp } -func ParseStateEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) messages.UIMessage { +func ParseStateEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) UIMessage { displayname := evt.Sender member := room.GetMember(evt.Sender) if member != nil { @@ -91,10 +92,10 @@ func ParseStateEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix. text = ParseAliasEvent(evt, displayname) } ts := unixToTime(evt.Timestamp) - return messages.NewExpandedTextMessage(evt.ID, evt.Sender, displayname, mautrix.MessageType(evt.Type.Type), text, ts) + return NewExpandedTextMessage(evt.ID, evt.Sender, displayname, mautrix.MessageType(evt.Type.Type), text, ts) } -func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) messages.UIMessage { +func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *mautrix.Event) UIMessage { displayname := evt.Sender member := room.GetMember(evt.Sender) if member != nil { @@ -125,16 +126,16 @@ 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 { - return messages.NewHTMLMessage(evt.ID, evt.Sender, displayname, evt.Content.MsgType, ParseHTMLMessage(room, evt, displayname), ts) + return NewHTMLMessage(evt, displayname, htmlp.Parse(room, evt, displayname)) } 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) + return NewTextMessage(evt.ID, evt.Sender, displayname, evt.Content.MsgType, evt.Content.Body, ts) case "m.image": data, hs, id, err := matrix.Download(evt.Content.URL) if err != nil { debug.Printf("Failed to download %s: %v", evt.Content.URL, err) } - return messages.NewImageMessage(matrix, evt.ID, evt.Sender, displayname, evt.Content.MsgType, evt.Content.Body, hs, id, data, ts) + return NewImageMessage(matrix, evt.ID, evt.Sender, displayname, evt.Content.MsgType, evt.Content.Body, hs, id, data, ts) } return nil } @@ -213,14 +214,14 @@ func getMembershipEventContent(room *rooms.Room, evt *mautrix.Event) (sender str return } -func ParseMembershipEvent(room *rooms.Room, evt *mautrix.Event) messages.UIMessage { +func ParseMembershipEvent(room *rooms.Room, evt *mautrix.Event) UIMessage { displayname, text := getMembershipEventContent(room, evt) if len(text) == 0 { return nil } ts := unixToTime(evt.Timestamp) - return messages.NewExpandedTextMessage(evt.ID, evt.Sender, displayname, "m.room.member", text, ts) + return NewExpandedTextMessage(evt.ID, evt.Sender, displayname, "m.room.member", text, ts) } func ParseAliasEvent(evt *mautrix.Event, displayname string) tstring.TString { diff --git a/ui/messages/parser/doc.go b/ui/messages/parser/doc.go deleted file mode 100644 index 8f91a1d..0000000 --- a/ui/messages/parser/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package parser contains the functions for parsing Matrix events into UIMessage objects. -package parser diff --git a/ui/view-main.go b/ui/view-main.go index 382d9c8..ff46c79 100644 --- a/ui/view-main.go +++ b/ui/view-main.go @@ -35,7 +35,7 @@ import ( "maunium.net/go/gomuks/lib/notification" "maunium.net/go/gomuks/matrix/pushrules" "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages/parser" + "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" ) @@ -485,5 +485,5 @@ func (view *MainView) LoadHistory(room string) { } func (view *MainView) ParseEvent(roomView ifc.RoomView, evt *mautrix.Event) ifc.Message { - return parser.ParseEvent(view.matrix, roomView.MxRoom(), evt) + return messages.ParseEvent(view.matrix, roomView.MxRoom(), evt) }