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