From 32e6ed8b210eb0244c894040fc4a03e899b3c8bf Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Sat, 27 Jun 2020 18:35:26 -0700 Subject: [PATCH] Reverted TextView; added image popover on click --- go.mod | 2 - internal/gts/httputil/image.go | 2 +- internal/ui/imgview/imgview.go | 82 +++++++++++++++++++ .../container/cozy/message_collapsed.go | 1 - .../messages/container/cozy/message_full.go | 1 - internal/ui/messages/message/message.go | 62 ++++++-------- internal/ui/messages/message/sending.go | 6 +- internal/ui/rich/parser/markup.go | 48 ++++++++++- 8 files changed, 158 insertions(+), 46 deletions(-) create mode 100644 internal/ui/imgview/imgview.go diff --git a/go.mod b/go.mod index 97bab34..34df7e8 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,6 @@ go 1.14 replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6 -replace github.com/diamondburned/cchat-discord => ../cchat-discord/ - require ( github.com/Xuanwo/go-locale v0.2.0 github.com/diamondburned/cchat v0.0.34 diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index 72ad07a..bd7bba1 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -50,7 +50,7 @@ func AsyncImage(img ImageContainer, url string, procs ...imgutil.Processor) { go syncImage(ctx, l, url, procs, gif) } -// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache. +// AsyncImageSized resizes using GdkPixbuf. This method uses the cache. func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...imgutil.Processor) { if url == "" { return diff --git a/internal/ui/imgview/imgview.go b/internal/ui/imgview/imgview.go new file mode 100644 index 0000000..cc18d40 --- /dev/null +++ b/internal/ui/imgview/imgview.go @@ -0,0 +1,82 @@ +package imgview + +import ( + "net/url" + "path" + "strings" + + "github.com/diamondburned/cchat-gtk/internal/gts/httputil" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +const ( + MaxWidth = 350 + MaxHeight = 350 +) + +type WidgetConnector interface { + gtk.IWidget + primitives.Connector +} + +var _ WidgetConnector = (*gtk.Label)(nil) + +func BindTooltip(connector WidgetConnector) { + // This implementation doesn't seem like a good idea. First off, is the + // closure really garbage collected? If it's not, then we have some huge + // issues. Second, if the closure is garbage collected, then when? If it's + // not garbage collecteed, then not only are we leaking 2 float64s per + // message, but we're also keeping alive the widget. + + var x, y float64 + connector.Connect("motion-notify-event", func(w gtk.IWidget, ev *gdk.Event) { + mev := gdk.EventMotionNewFromEvent(ev) + x, y = mev.MotionVal() + }) + + connector.Connect("activate-link", func(c WidgetConnector, uri string) bool { + switch ext(uri) { + case ".jpg", ".jpeg", ".png", ".webp", ".gif": + // Make a new rectangle to use in the popover. + r := gdk.Rectangle{} + r.SetX(int(x)) + r.SetY(int(y)) + + // Make a new image that's asynchronously fetched. + img, _ := gtk.ImageNewFromIconName("image-loading", gtk.ICON_SIZE_BUTTON) + img.SetMarginStart(5) + img.SetMarginEnd(5) + img.SetMarginTop(5) + img.SetMarginBottom(5) + img.Show() + + // Cap the width and height if requested. + var w, h = parser.FragmentImageSize(uri, MaxWidth, MaxHeight) + httputil.AsyncImageSized(img, uri, w, h) + + p, _ := gtk.PopoverNew(c) + p.SetPointingTo(r) + p.Connect("closed", img.Destroy) // on close, destroy image + p.Add(img) + p.Popup() + + return true + + default: + return false + } + }) +} + +// ext parses and sanitizes the extension to something comparable. +func ext(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return strings.ToLower(path.Ext(uri)) + } + + return strings.ToLower(path.Ext(u.Path)) +} diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index cd0a785..75bd5e3 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -24,7 +24,6 @@ func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage { func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage { // Set Timestamp's padding accordingly to Avatar's. - gc.Timestamp.SetProperty("ypad", 1) // trivial detail gc.Timestamp.SetSizeRequest(AvatarSize, -1) gc.Timestamp.SetVAlign(gtk.ALIGN_START) gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2) diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index 8d49608..abe17ea 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -57,7 +57,6 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage { // We don't call avatar.Show(). That's called in Attach. // Style the timestamp accordingly. - gc.Timestamp.SetProperty("ypad", 1) // trivial detail gc.Timestamp.SetXAlign(0.0) // left-align gc.Timestamp.SetVAlign(gtk.ALIGN_END) // bottom-align gc.Timestamp.SetMarginStart(0) // clear margins diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 8a56296..0350552 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/humanize" "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/imgview" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser" @@ -13,7 +14,6 @@ import ( "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" - "github.com/pkg/errors" ) type Container interface { @@ -55,8 +55,7 @@ type GenericContainer struct { Timestamp *gtk.Label Username *gtk.Label - Content *gtk.TextView - CBuffer *gtk.TextBuffer + Content *gtk.Label MenuItems []menu.Item } @@ -90,20 +89,18 @@ func NewEmptyContainer() *GenericContainer { user.SetMaxWidthChars(35) user.SetLineWrap(true) user.SetLineWrapMode(pango.WRAP_WORD_CHAR) - user.SetHAlign(gtk.ALIGN_END) + user.SetXAlign(1) // right align user.SetVAlign(gtk.ALIGN_START) user.SetSelectable(true) user.Show() - content, _ := gtk.TextViewNew() - content.SetHExpand(true) - content.SetWrapMode(gtk.WRAP_WORD_CHAR) - content.SetCursorVisible(false) - content.SetEditable(false) + content, _ := gtk.LabelNew("") + content.SetLineWrap(true) + content.SetLineWrapMode(pango.WRAP_WORD_CHAR) + content.SetXAlign(0) // left align + // content.SetSelectable(true) content.Show() - cbuffer, _ := content.GetBuffer() - // Add CSS classes. primitives.AddClass(ts, "message-time") primitives.AddClass(user, "message-author") @@ -113,31 +110,17 @@ func NewEmptyContainer() *GenericContainer { Timestamp: ts, Username: user, Content: content, - CBuffer: cbuffer, } - gc.Content.SetProperty("populate-all", true) - gc.Content.Connect("populate-popup", func(tv *gtk.TextView, popup *gtk.Widget) { - v, err := popup.Cast() - if err != nil { - log.Error(errors.Wrap(err, "Failed to cast popup to IWidget")) - return - } - - switch popup := v.(type) { - case menu.MenuAppender: - menu.MenuSeparator(popup) - menu.MenuItems(popup, gc.MenuItems) - - case menu.ToolbarInserter: - menu.ToolbarSeparator(popup) - menu.ToolbarItems(popup, gc.MenuItems) - - default: - log.Printlnf("Debug: typeOf(popup) = %T", popup) - } + gc.Content.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) { + menu.MenuSeparator(m) + menu.MenuItems(m, gc.MenuItems) }) + // Make up for the lack of inline images with an image popover that's shown + // when links are clicked. + imgview.BindTooltip(gc.Content) + return gc } @@ -182,12 +165,21 @@ func (m *GenericContainer) UpdateAuthorName(name text.Rich) { } func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { - // Render the content. - parser.RenderTextBuffer(m.CBuffer, content) + log.Println("Rendering", m.id, content) + var markup = parser.RenderMarkup(content) if edited { - parser.AppendEditBadge(m.CBuffer, m.Time()) + markup += " " + rich.Small("(edited)") } + + m.Content.SetMarkup(markup) + + // // Render the content. + // parser.RenderTextBuffer(m.CBuffer, content) + + // if edited { + // parser.AppendEditBadge(m.CBuffer, m.Time()) + // } } // AttachMenu connects signal handlers to handle a list of menu items from diff --git a/internal/ui/messages/message/sending.go b/internal/ui/messages/message/sending.go index 65e82f7..e9120f6 100644 --- a/internal/ui/messages/message/sending.go +++ b/internal/ui/messages/message/sending.go @@ -53,12 +53,14 @@ func (m *GenericPresendContainer) SetDone(id string) { func (m *GenericPresendContainer) SetLoading() { m.SetSensitive(false) - m.CBuffer.SetText(m.sendString) + m.Content.SetText(m.sendString) m.Content.SetTooltipText("") + + // m.CBuffer.SetText(m.sendString) } func (m *GenericPresendContainer) SetSentError(err error) { m.SetSensitive(true) // allow events incl right clicks - m.CBuffer.SetText(`` + html.EscapeString(m.sendString) + ``) + m.Content.SetMarkup(`` + html.EscapeString(m.sendString) + ``) m.Content.SetTooltipText(err.Error()) } diff --git a/internal/ui/rich/parser/markup.go b/internal/ui/rich/parser/markup.go index 3d7a493..cb2df3c 100644 --- a/internal/ui/rich/parser/markup.go +++ b/internal/ui/rich/parser/markup.go @@ -4,10 +4,12 @@ import ( "bytes" "fmt" "html" + "net/url" "sort" "strings" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/imgutil" ) type attrAppendMap struct { @@ -120,10 +122,7 @@ func RenderMarkup(content text.Rich) string { case text.Imager: // Ends don't matter with images. - appended.addf(start, - `%s`, - html.EscapeString(segment.Image()), html.EscapeString(segment.ImageText()), - ) + appended.add(start, composeImageMarkup(segment)) case text.Colorer: appended.span(start, end, fmt.Sprintf(`color="#%06X"`, segment.Color())) @@ -156,6 +155,47 @@ func RenderMarkup(content text.Rich) string { return buf.String() } +// string constant for formatting width and height in URL fragments +const f_FragmentSize = "w=%d;h=%d" + +func composeImageMarkup(imager text.Imager) string { + u, err := url.Parse(imager.Image()) + if err != nil { + // If the URL is invalid, then just write a normal text. + return html.EscapeString(imager.ImageText()) + } + + // Override the URL fragment with our own. + if w, h := imager.ImageSize(); w > 0 && h > 0 { + u.Fragment = fmt.Sprintf(f_FragmentSize, w, h) + } + + return fmt.Sprintf( + `%s`, + html.EscapeString(u.String()), html.EscapeString(imager.ImageText()), + ) +} + +// FragmentImageSize tries to parse the width and height encoded in the URL +// fragment, which is inserted by the markup renderer. A pair of zero values are +// returned if there is none. The returned width and height will be the minimum +// of the given maxes and the encoded sizes. +func FragmentImageSize(URL string, maxw, maxh int) (w, h int) { + u, err := url.Parse(URL) + if err != nil { + return + } + + // Ignore the error, as we can check for the integers. + fmt.Sscanf(u.Fragment, f_FragmentSize, &w, &h) + + if w > 0 && h > 0 { + return imgutil.MaxSize(w, h, maxw, maxh) + } + + return maxw, maxh +} + func span(key, value string) string { return "" }