cchat-gtk/internal/ui/primitives/roundimage/roundimage.go

305 lines
7.0 KiB
Go

package roundimage
import (
"context"
"log"
"math"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/handy"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
const (
pi = math.Pi
circle = 2 * math.Pi
)
type RadiusSetter interface {
SetRadius(float64)
}
type Connector interface {
ConnectHandlers(connector primitives.Connector)
}
type Imager interface {
gtk.IWidget
RadiusSetter
SetSizeRequest(w, h int)
// Embed setters.
httputil.ImageContainer
GetPixbuf() *gdk.Pixbuf
GetAnimation() *gdk.PixbufAnimation
GetImage() *gtk.Image
}
// Image represents an image with abstractions for asynchronously fetching
// images from a URL as well as having interchangeable fallbacks.
type Image struct {
gtk.Image
Radius float64
style *gtk.StyleContext
procs []imgutil.Processor
ifNone func(context.Context)
icon struct {
name string
size int
}
cancel context.CancelFunc
imgURL string
show bool
}
var _ Imager = (*Image)(nil)
// NewImage creates a new round image. If radius is 0, then it will be half the
// dimensions. If the radius is less than 0, then nothing is rounded.
func NewImage(radius float64) *Image {
i, err := gtk.ImageNew()
if err != nil {
log.Panicln("failed to create new roundimage.Image:", err)
}
style, _ := i.GetStyleContext()
image := &Image{
Image: *i,
Radius: radius,
style: style,
}
// Connect to the draw callback and clip the context.
i.Connect("draw", image.drawer)
return image
}
// NewSizedImage creates a new square image with the given square.
func NewSizedImage(radius float64, size int) *Image {
img := NewImage(radius)
img.SetSizeRequest(size, size)
return img
}
// AddProcessor adds image processors that will be processed on fetched images.
// Images generated internally, such as initials, won't use it.
func (i *Image) AddProcessor(procs ...imgutil.Processor) {
i.procs = append(i.procs, procs...)
}
// GetImage returns the underlying image widget.
func (i *Image) GetImage() *gtk.Image {
return &i.Image
}
// Size returns the minimum side's length. This method is used when Image is
// supposed to be a square/circle.
func (i *Image) Size() int {
w, h := i.GetSizeRequest()
if w > h {
return h
}
return w
}
// SetSIze sets the iamge's physical size. It is a convenient function for
// SetSizeRequest.
func (i *Image) SetSize(size int) {
i.SetSizeRequest(size, size)
}
// SetIfNone sets the callback to be used if an empty URL is given to the image.
// If nil is given, then a fallback icon is used.
func (i *Image) SetIfNone(ifNone func(context.Context)) {
i.ifNone = ifNone
}
// UpdateIfNone updates the image if the image currently does not have one
// fetched from the URL. It does nothing otherwise.
func (i *Image) UpdateIfNone() {
if i.ifNone == nil || i.imgURL != "" {
return
}
i.SetImageURL("")
}
// SetPlaceholderIcon sets the placeholder icon onto the image. The given icon
// size does not affect the image's physical size.
func (i *Image) SetPlaceholderIcon(iconName string, iconPx int) {
i.icon.name = iconName
i.icon.size = iconPx
if i.imgURL == "" {
i.SetImageURL("")
}
}
// GetImageURL gets the image's URL. It returns an empty string if the image
// does not have a URL set.
func (i *Image) GetImageURL() string {
return i.imgURL
}
// SetImageURL sets the image's URL. If the URL is empty, then the placeholder
// icon is used, or the IfNone callback is called, or the pixbuf is cleared.
func (i *Image) SetImageURL(url string) {
i.SetImageURLInto(url, i)
}
// SetImageURLInto is SetImageURL, but the image container is given as an
// argument. It is used by other widgets that extend on this Image.
func (i *Image) SetImageURLInto(url string, otherImage httputil.ImageContainer) {
i.imgURL = url
// TODO: fix this context leak: cancel not being called on all paths.
ctx := i.resetCtx()
if url != "" {
// No dynamic sizing support; yolo.
httputil.AsyncImage(ctx, otherImage, url, i.procs...)
return
}
if i.icon.name != "" {
primitives.SetImageIcon(i, i.icon.name, i.icon.size)
goto noImage
}
if i.ifNone != nil {
i.ifNone(ctx)
return
}
noImage:
i.Image.SetFromPixbuf(nil)
i.cancel()
}
func (i *Image) resetCtx() context.Context {
if i.cancel != nil {
i.cancel()
i.cancel = nil
}
// TODO: fix this context leak: cancel not being called on all paths.
ctx, cancel := context.WithCancel(context.Background())
i.cancel = cancel
return ctx
}
// SetRadius sets the radius to be drawn with. If 0 is given, then a full circle
// is drawn, which only works best for images guaranteed to be square.
// Otherwise, the radius is either the number given or the minimum of either the
// width or height.
func (i *Image) SetRadius(r float64) {
i.Radius = r
i.QueueDraw()
}
func (i *Image) drawer(image *gtk.Image, cc *cairo.Context) bool {
// Don't round if we're displaying a stock icon.
if i.imgURL == "" && i.icon.name != "" {
return false
}
a := image.GetAllocation()
w := float64(a.GetWidth())
h := float64(a.GetHeight())
min := w
// Use the largest side for radius calculation.
if h > w {
min = h
}
switch {
// If radius is less than 0, then don't round.
case i.Radius < 0:
return false
// If radius is 0, then we have to calculate our own radius.:This only
// works if the image is a square.
case i.Radius == 0:
// Calculate the radius by dividing a side by 2.
r := (min / 2)
// Draw an arc from 0deg to 360deg.
cc.Arc(w/2, h/2, r, 0, circle)
// We have to do this so the arc paint doesn't leave back a black
// background instead of the usual alpha.
cc.SetSourceRGBA(255, 255, 255, 0)
// Clip the image with the arc we drew.
cc.Clip()
// If radius is more than 0, then we have to calculate the radius from
// the edges.
case i.Radius > 0:
// StackOverflow is godly.
// https://stackoverflow.com/a/6959843.
// Copy the variables so we can change them later.
r := i.Radius
// Radius should be largest a single side divided by 2.
if max := min / 2; r > max {
r = max
}
// Draw 4 arcs at 4 corners.
cc.Arc(0+r, 0+r, r, 2*(pi/2), 3*(pi/2)) // top left
cc.Arc(w-r, 0+r, r, 3*(pi/2), 4*(pi/2)) // top right
cc.Arc(w-r, h-r, r, 0*(pi/2), 1*(pi/2)) // bottom right
cc.Arc(0+r, h-r, r, 1*(pi/2), 2*(pi/2)) // bottom left
// Close the created path.
cc.ClosePath()
cc.SetSourceRGBA(255, 255, 255, 0)
// Clip the image with the arc we drew.
cc.Clip()
}
// Paint the changes.
cc.Paint()
return false
}
// UseInitialsIfNone sets the given image to render an initial image if the
// image doesn't have a URL.
func (i *Image) UseInitialsIfNone(initialsFn func() string) {
i.SetIfNone(func(ctx context.Context) {
size := i.Size()
scale := i.GetScaleFactor()
a := handy.AvatarNew(size, initialsFn(), true)
p := a.DrawToPixbuf(size, scale)
if scale > 1 {
surface, _ := gdk.CairoSurfaceCreateFromPixbuf(p, scale, nil)
i.SetFromSurface(surface)
} else {
// Potentially save a copy.
i.SetFromPixbuf(p)
}
})
i.UpdateIfNone()
}