mirror of
https://github.com/diamondburned/cchat-gtk.git
synced 2024-12-23 12:46:45 +00:00
388 lines
9.2 KiB
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
|
|
}
|