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

388 lines
9.2 KiB
Go

package attachment
import (
"bytes"
"fmt"
"image"
"image/png"
"io"
"io/ioutil"
"mime"
"os"
"path/filepath"
"strings"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"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/disintegration/imaging"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
var pngEncoder = png.Encoder{
CompressionLevel: png.BestCompression,
}
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
}
// 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]gtk.IWidget
}
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]gtk.IWidget{},
}
}
// 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 {
c.Box.Remove(item)
}
// Reset the map.
c.items = map[string]gtk.IWidget{}
// 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) },
)
// Maybe try making a preview. A nil image is fine, so we can skip the error
// check.
// TODO: add a filesize check
i, _ := imaging.Open(path, imaging.AutoOrientation(true))
c.addPreview(filename, i)
return nil
}
// AddPixbuf is used for adding pixbufs from the clipboard.
func (c *Container) AddPixbuf(pb *gdk.Pixbuf) error {
// Pixbuf's colorspace is only RGB. This is indicated with
// GDK_COLORSPACE_RGB.
if pb.GetColorspace() != gdk.COLORSPACE_RGB {
return errors.New("Pixbuf has unsupported colorspace")
}
// Assert that the pixbuf has alpha, as we're using RGBA.
if !pb.GetHasAlpha() {
return errors.New("Pixbuf has no alpha channel")
}
// Assert that there are 4 channels: red, green, blue and alpha.
if pb.GetNChannels() != 4 {
return errors.New("Pixbuf has unexpected channel count")
}
// Assert that there are 8 bits in a channel/sample.
if pb.GetBitsPerSample() != 8 {
return errors.New("Pixbuf has unexpected bits per sample")
}
var img = &image.NRGBA{
Pix: pb.GetPixels(),
Stride: pb.GetRowstride(),
Rect: image.Rect(0, 0, pb.GetWidth(), pb.GetHeight()),
}
// Store the image in memory.
var buf bytes.Buffer
if err := pngEncoder.Encode(&buf, img); err != nil {
return errors.Wrap(err, "Failed to encode PNG")
}
var filename = c.append(
fmt.Sprintf("clipboard_%d.png", len(c.files)+1), int64(buf.Len()),
func() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewReader(buf.Bytes())), nil
},
)
c.addPreview(filename, img)
return nil
}
// -- 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 {
c.Box.Remove(w)
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, src image.Image) {
// 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 src != nil {
// Get the minimum dimension.
var w, h = minsize(src.Bounds().Dx(), src.Bounds().Dy(), ThumbSize)
var img *image.NRGBA
// Downscale the image.
img = imaging.Resize(src, w, h, imaging.Lanczos)
// Crop to a square.
img = imaging.CropCenter(img, ThumbSize, ThumbSize)
// Copy the image to a pixbuf.
gimg.SetFromPixbuf(gts.RenderPixbuf(img))
}
// 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() { 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 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"
}
}
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
}