diff --git a/theme/icons.go b/theme/icons.go index 17c525c739..022de16cc3 100644 --- a/theme/icons.go +++ b/theme/icons.go @@ -1,7 +1,12 @@ package theme import ( + "bytes" + "image" "image/color" + _ "image/jpeg" // register JPEG decoder so DisabledResource can desaturate JPEG icons + "image/png" + "math" "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal/svg" @@ -838,9 +843,57 @@ func (res *DisabledResource) Name() string { return "disabled_" + unwrapResource(res.source).Name() } -// Content returns the disabled style content of the correct resource for the current theme +// ITU-R BT.601 luma coefficients, scaled by 1000 for integer math. +const ( + lumaWeightR = 299 + lumaWeightG = 587 + lumaWeightB = 114 + lumaScale = 1000 +) + +// Content returns the disabled style content of the correct resource for the current theme. +// SVG resources are recolored with the theme's disabled color; bitmap resources (PNG, JPEG, ...) +// are desaturated to greyscale. func (res *DisabledResource) Content() []byte { - return colorizeLogError(unwrapResource(res.source).Content(), Color(ColorNameDisabled)) + src := unwrapResource(res.source) + content := src.Content() + if svg.IsResourceSVG(src) { + return colorizeLogError(content, Color(ColorNameDisabled)) + } + out, err := desaturate(content) + if err != nil { + fyne.LogError("Failed to desaturate bitmap for disabled state", err) + return content + } + return out +} + +// desaturate returns a PNG-encoded greyscale copy of the given image bytes, +// preserving the alpha channel. +func desaturate(src []byte) ([]byte, error) { + img, _, err := image.Decode(bytes.NewReader(src)) + if err != nil { + return src, err + } + bounds := img.Bounds() + gray := image.NewNRGBA(bounds) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + // Convert via NRGBA so the luminance is computed on unpremultiplied + // channels — otherwise partially-transparent pixels go too dark. + n := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) + lum := (lumaWeightR*uint32(n.R) + lumaWeightG*uint32(n.G) + lumaWeightB*uint32(n.B)) / lumaScale + if lum > math.MaxUint8 { + lum = math.MaxUint8 + } + gray.SetNRGBA(x, y, color.NRGBA{R: uint8(lum), G: uint8(lum), B: uint8(lum), A: n.A}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, gray); err != nil { + return src, err + } + return buf.Bytes(), nil } // ThemeColorName returns the fyne.ThemeColorName that is used as foreground color. diff --git a/widget/button.go b/widget/button.go index 8dd497d5b0..b57a5de9da 100644 --- a/widget/button.go +++ b/widget/button.go @@ -7,7 +7,6 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" col "fyne.io/fyne/v2/internal/color" - "fyne.io/fyne/v2/internal/svg" "fyne.io/fyne/v2/internal/widget" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" @@ -419,8 +418,7 @@ func (r *buttonRenderer) updateIconAndText() { r.icon.FillMode = canvas.ImageFillContain r.SetObjects([]fyne.CanvasObject{r.background, r.tapBG, r.label, r.icon}) } - // TODO support disabling bitmap resource not just SVG - if r.button.Disabled() && svg.IsResourceSVG(icon) { + if r.button.Disabled() { icon = theme.NewDisabledResource(icon) } r.icon.Resource = icon diff --git a/widget/button_internal_test.go b/widget/button_internal_test.go index 0753b42c55..8cfab305b6 100644 --- a/widget/button_internal_test.go +++ b/widget/button_internal_test.go @@ -1,8 +1,11 @@ package widget import ( + "bytes" "fmt" + "image" "image/color" + _ "image/png" "strings" "testing" @@ -135,6 +138,65 @@ func TestButton_DisabledIconChangedDirectly(t *testing.T) { assert.Equal(t, render.icon.Resource.Name(), fmt.Sprintf("disabled_%v", searchBaseName)) } +func TestButton_DisabledBitmapIcon(t *testing.T) { + pngIcon := fyne.NewStaticResource("fyne.png", iconData) + button := NewButtonWithIcon("Test", pngIcon, nil) + render := test.TempWidgetRenderer(t, button).(*buttonRenderer) + + // While enabled the original bitmap resource is rendered untouched. + assert.Equal(t, pngIcon, render.icon.Resource) + + button.Disable() + assert.True(t, strings.HasPrefix(render.icon.Resource.Name(), "disabled_"), + "disabled icon should be wrapped: %s", render.icon.Resource.Name()) + + // Sanity-check that the source PNG has coloured pixels; otherwise + // the desaturation assertion below would pass trivially. + origImg, _, err := image.Decode(bytes.NewReader(iconData)) + if !assert.NoError(t, err) { + return + } + var origColoured bool + origBounds := origImg.Bounds() + for y := origBounds.Min.Y; y < origBounds.Max.Y && !origColoured; y++ { + for x := origBounds.Min.X; x < origBounds.Max.X; x++ { + n := color.NRGBAModel.Convert(origImg.At(x, y)).(color.NRGBA) + if n.A > 0 && (n.R != n.G || n.G != n.B) { + origColoured = true + break + } + } + } + assert.True(t, origColoured, "test icon must contain coloured pixels") + + // Every opaque pixel in the disabled resource content must be greyscale. + disabledImg, _, err := image.Decode(bytes.NewReader(render.icon.Resource.Content())) + if !assert.NoError(t, err) { + return + } + var opaque, greyscale int + bounds := disabledImg.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + n := color.NRGBAModel.Convert(disabledImg.At(x, y)).(color.NRGBA) + if n.A == 0 { + continue + } + opaque++ + if n.R == n.G && n.G == n.B { + greyscale++ + } + } + } + assert.Greater(t, opaque, 0, "expected the disabled icon to have opaque pixels") + assert.Equal(t, opaque, greyscale, + "every opaque pixel should be greyscale: got %d of %d", greyscale, opaque) + + // Re-enabling restores the original resource reference. + button.Enable() + assert.Equal(t, pngIcon, render.icon.Resource) +} + func TestButton_Focus(t *testing.T) { tapped := false button := NewButton("Test", func() {