From 008f8948f66b7d330b8d18f1d5b719c7fca07ecf Mon Sep 17 00:00:00 2001 From: mohsen Date: Sun, 28 Jun 2026 10:57:24 +0330 Subject: [PATCH 1/3] 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/3] 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/3] 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.