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 }