From 008f8948f66b7d330b8d18f1d5b719c7fca07ecf Mon Sep 17 00:00:00 2001 From: mohsen Date: Sun, 28 Jun 2026 10:57:24 +0330 Subject: [PATCH 1/6] Fade disabled button icon when resource is a bitmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons with SVG icons were already grayed out on Disable() via NewDisabledResource, but bitmap resources (PNG, JPEG, ...) cannot be recolored that way and stayed at full opacity, leaving no visual cue that the button is disabled. Apply a translucency to the icon image when the button is disabled and the resource is not an SVG; reset it when the button is enabled again. The chosen translucency is a fixed value and documented inline — the emoji-text rendering aspect from the original report is tracked separately and is not addressed here. Refs #6217 (partial — Button.Icon only) Refs #6383 (follow-up for emoji text) --- widget/button.go | 20 +++++++++++++++++--- widget/button_internal_test.go | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/widget/button.go b/widget/button.go index 8dd497d5b0..2d7483f1f1 100644 --- a/widget/button.go +++ b/widget/button.go @@ -45,6 +45,14 @@ const ( ButtonIconTrailingText ) +// disabledIconTranslucency is the alpha-fade applied to bitmap icons (PNG, JPEG, ...) +// when the button is disabled. Themed SVGs are recolored to ColorNameDisabled +// instead, but raster resources cannot be recolored without per-pixel work, so we +// fall back to a fixed translucency. 0.5 is a compromise: more transparent would +// hide colorful icons against the disabled background, less transparent gives no +// visible disabled cue. +const disabledIconTranslucency = 0.5 + var ( _ fyne.Focusable = (*Button)(nil) _ fyne.Accessible = (*Button)(nil) @@ -419,9 +427,15 @@ 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) { - icon = theme.NewDisabledResource(icon) + r.icon.Translucency = 0 + if r.button.Disabled() { + if svg.IsResourceSVG(icon) { + icon = theme.NewDisabledResource(icon) + } else { + // Bitmap resources cannot be recolored, so fade them instead + // to provide a visual disabled cue. + r.icon.Translucency = disabledIconTranslucency + } } r.icon.Resource = icon r.icon.Refresh() diff --git a/widget/button_internal_test.go b/widget/button_internal_test.go index 0753b42c55..c02629ccfb 100644 --- a/widget/button_internal_test.go +++ b/widget/button_internal_test.go @@ -135,6 +135,27 @@ 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) + + // Bitmap resource is kept as-is (no DisabledResource wrapping) and not faded while enabled. + assert.Equal(t, pngIcon, render.icon.Resource) + assert.Equal(t, 0.0, render.icon.Translucency) + + // When disabled, the bitmap resource is unchanged but the image is faded + // to give a visual disabled cue (cannot recolor a bitmap). + button.Disable() + assert.Equal(t, pngIcon, render.icon.Resource) + assert.Equal(t, disabledIconTranslucency, render.icon.Translucency) + + // Re-enabling resets the fade. + button.Enable() + assert.Equal(t, pngIcon, render.icon.Resource) + assert.Equal(t, 0.0, render.icon.Translucency) +} + func TestButton_Focus(t *testing.T) { tapped := false button := NewButton("Test", func() { From cf066e59b28afe5eab81f28ee36108fb51e06e20 Mon Sep 17 00:00:00 2001 From: mohsen Date: Sun, 28 Jun 2026 16:35:12 +0330 Subject: [PATCH 2/6] Desaturate bitmap resources in DisabledResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #6381: translucency was a workaround. Bitmap resources can be recoloured by reducing saturation so the icon appears greyscale, matching how SVGs are recoloured to ColorNameDisabled. Extend theme.DisabledResource.Content() to decode non-SVG resources, convert each pixel to its ITU-R BT.601 luminance (alpha preserved), and return the result as PNG. SVG content keeps its existing colorize path. Drop the IsResourceSVG branching in widget/button.go — NewDisabledResource now handles both kinds. Removes the disabledIconTranslucency constant and the Translucency field manipulation introduced earlier on this branch. --- theme/icons.go | 43 ++++++++++++++++++++++++++++++++-- widget/button.go | 18 +------------- widget/button_internal_test.go | 14 +++++------ 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/theme/icons.go b/theme/icons.go index 17c525c739..2476078f3b 100644 --- a/theme/icons.go +++ b/theme/icons.go @@ -1,7 +1,11 @@ package theme import ( + "bytes" + "image" "image/color" + _ "image/jpeg" // register JPEG decoder so DisabledResource can desaturate JPEG icons + "image/png" "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal/svg" @@ -838,9 +842,44 @@ 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 +// 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 since they cannot be recolored. 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)) + } + return desaturateLogError(content) +} + +// desaturateLogError returns a PNG-encoded greyscale copy of the given image bytes, +// preserving the alpha channel. If decoding or encoding fails, the original bytes are +// returned so the caller can still render something. +func desaturateLogError(src []byte) []byte { + img, _, err := image.Decode(bytes.NewReader(src)) + if err != nil { + fyne.LogError("Failed to decode bitmap for disabled state", err) + return src + } + 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 := uint8((299*uint32(n.R) + 587*uint32(n.G) + 114*uint32(n.B)) / 1000) + gray.SetNRGBA(x, y, color.NRGBA{R: lum, G: lum, B: lum, A: n.A}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, gray); err != nil { + fyne.LogError("Failed to encode desaturated bitmap", err) + return src + } + return buf.Bytes() } // ThemeColorName returns the fyne.ThemeColorName that is used as foreground color. diff --git a/widget/button.go b/widget/button.go index 2d7483f1f1..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" @@ -45,14 +44,6 @@ const ( ButtonIconTrailingText ) -// disabledIconTranslucency is the alpha-fade applied to bitmap icons (PNG, JPEG, ...) -// when the button is disabled. Themed SVGs are recolored to ColorNameDisabled -// instead, but raster resources cannot be recolored without per-pixel work, so we -// fall back to a fixed translucency. 0.5 is a compromise: more transparent would -// hide colorful icons against the disabled background, less transparent gives no -// visible disabled cue. -const disabledIconTranslucency = 0.5 - var ( _ fyne.Focusable = (*Button)(nil) _ fyne.Accessible = (*Button)(nil) @@ -427,15 +418,8 @@ func (r *buttonRenderer) updateIconAndText() { r.icon.FillMode = canvas.ImageFillContain r.SetObjects([]fyne.CanvasObject{r.background, r.tapBG, r.label, r.icon}) } - r.icon.Translucency = 0 if r.button.Disabled() { - if svg.IsResourceSVG(icon) { - icon = theme.NewDisabledResource(icon) - } else { - // Bitmap resources cannot be recolored, so fade them instead - // to provide a visual disabled cue. - r.icon.Translucency = disabledIconTranslucency - } + icon = theme.NewDisabledResource(icon) } r.icon.Resource = icon r.icon.Refresh() diff --git a/widget/button_internal_test.go b/widget/button_internal_test.go index c02629ccfb..af4a889ba2 100644 --- a/widget/button_internal_test.go +++ b/widget/button_internal_test.go @@ -140,20 +140,18 @@ func TestButton_DisabledBitmapIcon(t *testing.T) { button := NewButtonWithIcon("Test", pngIcon, nil) render := test.TempWidgetRenderer(t, button).(*buttonRenderer) - // Bitmap resource is kept as-is (no DisabledResource wrapping) and not faded while enabled. + // While enabled the original bitmap resource is rendered untouched. assert.Equal(t, pngIcon, render.icon.Resource) - assert.Equal(t, 0.0, render.icon.Translucency) - // When disabled, the bitmap resource is unchanged but the image is faded - // to give a visual disabled cue (cannot recolor a bitmap). + // When disabled the resource is wrapped so DisabledResource.Content() + // returns a desaturated PNG copy — the icon is recolored, not faded. button.Disable() - assert.Equal(t, pngIcon, render.icon.Resource) - assert.Equal(t, disabledIconTranslucency, render.icon.Translucency) + assert.True(t, strings.HasPrefix(render.icon.Resource.Name(), "disabled_"), + "disabled icon should be wrapped: %s", render.icon.Resource.Name()) - // Re-enabling resets the fade. + // Re-enabling restores the original resource reference. button.Enable() assert.Equal(t, pngIcon, render.icon.Resource) - assert.Equal(t, 0.0, render.icon.Translucency) } func TestButton_Focus(t *testing.T) { From e558fb33b235fd92de558e2471e48d520908fdf1 Mon Sep 17 00:00:00 2001 From: mohsen Date: Tue, 30 Jun 2026 09:53:28 +0330 Subject: [PATCH 3/6] Address review: name luma constants, return desaturate error, fix gosec G115 --- theme/icons.go | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/theme/icons.go b/theme/icons.go index 2476078f3b..4a05395fdd 100644 --- a/theme/icons.go +++ b/theme/icons.go @@ -842,26 +842,37 @@ func (res *DisabledResource) Name() string { return "disabled_" + unwrapResource(res.source).Name() } +// 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 since they cannot be recolored. +// are desaturated to greyscale. func (res *DisabledResource) Content() []byte { src := unwrapResource(res.source) content := src.Content() if svg.IsResourceSVG(src) { return colorizeLogError(content, Color(ColorNameDisabled)) } - return desaturateLogError(content) + out, err := desaturate(content) + if err != nil { + fyne.LogError("Failed to desaturate bitmap for disabled state", err) + return content + } + return out } -// desaturateLogError returns a PNG-encoded greyscale copy of the given image bytes, -// preserving the alpha channel. If decoding or encoding fails, the original bytes are -// returned so the caller can still render something. -func desaturateLogError(src []byte) []byte { +// 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 { - fyne.LogError("Failed to decode bitmap for disabled state", err) - return src + return nil, err } bounds := img.Bounds() gray := image.NewNRGBA(bounds) @@ -870,16 +881,18 @@ func desaturateLogError(src []byte) []byte { // 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 := uint8((299*uint32(n.R) + 587*uint32(n.G) + 114*uint32(n.B)) / 1000) - gray.SetNRGBA(x, y, color.NRGBA{R: lum, G: lum, B: lum, A: n.A}) + lum := (lumaWeightR*uint32(n.R) + lumaWeightG*uint32(n.G) + lumaWeightB*uint32(n.B)) / lumaScale + if lum > 255 { + lum = 255 + } + 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 { - fyne.LogError("Failed to encode desaturated bitmap", err) - return src + return nil, err } - return buf.Bytes() + return buf.Bytes(), nil } // ThemeColorName returns the fyne.ThemeColorName that is used as foreground color. From 372597b4cb1a711156d86fa7fdfa42bb66c96810 Mon Sep 17 00:00:00 2001 From: mohsen Date: Tue, 21 Jul 2026 15:05:57 +0330 Subject: [PATCH 4/6] Verify pixel-level desaturation in disabled bitmap test Per review feedback on #6381: TestButton_DisabledBitmapIcon claimed the disabled bitmap was recoloured but only checked the resource name prefix. Decode DisabledResource.Content() and assert every opaque pixel is greyscale (R==G==B). A sanity check on the source PNG confirms it has coloured pixels, so the assertion cannot pass trivially. --- widget/button_internal_test.go | 47 ++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/widget/button_internal_test.go b/widget/button_internal_test.go index af4a889ba2..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" @@ -143,12 +146,52 @@ func TestButton_DisabledBitmapIcon(t *testing.T) { // While enabled the original bitmap resource is rendered untouched. assert.Equal(t, pngIcon, render.icon.Resource) - // When disabled the resource is wrapped so DisabledResource.Content() - // returns a desaturated PNG copy — the icon is recolored, not faded. 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) From e344f197652fdf5926dea8b6f5bf5e5e1ef5ae09 Mon Sep 17 00:00:00 2001 From: mohsen Date: Tue, 21 Jul 2026 15:15:18 +0330 Subject: [PATCH 5/6] Fix revive add-constant lint on desaturate clamp CI's golangci-lint (revive/add-constant) flagged the 255 literals in theme/icons.go. Use math.MaxUint8 so the intent is explicit and no magic number is introduced. --- theme/icons.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/theme/icons.go b/theme/icons.go index 4a05395fdd..3f0c432466 100644 --- a/theme/icons.go +++ b/theme/icons.go @@ -6,6 +6,7 @@ import ( "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" @@ -882,8 +883,8 @@ func desaturate(src []byte) ([]byte, error) { // 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 > 255 { - lum = 255 + 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}) } From 5ffa72f5d46cd72d535f15b001670b22d740342d Mon Sep 17 00:00:00 2001 From: mohsen Date: Tue, 28 Jul 2026 08:42:33 +0330 Subject: [PATCH 6/6] Fall back to source bytes on desaturate error Per review on #6381: return src (not nil) when image.Decode or png.Encode fails so callers get usable content on the error path rather than none. --- theme/icons.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/theme/icons.go b/theme/icons.go index 3f0c432466..022de16cc3 100644 --- a/theme/icons.go +++ b/theme/icons.go @@ -873,7 +873,7 @@ func (res *DisabledResource) Content() []byte { func desaturate(src []byte) ([]byte, error) { img, _, err := image.Decode(bytes.NewReader(src)) if err != nil { - return nil, err + return src, err } bounds := img.Bounds() gray := image.NewNRGBA(bounds) @@ -891,7 +891,7 @@ func desaturate(src []byte) ([]byte, error) { } var buf bytes.Buffer if err := png.Encode(&buf, gray); err != nil { - return nil, err + return src, err } return buf.Bytes(), nil }