cchat-gtk/internal/ui/messages/input/attachment/attachment.go

405 lines
9.5 KiB
Go

package attachment
import (
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
const (
ThumbSize = 72
IconSize = 56
)
// File represents a middle format that can be used to create a
// MessageAttachment.
type File struct {
Prog *Progress
Name string
Size int64 // -1 = stream
}
// NewFile creates a new attachment file with a progress state.
func NewFile(name string, size int64, open Open) File {
return File{
Prog: NewProgress(NewReusableReader(open), size),
Name: name,
Size: size,
}
}
// AsAttachment turns File into a MessageAttachment. This method will always
// make a new MessageAttachment and will never return an old one.
//
// The reason being MessageAttachment should never be reused, as it hides the
// fact that the io.Reader is reusable.
func (f *File) AsAttachment() cchat.MessageAttachment {
return cchat.MessageAttachment{
Name: f.Name,
Reader: f.Prog,
}
}
type Container struct {
*gtk.Revealer
Scroll *gtk.ScrolledWindow
Box *gtk.Box
enabled bool
// states
files []File
items map[string]primitives.WidgetDestroyer
}
var attachmentsCSS = primitives.PrepareCSS(`
.attachments { padding: 5px; padding-bottom: 0 }
`)
func New() *Container {
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
box.Show()
primitives.AddClass(box, "attachments")
primitives.AttachCSS(box, attachmentsCSS)
scr, _ := gtk.ScrolledWindowNew(nil, nil)
scr.SetPolicy(gtk.POLICY_EXTERNAL, gtk.POLICY_NEVER)
scr.SetProperty("kinetic-scrolling", false)
scr.Add(box)
scr.Show()
// Scroll left/right when the wheel goes up/down.
scr.Connect("scroll-event", func(s *gtk.ScrolledWindow, ev *gdk.Event) bool {
// Magic thing I found out while print-debugging. DeltaY shows the same
// offset whether you scroll with Shift or not, which makes sense.
// DeltaX is always 0.
var adj = scr.GetHAdjustment()
switch ev := gdk.EventScrollNewFromEvent(ev); ev.DeltaY() {
case 1:
adj.SetValue(adj.GetValue() + adj.GetStepIncrement())
case -1:
adj.SetValue(adj.GetValue() - adj.GetStepIncrement())
default:
// Not handled.
return false
}
// Handled.
return true
})
rev, _ := gtk.RevealerNew()
rev.SetRevealChild(false)
rev.Add(scr)
return &Container{
Revealer: rev,
Scroll: scr,
Box: box,
items: map[string]primitives.WidgetDestroyer{},
}
}
// SetMarginStart sets the inner margin of the attachments carousel.
func (c *Container) SetMarginStart(margin int) {
c.Box.SetMarginStart(margin)
}
// Enabled returns whether or not the container allows attachments.
func (c *Container) Enabled() bool {
return c.enabled
}
func (c *Container) SetEnabled(enabled bool) {
// Set the enabled state; reset the container if we're disabling the
// attachment box.
if c.enabled = enabled; !enabled {
c.Reset()
}
}
// Files returns the list of attachments
func (c *Container) Files() []File {
return c.files
}
// Reset does NOT close files.
func (c *Container) Reset() {
// Reset states. We do not touch the old files slice, as other callers may
// be referencing and using it.
c.files = nil
// Clear all items.
for _, item := range c.items {
item.Destroy()
}
// Reset the map.
c.items = map[string]primitives.WidgetDestroyer{}
// Hide the window.
c.SetRevealChild(false)
}
// AddFiles is used for the file chooser's callback.
func (c *Container) AddFiles(paths []string) {
for _, path := range paths {
if err := c.AddFile(path); err != nil {
log.Error(errors.Wrap(err, "Failed to add file"))
}
}
}
// AddFile is used for the file picker.
func (c *Container) AddFile(path string) error {
// Check the file and get the size.
s, err := os.Stat(path)
if err != nil {
return errors.Wrap(err, "Failed to stat file")
}
var filename = c.append(
filepath.Base(path), s.Size(),
func() (io.ReadCloser, error) { return os.Open(path) },
)
scale := c.GetScaleFactor()
// Maybe try making a preview. A nil image is fine, so we can skip the error
// check.
// TODO: add a filesize check
pixbuf, _ := gdk.PixbufNewFromFileAtScale(path, ThumbSize*scale, ThumbSize*scale, true)
c.addPreview(filename, thumbnailPixbuf(pixbuf, scale))
return nil
}
// AddPixbuf is used for adding pixbufs from the clipboard.
func (c *Container) AddPixbuf(pb *gdk.Pixbuf) {
var filename = c.append(
fmt.Sprintf("clipboard_%d.png", len(c.files)+1), -1,
func() (io.ReadCloser, error) {
r, w := io.Pipe()
go func() { w.CloseWithError(pb.WritePNG(w, 9)) }()
return r, nil
},
)
scale := c.GetScaleFactor()
c.addPreview(filename, thumbnailPixbuf(pb, scale))
return
}
// -- internal methods --
// append guarantees there's no collision. It returns the unique filename.
func (c *Container) append(name string, sz int64, open Open) string {
// Show the preview window.
c.SetRevealChild(true)
// Guarantee that the filename will never collide.
for _, file := range c.files {
if file.Name == name {
// Hopefully this works? I'm not sure. But this will keep prepending
// an underscore.
name = "_" + name
}
}
c.files = append(c.files, NewFile(name, sz, open))
return name
}
func (c *Container) remove(name string) {
for i, file := range c.files {
if file.Name == name {
c.files = append(c.files[:i], c.files[i+1:]...)
break
}
}
if w, ok := c.items[name]; ok {
w.Destroy()
delete(c.items, name)
}
// Collapse the container if there's nothing.
if len(c.items) == 0 {
c.SetRevealChild(false)
}
}
var previewCSS = primitives.PrepareCSS(`
.attachment-preview {
box-shadow: none;
border: none;
background-color: alpha(@theme_fg_color, 0.15);
border-radius: 5px;
}
`)
var deleteAttBtnCSS = primitives.PrepareCSS(`
.delete-attachment {
/* Remove styling from the Gtk themes */
border: none;
box-shadow: none;
/* Add our own styling */
border-radius: 999px 999px;
transition: linear 100ms all;
background-color: alpha(@theme_bg_color, 0.75);
}
.delete-attachment:hover {
background-color: alpha(red, 0.5);
}
`)
func (c *Container) addPreview(name string, thumbnail *cairo.Surface) {
// Make a fallback image first.
gimg, _ := roundimage.NewImage(4) // border-radius: 4px
primitives.SetImageIcon(gimg.Image, iconFromName(name), IconSize)
gimg.SetSizeRequest(ThumbSize, ThumbSize)
gimg.SetVAlign(gtk.ALIGN_CENTER)
gimg.SetHAlign(gtk.ALIGN_CENTER)
gimg.SetTooltipText(name)
gimg.Show()
primitives.AddClass(gimg, "attachment-preview")
primitives.AttachCSS(gimg, previewCSS)
// Determine if we could generate an image preview.
if thumbnail != nil {
gimg.SetFromSurface(thumbnail)
}
// BLOAT!!! Make an overlay of an event box that, when hovered, will show
// something that allows closing the image.
del, _ := gtk.ButtonNewFromIconName("window-close-symbolic", gtk.ICON_SIZE_DIALOG)
del.SetVAlign(gtk.ALIGN_CENTER)
del.SetHAlign(gtk.ALIGN_CENTER)
del.SetTooltipText("Remove " + name)
del.Connect("clicked", func(del *gtk.Button) { c.remove(name) })
del.Show()
primitives.AddClass(del, "delete-attachment")
primitives.AttachCSS(del, deleteAttBtnCSS)
ovl, _ := gtk.OverlayNew()
ovl.SetSizeRequest(ThumbSize, ThumbSize)
ovl.Add(gimg)
ovl.AddOverlay(del)
ovl.Show()
c.items[name] = ovl
c.Box.PackStart(ovl, false, false, 0)
}
func thumbnailPixbuf(pixbuf *gdk.Pixbuf, scale int) *cairo.Surface {
if pixbuf == nil {
return nil
}
var (
originalWidth = pixbuf.GetWidth()
originalHeight = pixbuf.GetHeight()
scaledThumbSize = ThumbSize * scale
scaledWidth, scaledHeight = minsize(originalWidth, originalHeight, scaledThumbSize)
// offset of src on thumbnail; one of those will be 0
offsetX = float64(scaledThumbSize-scaledWidth) / 2
offsetY = float64(scaledThumbSize-scaledHeight) / 2
)
thumbnail, err := gdk.PixbufNew(
pixbuf.GetColorspace(),
true, 8, // always have alpha, 8bpc
scaledThumbSize, scaledThumbSize,
)
if err != nil {
panic("failed to allocate upload thumbnail pixbuf: " + err.Error())
}
// Fill with transparent pixels.
thumbnail.Fill(0x0)
pixbuf.Scale(
thumbnail,
int(offsetX), int(offsetY),
// size of src on thumbnail
scaledWidth, scaledHeight,
// no offset on source image
offsetX, offsetY,
// scale ratio for both sides
float64(scaledWidth)/float64(originalWidth),
float64(scaledHeight)/float64(originalHeight),
// expensive rescale algorithm
gdk.INTERP_HYPER,
)
surface, err := gdk.CairoSurfaceCreateFromPixbuf(thumbnail, scale, nil)
if err != nil {
panic("failed to create thumbnail cairo surface: " + err.Error())
}
return surface
}
func iconFromName(filename string) string {
switch t := mime.TypeByExtension(filepath.Ext(filename)); {
case strings.HasPrefix(t, "image"):
return "image-x-generic-symbolic"
case strings.HasPrefix(t, "audio"):
return "audio-x-generic-symbolic"
case strings.HasPrefix(t, "application"):
return "application-x-appliance-symbolic"
case strings.HasPrefix(t, "text"):
fallthrough
default:
return "text-x-generic-symbolic"
}
}
// minsize returns the scaled size so that the largest edge is maxsz.
func minsize(w, h, maxsz int) (int, int) {
if w > h {
// return the scaled width as max
// h*max/w is the same as h/w*max but with more accuracy
return maxsz, h * maxsz / w
}
return w * maxsz / h, maxsz
}
func min(w, h int) int {
if w > h {
return h
}
return w
}
func max(w, h int) int {
if w > h {
return w
}
return h
}