Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion canvas.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
package fyne

import "image"
import (
"image"
"math"
)

const (
// FullyRoundedCornerRadius can be applied to a canvas corner radius to achieve fully rounded corners.
// This constant represents the maximum possible corner radius, resulting in a pill or circular appearance.
// Since: 2.7
FullyRoundedCornerRadius float32 = math.MaxFloat32
)

// Canvas defines a graphical canvas to which a [CanvasObject] or Container can be added.
// Each canvas has a scale which is automatically applied during the render process.
Expand Down
101 changes: 101 additions & 0 deletions canvas/base_shadow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package canvas

import (
"image/color"

"fyne.io/fyne/v2"
)

// ShadowType defines the type of shadow to render.
type ShadowType int

const (
DropShadow ShadowType = iota
BoxShadow
)

// Shadowable defines an interface for objects that can render a shadow.
// It provides methods to calculate the scaled size and position including shadow paddings,
// retrieve the shadow paddings, and obtain the content size and position excluding the shadow.
// Content refers to the area without shadow, while the shadow is rendered outside this area.
// The canvas object size is reduced to include the shadow within the allocated frame.
//
// Since: 2.7
type Shadowable interface {
// SizeAndPositionWithShadow returns the total size and position needed to render the shadow around the content
// with the input size. Total size is larger than the input size.
SizeAndPositionWithShadow(size fyne.Size) (fyne.Size, fyne.Position)
// ShadowPaddings returns the paddings (left, top, right, bottom) for the shadow.
ShadowPaddings() [4]float32
// ContentSize returns the size of the content excluding shadow paddings.
ContentSize() fyne.Size
// ContentPos returns the position of the content excluding shadow paddings.
ContentPos() fyne.Position
}

// Ensure baseShadow implements Shadowable.
var _ Shadowable = (*baseShadow)(nil)

// baseShadow provides base functionality for objects that can have a shadow.
// Intended to be embedded in other structs to add shadow support.
type baseShadow struct {
baseObject

ShadowColor color.Color // Color of the shadow.
ShadowSoftness float32 // Softness (blur radius) of the shadow.
ShadowOffset fyne.Position // Offset of the shadow relative to the content.
ShadowType ShadowType // Type of shadow (DropShadow or BoxShadow).
}

// SizeAndPositionWithShadow calculates the total size and adjusted position needed to accommodate the shadow around the content.
// The returned size includes the shadow paddings on all sides, while the position shifts the shadow to ensure content is correctly aligned within the shadow area.
// The input size parameter represents the original content size, excluding any shadow.
func (r *baseShadow) SizeAndPositionWithShadow(size fyne.Size) (fyne.Size, fyne.Position) {
paddings := r.ShadowPaddings()
return fyne.NewSize(size.Width+paddings[0]+paddings[2], size.Height+paddings[1]+paddings[3]), fyne.NewPos(-paddings[0], -paddings[1])
}

// ShadowPaddings calculates the shadow paddings (left, top, right, bottom) based on offset and softness.
func (r *baseShadow) ShadowPaddings() [4]float32 {
offsetX := r.ShadowOffset.X
offsetY := r.ShadowOffset.Y
softness := r.ShadowSoftness

rightReach := -offsetX + softness
leftReach := offsetX + softness
topReach := -offsetY + softness
bottomReach := offsetY + softness

var padLeft, padRight, padTop, padBottom float32

if leftReach > 0 {
padLeft = leftReach
}
if rightReach > 0 {
padRight = rightReach
}
if topReach > 0 {
padTop = topReach
}
if bottomReach > 0 {
padBottom = bottomReach
}

// Returns paddings in order: left, top, right, bottom.
return [4]float32{padLeft, padTop, padRight, padBottom}
}

// ContentSize returns the size of the content area, excluding any space added for shadow paddings.
func (r *baseShadow) ContentSize() fyne.Size {
paddings := r.ShadowPaddings()
size := r.baseObject.Size()
return fyne.NewSize(size.Width-paddings[0]-paddings[2], size.Height-paddings[1]-paddings[3])
}

// ContentPos returns the top-left position of the content area, adjusted to exclude the shadow paddings.
// This gives the position where the actual content (without shadow) is rendered within the shadowed object.
func (r *baseShadow) ContentPos() fyne.Position {
paddings := r.ShadowPaddings()
position := r.baseObject.Position()
return fyne.NewPos(position.X+paddings[0], position.Y+paddings[1])
}
64 changes: 64 additions & 0 deletions canvas/base_shadow_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package canvas

import (
"image/color"
"testing"

"fyne.io/fyne/v2"
"github.com/stretchr/testify/assert"
)

func TestBaseShadow_ShadowPaddings(t *testing.T) {
b := &baseShadow{
baseObject: baseObject{},
ShadowColor: color.NRGBA{0, 0, 0, 128},
ShadowSoftness: 4,
ShadowOffset: fyne.NewPos(2, 3),
ShadowType: DropShadow,
}

pads := b.ShadowPaddings()
assert.Equal(t, float32(6), pads[0])
assert.Equal(t, float32(1), pads[1])
assert.Equal(t, float32(2), pads[2])
assert.Equal(t, float32(7), pads[3])
}

func TestBaseShadow_SizeAndPositionWithShadow(t *testing.T) {
b := &baseShadow{
baseObject: baseObject{},
ShadowColor: color.NRGBA{0, 0, 0, 128},
ShadowSoftness: 4,
ShadowOffset: fyne.NewPos(2, 3),
ShadowType: DropShadow,
}
size := fyne.NewSize(10, 20)
totalSize, pos := b.SizeAndPositionWithShadow(size)
pads := b.ShadowPaddings()

assert.Equal(t, size.Width+pads[0]+pads[2], totalSize.Width)
assert.Equal(t, size.Height+pads[1]+pads[3], totalSize.Height)
assert.Equal(t, -pads[0], pos.X)
assert.Equal(t, -pads[1], pos.Y)
}

func TestBaseShadow_ContentSizeAndPos(t *testing.T) {
b := &baseShadow{
baseObject: baseObject{},
ShadowColor: color.NRGBA{0, 0, 0, 128},
ShadowSoftness: 4,
ShadowOffset: fyne.NewPos(2, 3),
ShadowType: BoxShadow,
}
b.baseObject.Resize(fyne.NewSize(30, 40))
b.baseObject.Move(fyne.NewPos(5, 6))

pads := b.ShadowPaddings()
contentSize := b.ContentSize()
contentPos := b.ContentPos()

assert.Equal(t, 30-pads[0]-pads[2], contentSize.Width)
assert.Equal(t, 40-pads[1]-pads[3], contentSize.Height)
assert.Equal(t, 5+pads[0], contentPos.X)
assert.Equal(t, 6+pads[1], contentPos.Y)
}
41 changes: 37 additions & 4 deletions canvas/circle.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,36 @@ import (
var _ fyne.CanvasObject = (*Circle)(nil)

// Circle describes a colored circle primitive in a Fyne canvas
// Shadow support since 2.7
type Circle struct {
baseShadow

Position1 fyne.Position // The current top-left position of the Circle
Position2 fyne.Position // The current bottomright position of the Circle
Hidden bool // Is this circle currently hidden

FillColor color.Color // The circle fill color
StrokeColor color.Color // The circle stroke color
StrokeWidth float32 // The stroke width of the circle

// When true, the circle keeps the requested size for its content, and the total object size expands to include the shadow.
// This means the object will be properly scaled and moved to fit the requested size and position.
// The total size including the shadow will be larger than the requested size.
//
// Since: 2.7
ExpandForShadow bool
}

// NewCircle returns a new Circle instance
func NewCircle(color color.Color) *Circle {
return &Circle{FillColor: color}
}

func (c *Circle) ContentPos() fyne.Position {
paddings := c.ShadowPaddings()
position := c.Position()
return fyne.NewPos(position.X+paddings[0], position.Y+paddings[1])
}

// Hide will set this circle to not be visible
func (c *Circle) Hide() {
c.Hidden = true
Expand All @@ -40,12 +55,21 @@ func (c *Circle) MinSize() fyne.Size {
}

// Move the circle object to a new position, relative to its parent / canvas
// If ExpandForShadow is true, the position is adjusted to account for the shadow paddings.
// The circle position is then updated accordingly to reflect the new position.
func (c *Circle) Move(pos fyne.Position) {
if c.Position1 == pos {
size := c.Size()
if c.ExpandForShadow {
// Note: added Circle custom ContentPos() method because Circle has custom Position() method
if pos == c.ContentPos() {
return
}
_, p := c.SizeAndPositionWithShadow(size)
pos = pos.Add(p)
} else if c.Position1 == pos {
return
}

size := c.Size()
c.Position1 = pos
c.Position2 = c.Position1.Add(size)

Expand All @@ -64,8 +88,17 @@ func (c *Circle) Refresh() {

// Resize sets a new bottom-right position for the circle object
// If it has a stroke width this will cause it to Refresh.
// If ExpandForShadow is true, the size is adjusted to accommodate the shadow,
// ensuring the circle size matches the requested size.
// The total size including the shadow will be larger than the requested size.
func (c *Circle) Resize(size fyne.Size) {
if size == c.Size() {
if c.ExpandForShadow {
if size == c.ContentSize() {
return
}
sizeWithShadow, _ := c.SizeAndPositionWithShadow(size)
size = sizeWithShadow
} else if size == c.Size() {
return
}

Expand Down
42 changes: 39 additions & 3 deletions canvas/rectangle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (
var _ fyne.CanvasObject = (*Rectangle)(nil)

// Rectangle describes a colored rectangle primitive in a Fyne canvas
// Shadow support since 2.7
type Rectangle struct {
baseObject
baseShadow

FillColor color.Color // The rectangle fill color
StrokeColor color.Color // The rectangle stroke color
Expand All @@ -26,6 +27,24 @@ type Rectangle struct {
//
// Since: 2.7
Aspect float32

// The `CornerRadius` field sets the default radius for all corners. If a specific corner
// radius (such as `TopRightCornerRadius`, `TopLeftCornerRadius`, `BottomRightCornerRadius`,
// or `BottomLeftCornerRadius`) is set to a value greater than `CornerRadius`, that value
// will override `CornerRadius` for the respective corner. Otherwise, `CornerRadius` is used.
//
// Since: 2.7
TopRightCornerRadius float32
TopLeftCornerRadius float32
BottomRightCornerRadius float32
BottomLeftCornerRadius float32

// When true, the rectangle keeps the requested size for its content, and the total object size expands to include the shadow.
// This means the rectangle will be properly scaled and moved to fit the requested size and position.
// The total size including the shadow will be larger than the requested size.
//
// Since: 2.7
ExpandForShadow bool
}

// Hide will set this rectangle to not be visible
Expand All @@ -36,8 +55,16 @@ func (r *Rectangle) Hide() {
}

// Move the rectangle to a new position, relative to its parent / canvas
// If ExpandForShadow is true, the position is adjusted to account for the shadow paddings.
// The rectangle position is then updated accordingly to reflect the new position.
func (r *Rectangle) Move(pos fyne.Position) {
if r.Position() == pos {
if r.ExpandForShadow {
if pos == r.ContentPos() {
return
}
_, p := r.SizeAndPositionWithShadow(r.Size())
pos = pos.Add(p)
} else if r.Position() == pos {
return
}

Expand All @@ -54,8 +81,17 @@ func (r *Rectangle) Refresh() {
// Resize on a rectangle updates the new size of this object.
// If it has a stroke width this will cause it to Refresh.
// If Aspect is non-zero it may cause the rectangle to be smaller than the requested size.
// If ExpandForShadow is true, the size is adjusted to accommodate the shadow,
// ensuring the rectangle size matches the requested size.
// The total size including the shadow will be larger than the requested size.
func (r *Rectangle) Resize(s fyne.Size) {
if s == r.Size() {
if r.ExpandForShadow {
if s == r.ContentSize() {
return
}
size, _ := r.SizeAndPositionWithShadow(s)
s = size
} else if s == r.Size() {
return
}

Expand Down
Loading