diff --git a/bundles/org.eclipse.e4.ui.workbench.swt/META-INF/MANIFEST.MF b/bundles/org.eclipse.e4.ui.workbench.swt/META-INF/MANIFEST.MF index fc7c8b7746f..c21c53df8ad 100644 --- a/bundles/org.eclipse.e4.ui.workbench.swt/META-INF/MANIFEST.MF +++ b/bundles/org.eclipse.e4.ui.workbench.swt/META-INF/MANIFEST.MF @@ -9,7 +9,7 @@ Bundle-Activator: org.eclipse.e4.ui.internal.workbench.swt.WorkbenchSWTActivator Require-Bundle: org.eclipse.e4.ui.workbench;bundle-version="0.10.0", org.eclipse.e4.core.services;bundle-version="1.0.0", org.eclipse.e4.ui.services;bundle-version="0.1.0", - org.eclipse.jface;bundle-version="[3.39.0,4.0.0)", + org.eclipse.jface;bundle-version="[3.41.0,4.0.0)", org.eclipse.e4.ui.dialogs;bundle-version="1.1.600", org.eclipse.core.databinding;bundle-version="[1.2.0,2.0.0)", org.eclipse.jface.databinding;bundle-version="[1.3.0,2.0.0)", diff --git a/bundles/org.eclipse.e4.ui.workbench.swt/src/org/eclipse/e4/ui/internal/workbench/swt/WorkbenchSWTActivator.java b/bundles/org.eclipse.e4.ui.workbench.swt/src/org/eclipse/e4/ui/internal/workbench/swt/WorkbenchSWTActivator.java index 3a7f5a5cad2..7f0b999fb55 100644 --- a/bundles/org.eclipse.e4.ui.workbench.swt/src/org/eclipse/e4/ui/internal/workbench/swt/WorkbenchSWTActivator.java +++ b/bundles/org.eclipse.e4.ui.workbench.swt/src/org/eclipse/e4/ui/internal/workbench/swt/WorkbenchSWTActivator.java @@ -43,6 +43,8 @@ import org.eclipse.core.runtime.Platform; import org.eclipse.jface.dialogs.DialogSettings; import org.eclipse.jface.dialogs.IDialogSettings; +import org.eclipse.jface.internal.provisional.resource.IImageURLModifier; +import org.eclipse.jface.internal.provisional.resource.ImageURLModifiers; import org.eclipse.osgi.service.datalocation.Location; import org.eclipse.osgi.service.debug.DebugOptions; import org.eclipse.osgi.service.debug.DebugOptionsListener; @@ -62,6 +64,7 @@ public class WorkbenchSWTActivator implements BundleActivator, DebugOptionsListe private BundleContext context; private ServiceTracker locationTracker; + private ServiceTracker imageURLModifierTracker; private static WorkbenchSWTActivator activator; private DebugTrace trace; @@ -89,11 +92,25 @@ public void start(BundleContext context) throws Exception { Hashtable props = new Hashtable<>(2); props.put(DebugOptions.LISTENER_SYMBOLICNAME, PI_RENDERERS); context.registerService(DebugOptionsListener.class, this, props); + ServiceTracker tracker = new ServiceTracker<>(context, + IImageURLModifier.class, null); + tracker.open(); + imageURLModifierTracker = tracker; + // getService() returns the highest ranked service and follows ranking changes + ImageURLModifiers.setURLModifier(url -> { + IImageURLModifier modifier = tracker.getService(); + return modifier == null ? null : modifier.modifyURL(url); + }); } @Override public void stop(BundleContext context) throws Exception { saveDialogSettings(); + if (imageURLModifierTracker != null) { + ImageURLModifiers.setURLModifier(null); + imageURLModifierTracker.close(); + imageURLModifierTracker = null; + } } public Bundle getBundle() { diff --git a/bundles/org.eclipse.jface/META-INF/MANIFEST.MF b/bundles/org.eclipse.jface/META-INF/MANIFEST.MF index a08e60a7a2e..22cbff0d077 100644 --- a/bundles/org.eclipse.jface/META-INF/MANIFEST.MF +++ b/bundles/org.eclipse.jface/META-INF/MANIFEST.MF @@ -20,6 +20,7 @@ Export-Package: org.eclipse.jface, org.eclipse.jface.images, org.eclipse.jface.internal;x-friends:="org.eclipse.ui.workbench,org.eclipse.e4.ui.workbench.renderers.swt,org.eclipse.jface.tests", org.eclipse.jface.internal.provisional.action;x-friends:="org.eclipse.ui.workbench,org.eclipse.ui.ide", + org.eclipse.jface.internal.provisional.resource;x-friends:="org.eclipse.e4.ui.workbench.swt,org.eclipse.e4.ui.tests,org.eclipse.jface.tests", org.eclipse.jface.layout, org.eclipse.jface.menus, org.eclipse.jface.operation, diff --git a/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/IImageURLModifier.java b/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/IImageURLModifier.java new file mode 100644 index 00000000000..9fca82f71d7 --- /dev/null +++ b/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/IImageURLModifier.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2026 vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.jface.internal.provisional.resource; + +import java.net.URL; + +/** + * Rewrites image URLs before they are loaded, so icons can be substituted + * without changing the code that creates the image descriptors. The E4 + * workbench installs the highest ranked OSGi service of this type. + * + * @see ImageURLModifiers#setURLModifier(IImageURLModifier) + */ +@FunctionalInterface +public interface IImageURLModifier { + + /** + * Returns the URL to load instead of the given one, or null to + * keep it. Called for every image load, so it must be fast and thread-safe. + * + * @param originalURL the URL the image would be loaded from + * @return the replacement URL, or null to keep the original + */ + URL modifyURL(URL originalURL); +} diff --git a/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/ImageURLModifiers.java b/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/ImageURLModifiers.java new file mode 100644 index 00000000000..e183e2c9fb2 --- /dev/null +++ b/bundles/org.eclipse.jface/src/org/eclipse/jface/internal/provisional/resource/ImageURLModifiers.java @@ -0,0 +1,44 @@ +/******************************************************************************* + * Copyright (c) 2026 vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.jface.internal.provisional.resource; + +/** + * Holds the {@link IImageURLModifier} consulted when an image is loaded from a + * URL. + */ +public final class ImageURLModifiers { + + /** Read from any thread. */ + private static volatile IImageURLModifier urlModifier; + + private ImageURLModifiers() { + } + + /** + * Installs the modifier consulted when an image is loaded from a URL. Images + * created before are not reloaded. + * + * @param modifier the modifier, or null to remove it + */ + public static void setURLModifier(IImageURLModifier modifier) { + urlModifier = modifier; + } + + /** + * @return the installed modifier, or null if none is installed + */ + public static IImageURLModifier getURLModifier() { + return urlModifier; + } +} diff --git a/bundles/org.eclipse.jface/src/org/eclipse/jface/resource/URLImageDescriptor.java b/bundles/org.eclipse.jface/src/org/eclipse/jface/resource/URLImageDescriptor.java index 646ca5cb90f..f05bb3c8986 100644 --- a/bundles/org.eclipse.jface/src/org/eclipse/jface/resource/URLImageDescriptor.java +++ b/bundles/org.eclipse.jface/src/org/eclipse/jface/resource/URLImageDescriptor.java @@ -33,6 +33,8 @@ import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.Status; import org.eclipse.jface.internal.InternalPolicy; +import org.eclipse.jface.internal.provisional.resource.IImageURLModifier; +import org.eclipse.jface.internal.provisional.resource.ImageURLModifiers; import org.eclipse.jface.util.Policy; import org.eclipse.swt.SWT; import org.eclipse.swt.SWTException; @@ -63,7 +65,7 @@ private ImageFileNameProvider createURLImageFileNameProvider() { // The calling image will do that itself! return getFilePath(tempURL, logIOException); } - return getZoomedImageSource(tempURL, url, zoom, u -> getFilePath(u, logIOException)); + return getZoomedImageSource(tempURL, zoom, u -> getFilePath(u, logIOException)); } return null; }; @@ -107,12 +109,12 @@ public ImageData getImageData(int zoom) { if (zoom == 100 || canLoadAtZoom(tempURL, zoom)) { return getImageData(tempURL, 100, zoom); } - return getZoomedImageSource(tempURL, url, zoom, u -> getImageData(u, zoom, zoom)); + return getZoomedImageSource(tempURL, zoom, u -> getImageData(u, zoom, zoom)); } return null; } - private static R getZoomedImageSource(URL url, String urlString, int zoom, Function getImage) { + private static R getZoomedImageSource(URL url, int zoom, Function getImage) { URL xUrl = getxURL(url, zoom); if (xUrl != null) { R xdata = getImage.apply(xUrl); @@ -120,9 +122,10 @@ private static R getZoomedImageSource(URL url, String urlString, int zoom, F return xdata; } } - String xpath = getxPath(urlString, zoom); + // derived from the already modified URL, so the modifier must not run again + String xpath = getxPath(url.toExternalForm(), zoom); if (xpath != null) { - URL xPathUrl = getURL(xpath); + URL xPathUrl = toURL(xpath); if (xPathUrl != null) { return getImage.apply(xPathUrl); } @@ -352,14 +355,30 @@ public Image createImage(boolean returnMissingImageOnError, Device device) { } } + /** + * Resolves the given URL string, applying the URL modifier. Every code path of + * this descriptor resolves its URL here. + */ private static URL getURL(String urlString) { - URL result = null; + URL result = toURL(urlString); + IImageURLModifier modifier = ImageURLModifiers.getURLModifier(); + if (result != null && modifier != null) { + URL modified = modifier.modifyURL(result); + if (modified != null) { + return modified; + } + } + return result; + } + + @SuppressWarnings("deprecation") // keeps the lenient parsing of new URL(String) + private static URL toURL(String urlString) { try { - result = new URL(urlString); + return new URL(urlString); } catch (MalformedURLException e) { Policy.logException(e); + return null; } - return result; } @Override diff --git a/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/UIAllTests.java b/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/UIAllTests.java index f77ed00292a..9a9ef8c92ac 100644 --- a/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/UIAllTests.java +++ b/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/UIAllTests.java @@ -33,6 +33,7 @@ import org.eclipse.e4.ui.tests.workbench.ExtensionsSortTests; import org.eclipse.e4.ui.tests.workbench.HandlerActivationTest; import org.eclipse.e4.ui.tests.workbench.HandlerTest; +import org.eclipse.e4.ui.tests.workbench.ImageURLModifierTrackerTest; import org.eclipse.e4.ui.tests.workbench.InjectionEventTest; import org.eclipse.e4.ui.tests.workbench.MApplicationCommandAccessTest; import org.eclipse.e4.ui.tests.workbench.MMenuItemTest; @@ -101,6 +102,7 @@ ExtensionsSortTests.class, HandlerActivationTest.class, ModelAssemblerTests.class, + ImageURLModifierTrackerTest.class, ModelAssemblerFragmentOrderingTests.class, E4ResourceTest.class, AreaRendererTest.class, diff --git a/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/workbench/ImageURLModifierTrackerTest.java b/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/workbench/ImageURLModifierTrackerTest.java new file mode 100644 index 00000000000..7d4bd87a14e --- /dev/null +++ b/tests/org.eclipse.e4.ui.tests/src/org/eclipse/e4/ui/tests/workbench/ImageURLModifierTrackerTest.java @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (c) 2026 vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.e4.ui.tests.workbench; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.eclipse.core.runtime.Adapters; +import org.eclipse.core.runtime.Platform; +import org.eclipse.jface.internal.provisional.resource.IImageURLModifier; +import org.eclipse.jface.resource.ImageDescriptor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.Constants; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.ServiceRegistration; + +/** + * Tests that the E4 workbench installs the highest ranked + * {@link IImageURLModifier} service in JFace. + */ +public class ImageURLModifierTrackerTest { + + private final BundleContext context = FrameworkUtil.getBundle(ImageURLModifierTrackerTest.class) + .getBundleContext(); + private final List> registrations = new ArrayList<>(); + private ImageDescriptor descriptor; + + @BeforeAll + public static void startWorkbenchBundle() throws Exception { + // lazily activated, and earlier tests may not have loaded its classes + Platform.getBundle("org.eclipse.e4.ui.workbench.swt").start(Bundle.START_TRANSIENT); + } + + @BeforeEach + public void setUp() throws Exception { + descriptor = ImageDescriptor.createFromURL(URI.create("file:/original.png").toURL()); + } + + @AfterEach + public void tearDown() { + for (ServiceRegistration registration : registrations) { + try { + registration.unregister(); + } catch (IllegalStateException e) { + // already unregistered by the test + } + } + } + + @Test + public void testHighestRankedModifierIsInstalled() { + register("a", 1); + register("b", 5); + register("c", 3); + + assertEquals("file:/b.png", resolvedURL()); + } + + @Test + public void testRemovingInstalledModifierFallsBackToNext() { + register("a", 1); + ServiceRegistration b = register("b", 5); + + b.unregister(); + assertEquals("file:/a.png", resolvedURL()); + + registrations.get(0).unregister(); + assertEquals("file:/original.png", resolvedURL()); + } + + @Test + public void testRankingChangeIsApplied() { + ServiceRegistration a = register("a", 1); + register("b", 5); + + a.setProperties(FrameworkUtil.asDictionary(Map.of(Constants.SERVICE_RANKING, 10))); + + assertEquals("file:/a.png", resolvedURL()); + } + + private ServiceRegistration register(String name, int ranking) { + IImageURLModifier modifier = _ -> { + try { + return URI.create("file:/" + name + ".png").toURL(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }; + ServiceRegistration registration = context.registerService(IImageURLModifier.class, + modifier, FrameworkUtil.asDictionary(Map.of(Constants.SERVICE_RANKING, ranking))); + registrations.add(registration); + return registration; + } + + private String resolvedURL() { + return Adapters.adapt(descriptor, URL.class).toExternalForm(); + } +} diff --git a/tests/org.eclipse.jface.tests/src/org/eclipse/jface/tests/images/UrlImageDescriptorTest.java b/tests/org.eclipse.jface.tests/src/org/eclipse/jface/tests/images/UrlImageDescriptorTest.java index 31ef2696927..804bdb46d26 100644 --- a/tests/org.eclipse.jface.tests/src/org/eclipse/jface/tests/images/UrlImageDescriptorTest.java +++ b/tests/org.eclipse.jface.tests/src/org/eclipse/jface/tests/images/UrlImageDescriptorTest.java @@ -29,10 +29,12 @@ import org.eclipse.core.runtime.Adapters; import org.eclipse.core.runtime.IPath; import org.eclipse.jface.internal.InternalPolicy; +import org.eclipse.jface.internal.provisional.resource.ImageURLModifiers; import org.eclipse.jface.resource.ImageDescriptor; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.ImageFileNameProvider; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -202,4 +204,115 @@ public void testAdaptToURL() { assertEquals(imageDataOrig200.height, imageDataURL200.height); } -} \ No newline at end of file + @AfterEach + public void clearURLModifier() { + ImageURLModifiers.setURLModifier(null); + } + + @Test + public void testNoURLModifierInstalledByDefault() { + ImageDescriptor descriptor = ImageDescriptor + .createFromURL(FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png")); + assertEquals("zoomIn.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + } + + @Test + public void testURLModifierRedirectsImageData() { + URL zoomIn = FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png"); + URL rectangular = FileImageDescriptorTest.class.getResource("/icons/imagetests/rectangular-57x16.png"); + ImageData original = ImageDescriptor.createFromURL(zoomIn).getImageData(100); + ImageData replacement = ImageDescriptor.createFromURL(rectangular).getImageData(100); + assertNotEquals(original.width, replacement.width, "test images are indistinguishable"); + + ImageDescriptor descriptor = ImageDescriptor.createFromURL(zoomIn); + ImageURLModifiers.setURLModifier(url -> zoomIn.toExternalForm().equals(url.toExternalForm()) ? rectangular : url); + + assertEquals(replacement.width, descriptor.getImageData(100).width); + } + + @Test + public void testURLModifierLeavesUnmatchedURLsAlone() { + ImageDescriptor descriptor = ImageDescriptor + .createFromURL(FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png")); + ImageURLModifiers.setURLModifier(url -> url); + + assertEquals("zoomIn.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + } + + @Test + public void testURLModifierReturningNullKeepsOriginalURL() { + ImageDescriptor descriptor = ImageDescriptor + .createFromURL(FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png")); + ImageURLModifiers.setURLModifier(_ -> null); + + assertEquals("zoomIn.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + } + + @Test + public void testURLModifierIsClearedByNull() { + URL zoomIn = FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png"); + URL rectangular = FileImageDescriptorTest.class.getResource("/icons/imagetests/rectangular-57x16.png"); + ImageDescriptor descriptor = ImageDescriptor.createFromURL(zoomIn); + + ImageURLModifiers.setURLModifier(_ -> rectangular); + assertEquals("rectangular-57x16.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + + ImageURLModifiers.setURLModifier(null); + assertEquals("zoomIn.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + } + + /** + * The @2x variant is derived from the rewritten URL, so an icon pack only + * supplies the base name. + */ + @Test + public void testURLModifierIsAppliedBeforeHiDpiLookup() { + URL rectangular = FileImageDescriptorTest.class.getResource("/icons/imagetests/rectangular-57x16.png"); + URL zoomIn = FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png"); + ImageDescriptor descriptor = ImageDescriptor.createFromURL(rectangular); + + ImageURLModifiers.setURLModifier(url -> url.getPath().contains("rectangular-57x16") ? zoomIn : url); + + assertEquals("zoomIn.png", IPath.fromOSString(imagePath(descriptor, 100)).lastSegment()); + assertEquals("zoomIn@2x.png", IPath.fromOSString(imagePath(descriptor, 200)).lastSegment()); + } + + /** + * The scaled name fallback is derived from the rewritten URL too, so an icon + * pack is not mixed with platform icons at higher zoom levels. + */ + @Test + public void testURLModifierIsAppliedBeforeScaledNameLookup() { + URL zoomIn = FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png"); + URL rectangular = FileImageDescriptorTest.class.getResource("/icons/imagetests/rectangular-57x16.png"); + ImageDescriptor descriptor = ImageDescriptor.createFromURL(zoomIn); + + ImageURLModifiers.setURLModifier(url -> url.getPath().endsWith("/zoomIn.png") ? rectangular : url); + + assertEquals("rectangular-114x32.png", IPath.fromOSString(imagePath(descriptor, 200)).lastSegment()); + assertEquals("rectangular-86x24.png", IPath.fromOSString(imagePath(descriptor, 150)).lastSegment()); + assertEquals(114, descriptor.getImageData(200).width); + } + + @Test + public void testURLModifierIsAppliedToAdaptedURL() { + URL zoomIn = FileImageDescriptorTest.class.getResource("/icons/imagetests/zoomIn.png"); + URL rectangular = FileImageDescriptorTest.class.getResource("/icons/imagetests/rectangular-57x16.png"); + ImageDescriptor descriptor = ImageDescriptor.createFromURL(zoomIn); + + ImageURLModifiers.setURLModifier(_ -> rectangular); + + URL adapted = Adapters.adapt(descriptor, URL.class); + assertNotNull(adapted, "URLImageDescriptor does not adapt to URL"); + assertEquals(rectangular.toExternalForm(), adapted.toExternalForm()); + } + + private static String imagePath(ImageDescriptor descriptor, int zoom) { + ImageFileNameProvider fileNameProvider = Adapters.adapt(descriptor, ImageFileNameProvider.class); + assertNotNull(fileNameProvider, "URLImageDescriptor does not adapt to ImageFileNameProvider"); + String path = fileNameProvider.getImagePath(zoom); + assertNotNull(path, "URLImageDescriptor ImageFileNameProvider does not return the " + zoom + "% path"); + return path; + } + +}