From 86f9070c38a2b2d8cc80e19776e7f42e5e3ca4a7 Mon Sep 17 00:00:00 2001 From: raghucssit Date: Wed, 18 Mar 2026 22:39:37 +0100 Subject: [PATCH] Add font zoom functionality to console view -Add key listener on text widget of console view which listens ctrl plus and control minus(including numpad +/-) -Zoom in/out of the console view text in steps based on the keys pressed. see https://github.com/eclipse-platform/eclipse.platform/issues/2578 --- .../debug/tests/console/ConsoleTests.java | 416 +++++++++++++++++ .../org.eclipse.ui.console/plugin.properties | 4 + debug/org.eclipse.ui.console/plugin.xml | 63 +++ .../ui/internal/console/ConsoleManager.java | 3 + .../console/ConsoleZoomInHandler.java | 440 ++++++++++++++++++ .../console/ConsoleZoomOutHandler.java | 38 ++ 6 files changed, 964 insertions(+) create mode 100644 debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomInHandler.java create mode 100644 debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomOutHandler.java diff --git a/debug/org.eclipse.debug.tests/src/org/eclipse/debug/tests/console/ConsoleTests.java b/debug/org.eclipse.debug.tests/src/org/eclipse/debug/tests/console/ConsoleTests.java index 470aaea7163..d05cd871520 100644 --- a/debug/org.eclipse.debug.tests/src/org/eclipse/debug/tests/console/ConsoleTests.java +++ b/debug/org.eclipse.debug.tests/src/org/eclipse/debug/tests/console/ConsoleTests.java @@ -16,17 +16,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import org.eclipse.core.commands.Command; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; import org.eclipse.debug.tests.DebugTestExtension; import org.eclipse.debug.tests.TestUtil; import org.eclipse.jface.text.IDocument; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.widgets.Display; import org.eclipse.ui.IViewPart; import org.eclipse.ui.IWorkbenchCommandConstants; import org.eclipse.ui.IWorkbenchPage; @@ -36,10 +43,14 @@ import org.eclipse.ui.console.IConsole; import org.eclipse.ui.console.IConsoleConstants; import org.eclipse.ui.console.IConsoleManager; +import org.eclipse.ui.console.IConsoleView; import org.eclipse.ui.console.IOConsole; import org.eclipse.ui.console.IOConsoleOutputStream; import org.eclipse.ui.console.MessageConsole; +import org.eclipse.ui.console.TextConsole; +import org.eclipse.ui.handlers.IHandlerService; import org.eclipse.ui.internal.console.ConsoleManager; +import org.eclipse.ui.internal.console.ConsoleZoomInHandler; import org.eclipse.ui.texteditor.IWorkbenchActionDefinitionIds; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; @@ -233,4 +244,409 @@ public void testIOConsoleAvailable() throws Exception { consoleInput.available(); } } + + /** + * Zooming in must only change the font size of the console currently + * shown in the active console view; a different console (of a different + * type, with its own natural font) that was already open must not be + * affected. + */ + @Test + public void testZoomOnlyAffectsActiveConsoleType(TestInfo testInfo) throws Exception { + String activeType = uniqueConsoleType("active"); //$NON-NLS-1$ + String otherType = uniqueConsoleType("other"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole activeConsole = new MessageConsole("Zoom Active Console", activeType, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + MessageConsole otherConsole = new MessageConsole("Zoom Other Console", otherType, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { activeConsole, otherConsole }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(activeConsole); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int activeInitialHeight = getFontHeight(activeConsole); + int otherInitialHeight = getFontHeight(otherConsole); + + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + + assertEquals(activeInitialHeight + 1, getFontHeight(activeConsole), + "zooming should increase the font size of the console shown in the active console view"); //$NON-NLS-1$ + assertEquals(otherInitialHeight, getFontHeight(otherConsole), + "zooming must not affect a console of a different type that was already open"); //$NON-NLS-1$ + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + } + } + + /** + * A font change coming from somewhere else (e.g. a preference page) must + * be able to override the current zoom instead of being silently + * reverted. + *

+ * The very first external font change on a freshly zoomed console is + * reasserted once (to cope with consoles, such as {@code ProcessConsole}, + * that set their own font asynchronously right after being zoomed), so + * this test performs such a change first and confirms it is reverted, + * before confirming that a second, later change is genuinely honored. + *

+ */ + @Test + public void testPreferenceFontChangeOverridesZoom(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("pref"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole console = new MessageConsole("Zoom Preference Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + Font reassertedFont = null; + Font preferenceFont = null; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int naturalHeight = getFontHeight(console); + + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + int zoomedHeight = getFontHeight(console); + assertEquals(naturalHeight + 1, zoomedHeight, "zoom should have increased the font size"); //$NON-NLS-1$ + + // simulate the console re-asserting its own natural font once, right + // after being zoomed (mirrors e.g. ProcessConsole's asynchronous font + // initialization) - this first external change is expected to be + // reverted back to the current zoom level + reassertedFont = withHeight(console.getFont(), naturalHeight); + console.setFont(reassertedFont); + processUiEvents(); + assertEquals(zoomedHeight, getFontHeight(console), + "the console's own re-assertion of its natural font should have been reverted back to the current zoom level"); //$NON-NLS-1$ + + // simulate an actual, deliberate font change from a preference page: + // this must be accepted as the new base font, overriding the zoom + int preferenceHeight = naturalHeight + 5; + preferenceFont = withHeight(console.getFont(), preferenceHeight); + console.setFont(preferenceFont); + processUiEvents(); + assertEquals(preferenceHeight, getFontHeight(console), + "a later, deliberate font change (e.g. from a preference page) should override the current zoom"); //$NON-NLS-1$ + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + if (reassertedFont != null && !reassertedFont.isDisposed()) { + reassertedFont.dispose(); + } + if (preferenceFont != null && !preferenceFont.isDisposed()) { + preferenceFont.dispose(); + } + } + } + + /** + * The current zoom level (base font height and delta) must be persisted + * to the console plug-in's preferences as soon as a zoom step is applied. + */ + @Test + public void testZoomLevelIsPersisted(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("persist"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole console = new MessageConsole("Zoom Persistence Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int naturalHeight = getFontHeight(console); + + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + + String persisted = getPersistedZoomState(); + String expectedEntry = type + "=" + naturalHeight + "|" + 1; //$NON-NLS-1$ //$NON-NLS-2$ + assertThat(persisted).as("persisted zoom state should contain an entry for the zoomed console type") //$NON-NLS-1$ + .contains(expectedEntry); + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + } + } + + /** + * A console added later, of a type that has already been zoomed, must + * immediately start at the current zoom level for that type - even + * though it was never itself shown in the active console view. + */ + @Test + public void testNewConsoleOfAlreadyZoomedTypeInheritsZoom(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("inherit"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole firstConsole = new MessageConsole("Zoom Inherit Console 1", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] firstConsoles = { firstConsole }; + MessageConsole secondConsole = null; + try { + consoleManager.addConsoles(firstConsoles); + consoleManager.showConsoleView(firstConsole); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int naturalHeight = getFontHeight(firstConsole); + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + int zoomedHeight = getFontHeight(firstConsole); + assertEquals(naturalHeight + 1, zoomedHeight, "zoom should have increased the font size"); //$NON-NLS-1$ + + // a second console of the SAME type, added afterwards and never shown + // in the console view, should still start at the current zoom level + secondConsole = new MessageConsole("Zoom Inherit Console 2", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + consoleManager.addConsoles(new IConsole[] { secondConsole }); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + assertEquals(zoomedHeight, getFontHeight(secondConsole), + "a newly added console of an already zoomed type should immediately start at the current zoom level"); //$NON-NLS-1$ + } finally { + if (secondConsole != null) { + consoleManager.removeConsoles(new IConsole[] { secondConsole }); + } + consoleManager.removeConsoles(firstConsoles); + activePage.hideView(consoleViewPart); + } + } + + /** + * Zooming out must decrease the font size, mirroring zoom in. + */ + @Test + public void testZoomOutDecreasesFontSize(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("zoomOut"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole console = new MessageConsole("Zoom Out Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int naturalHeight = getFontHeight(console); + + // zoom in twice, then out once: should settle one step above natural + ConsoleZoomInHandler.applyZoom(consoleView, 1); + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + assertEquals(naturalHeight + 2, getFontHeight(console), "two zoom-in steps should increase the font size by 2"); //$NON-NLS-1$ + + ConsoleZoomInHandler.applyZoom(consoleView, -1); + processUiEvents(); + assertEquals(naturalHeight + 1, getFontHeight(console), "a zoom-out step should decrease the font size"); //$NON-NLS-1$ + + // zoom back down to (and past) the natural size + ConsoleZoomInHandler.applyZoom(consoleView, -1); + ConsoleZoomInHandler.applyZoom(consoleView, -1); + processUiEvents(); + assertEquals(naturalHeight - 1, getFontHeight(console), + "zooming out below the natural size should be possible"); //$NON-NLS-1$ + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + } + } + + /** + * The font height must never go below the minimum, nor above the maximum + * supported font size, regardless of how large a zoom delta is applied. + */ + @Test + public void testZoomClampsAtMinimumAndMaximumFontSize(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("clamp"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole console = new MessageConsole("Zoom Clamp Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int minFontSize = getStaticIntField("MIN_FONT_SIZE"); //$NON-NLS-1$ + int maxFontSize = getStaticIntField("MAX_FONT_SIZE"); //$NON-NLS-1$ + + ConsoleZoomInHandler.applyZoom(consoleView, 1000); + processUiEvents(); + assertEquals(maxFontSize, getFontHeight(console), "zooming in by a huge delta should clamp at the maximum font size"); //$NON-NLS-1$ + + ConsoleZoomInHandler.applyZoom(consoleView, -10000); + processUiEvents(); + assertEquals(minFontSize, getFontHeight(console), "zooming out by a huge delta should clamp at the minimum font size"); //$NON-NLS-1$ + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + } + } + + /** + * When a console is removed, its custom zoom font must be disposed so it + * is not leaked (see also the {@code SWT Resource was not properly + * disposed} regression this guards against). + */ + @Test + public void testRemovingConsoleDisposesZoomFont(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("disposeOnRemove"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + IConsoleView consoleView = (IConsoleView) consoleViewPart; + + MessageConsole console = new MessageConsole("Zoom Dispose Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + ConsoleZoomInHandler.applyZoom(consoleView, 1); + processUiEvents(); + + Field zoomFontAttributeField = ConsoleZoomInHandler.class.getDeclaredField("ZOOM_FONT_ATTRIBUTE"); //$NON-NLS-1$ + zoomFontAttributeField.setAccessible(true); + String zoomFontAttribute = (String) zoomFontAttributeField.get(null); + + Object attribute = console.getAttribute(zoomFontAttribute); + assertThat(attribute).as("a custom zoom font should have been created").isInstanceOf(Font.class); //$NON-NLS-1$ + Font zoomFont = (Font) attribute; + assertFalse(zoomFont.isDisposed(), "the zoom font must not be disposed while its console is still open"); //$NON-NLS-1$ + + consoleManager.removeConsoles(consoles); + processUiEvents(); + + assertTrue(zoomFont.isDisposed(), "the zoom font must be disposed once its console is removed"); //$NON-NLS-1$ + } finally { + activePage.hideView(consoleViewPart); + } + } + + /** + * Executing the actual registered zoom-in command (as a keybinding would) + * while the console view is the active part must zoom the console it is + * currently showing. + */ + @Test + public void testZoomCommandExecutesWhileConsoleViewActive(TestInfo testInfo) throws Exception { + String type = uniqueConsoleType("command"); //$NON-NLS-1$ + + IWorkbenchPage activePage = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart consoleViewPart = activePage.showView(IConsoleConstants.ID_CONSOLE_VIEW); + + MessageConsole console = new MessageConsole("Zoom Command Console", type, null, //$NON-NLS-1$ + StandardCharsets.UTF_8.name(), true); + IConsoleManager consoleManager = ConsolePlugin.getDefault().getConsoleManager(); + IConsole[] consoles = { console }; + try { + consoleManager.addConsoles(consoles); + consoleManager.showConsoleView(console); + activePage.activate(consoleViewPart); + TestUtil.waitForJobs(testInfo.getDisplayName(), ConsoleManager.CONSOLE_JOB_FAMILY, 100, 3000); + processUiEvents(); + + int naturalHeight = getFontHeight(console); + + IHandlerService handlerService = PlatformUI.getWorkbench().getService(IHandlerService.class); + handlerService.executeCommand("org.eclipse.ui.console.command.fontZoomIn", null); //$NON-NLS-1$ + processUiEvents(); + + assertEquals(naturalHeight + 1, getFontHeight(console), + "executing the zoom-in command should increase the font size of the active console"); //$NON-NLS-1$ + } finally { + consoleManager.removeConsoles(consoles); + activePage.hideView(consoleViewPart); + } + } + + private static int getStaticIntField(String name) throws ReflectiveOperationException { + Field field = ConsoleZoomInHandler.class.getDeclaredField(name); + field.setAccessible(true); + return field.getInt(null); + } + + private static String uniqueConsoleType(String suffix) { + return "org.eclipse.debug.tests.console.zoomTest." + suffix + "." + System.nanoTime(); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private static int getFontHeight(TextConsole console) { + Font font = console.getFont(); + FontData[] fontData = font.getFontData(); + return fontData[0].getHeight(); + } + + private static Font withHeight(Font font, int height) { + FontData fontData = font.getFontData()[0]; + return new Font(font.getDevice(), fontData.getName(), height, fontData.getStyle()); + } + + private static void processUiEvents() { + Display display = Display.getCurrent(); + if (display != null) { + while (display.readAndDispatch()) { + // drain pending UI work (e.g. asyncExec calls) triggered by the zoom handler + } + } + } + + /** + * Reads the raw, persisted per-console-type zoom state string via + * reflection, since {@code ConsoleZoomInHandler} intentionally does not + * expose its preference key as public API. + */ + private static String getPersistedZoomState() throws ReflectiveOperationException { + Field prefKeyField = ConsoleZoomInHandler.class.getDeclaredField("PREF_ZOOM_FONT_HEIGHTS"); //$NON-NLS-1$ + prefKeyField.setAccessible(true); + String prefKey = (String) prefKeyField.get(null); + IEclipsePreferences preferences = InstanceScope.INSTANCE.getNode(ConsolePlugin.getUniqueIdentifier()); + return preferences.get(prefKey, ""); //$NON-NLS-1$ + } } diff --git a/debug/org.eclipse.ui.console/plugin.properties b/debug/org.eclipse.ui.console/plugin.properties index 7f084b3ff55..5dc60ccbc69 100644 --- a/debug/org.eclipse.ui.console/plugin.properties +++ b/debug/org.eclipse.ui.console/plugin.properties @@ -37,3 +37,7 @@ command.copy_without_escapes.name = Copy Text Without ANSI Escapes command.copy_without_escapes.description = Copy the console content to clipboard, removing the escape sequences command.enable_disable.name = Enable / Disable ANSI Support command.enable_disable.description = Enable / disable ANSI Support +command.console.fontZoomIn.name = Zoom In Console Font +command.console.fontZoomIn.description = Increase the console font size +command.console.fontZoomOut.name = Zoom Out Console Font +command.console.fontZoomOut.description = Decrease the console font size diff --git a/debug/org.eclipse.ui.console/plugin.xml b/debug/org.eclipse.ui.console/plugin.xml index cf75e8936ce..48cd9f4e917 100644 --- a/debug/org.eclipse.ui.console/plugin.xml +++ b/debug/org.eclipse.ui.console/plugin.xml @@ -91,6 +91,36 @@ M4 = Platform-specific fourth key sequence="M1+M2+Insert"> --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleManager.java b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleManager.java index 3e8de2d787b..ff52da5fafc 100644 --- a/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleManager.java +++ b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleManager.java @@ -97,6 +97,9 @@ public ConsoleManager() { fConsoleViews = new ArrayList<>(); redrawConsoleJob = new RedrawJob(); showConsoleJob = new ShowConsoleViewJob(); + // Start ConsoleZoomInHandler which installs a listener on this console + // manager to keep consoles added later in sync with the current zoom level. + ConsoleZoomInHandler.startup(this); warnAboutContentChangeJob = new WarnAboutContentChangedJob(); } diff --git a/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomInHandler.java b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomInHandler.java new file mode 100644 index 00000000000..b7b20266ea1 --- /dev/null +++ b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomInHandler.java @@ -0,0 +1,440 @@ +/******************************************************************************* + * Copyright (c) 2026 Advantest Europe 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: + * Raghunandana Murthappa + *******************************************************************************/ +package org.eclipse.ui.internal.console; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.jface.util.IPropertyChangeListener; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.console.ConsolePlugin; +import org.eclipse.ui.console.IConsole; +import org.eclipse.ui.console.IConsoleConstants; +import org.eclipse.ui.console.IConsoleListener; +import org.eclipse.ui.console.IConsoleManager; +import org.eclipse.ui.console.IConsoleView; +import org.eclipse.ui.console.TextConsole; +import org.eclipse.ui.handlers.HandlerUtil; +import org.osgi.service.prefs.BackingStoreException; + +/** + * Command handler to increase the font size of the console currently shown in + * the active console view. + *

+ * Zoom is tracked per {@link IConsole#getType() console type} (since types can + * have different natural font sizes) and applied to every registered console + * of that type, as well as to ones added later. Font changes coming from + * elsewhere (e.g. a preference page) are never reverted; they are instead + * adopted as the new base font, resetting the zoom delta to zero. + *

+ */ +public class ConsoleZoomInHandler extends AbstractHandler { + + /** + * Key used to remember, on the console itself, the custom font created for + * zooming, so it can be reused/replaced and eventually disposed. + */ + static final String ZOOM_FONT_ATTRIBUTE = ConsoleZoomInHandler.class.getName() + ".zoomFont"; //$NON-NLS-1$ + + /** + * Key used to remember, on the console itself, that a mismatching font change + * was already reasserted once for it; see {@link #onFontChanged(TextConsole)}. + */ + private static final String REENFORCED_ATTRIBUTE = ConsoleZoomInHandler.class.getName() + ".reenforced"; //$NON-NLS-1$ + + private static final int MIN_FONT_SIZE = 6; + private static final int MAX_FONT_SIZE = 72; + private static final int STEP = 1; + + /** + * Key used, in the per-type persisted zoom string, for consoles that report a + * null {@link IConsole#getType() type}. + */ + private static final String DEFAULT_TYPE_KEY = "$default$"; //$NON-NLS-1$ + + private static final String TYPE_ENTRY_SEPARATOR = ":"; //$NON-NLS-1$ + private static final String TYPE_VALUE_SEPARATOR = "="; //$NON-NLS-1$ + private static final String BASE_DELTA_SEPARATOR = "|"; //$NON-NLS-1$ + + /** + * Preference key under which the per console type zoom state is persisted, + * as type1=base1|delta1:type2=base2|delta2. There is + * intentionally no preference page for this. + */ + private static final String PREF_ZOOM_FONT_HEIGHTS = ConsolePlugin.getUniqueIdentifier() + ".zoomFontHeights"; //$NON-NLS-1$ + + /** + * The base (natural, un-zoomed) font height and the zoom delta currently + * applied on top of it, for consoles of a given type. + */ + private record ZoomState(int base, int delta) { + int height() { + return base + delta; + } + } + + /** + * Shared zoom bookkeeping, keyed by {@link IConsole#getType() console type}. + * A missing entry means that type has never been zoomed. + */ + private static final Map sZoomByType = new ConcurrentHashMap<>(); + + /** + * Re-applies the expected zoom whenever a console's font changes after the + * console was added, unless the new height doesn't match what we expect - in + * which case it's an external change, so it's adopted as the new base font + * instead of being reverted; see {@link #onFontChanged(TextConsole)}. + */ + private static final IPropertyChangeListener FONT_ENFORCER = event -> { + if (!IConsoleConstants.P_FONT.equals(event.getProperty())) { + return; + } + if (event.getSource() instanceof TextConsole textConsole) { + onFontChanged(textConsole); + } + }; + + /** + * Applies the current zoom to newly added consoles and attaches + * {@link #FONT_ENFORCER} to them, and disposes each console's custom zoom + * font when it is removed. + */ + private static final IConsoleListener ZOOM_FONT_LISTENER = new IConsoleListener() { + @Override + public void consolesAdded(IConsole[] consoles) { + for (IConsole console : consoles) { + if (console instanceof TextConsole textConsole) { + textConsole.addPropertyChangeListener(FONT_ENFORCER); + ZoomState state = sZoomByType.get(typeKey(textConsole)); + if (state != null) { + applyHeight(textConsole, state.height()); + } + } + } + } + + @Override + public void consolesRemoved(IConsole[] consoles) { + for (IConsole console : consoles) { + if (console instanceof TextConsole textConsole) { + textConsole.removePropertyChangeListener(FONT_ENFORCER); + disposeZoomFont(textConsole); + } + } + } + }; + + /** + * Registers {@link #ZOOM_FONT_LISTENER} and loads any persisted zoom state. + * Must be called with the manager directly (not via + * {@link ConsolePlugin#getConsoleManager()}), since this runs from + * {@code ConsoleManager}'s own constructor. + * + * @param consoleManager the console manager being initialized + */ + public static void startup(IConsoleManager consoleManager) { + consoleManager.addConsoleListener(ZOOM_FONT_LISTENER); + sZoomByType.putAll(loadPersistedZoomStates()); + } + + /** + * Returns the key under which zoom state for the given console's type is tracked. + * + * @param console the console + * @return the console's type, or {@link #DEFAULT_TYPE_KEY} if it has none + */ + private static String typeKey(IConsole console) { + String type = console.getType(); + return type != null ? type : DEFAULT_TYPE_KEY; + } + + /** + * Reacts to a font change on the given console: matching the expected zoom + * height is a no-op. A mismatch is reasserted (zoom re-applied) the first + * time it occurs for that console instance - since consoles like + * {@code ProcessConsole} set their own font asynchronously right after being + * added, which would otherwise silently undo the zoom just applied to them. + * Any further mismatch afterwards is a genuine external change (e.g. a + * preference page), so it is adopted as the new base font instead (delta + * reset to 0). + * + * @param textConsole the console whose font changed + */ + private static void onFontChanged(TextConsole textConsole) { + Integer currentHeight = getFontHeight(textConsole); + if (currentHeight == null) { + return; + } + String type = typeKey(textConsole); + ZoomState state = sZoomByType.get(type); + if (state != null) { + if (state.height() == currentHeight.intValue()) { + return; + } + if (!Boolean.TRUE.equals(textConsole.getAttribute(REENFORCED_ATTRIBUTE))) { + textConsole.setAttribute(REENFORCED_ATTRIBUTE, Boolean.TRUE); + applyHeight(textConsole, state.height()); + return; + } + } + // genuine external change: our own custom zoom font, if any, is no longer + // the console's active font, so it must be disposed now instead of being + // leaked until the console (e.g. a long-lived ProcessConsole) is removed + disposeZoomFont(textConsole); + sZoomByType.put(type, new ZoomState(currentHeight.intValue(), 0)); + persistZoomStates(); + } + + /** + * Loads the zoom state persisted from a previous session, if any. + * + * @return the persisted per-type zoom state; empty if none or unparsable + */ + private static Map loadPersistedZoomStates() { + String persisted = getPreferences().get(PREF_ZOOM_FONT_HEIGHTS, ""); //$NON-NLS-1$ + Map result = new HashMap<>(); + if (persisted.isEmpty()) { + return result; + } + for (String entry : persisted.split(TYPE_ENTRY_SEPARATOR)) { + int eq = entry.indexOf(TYPE_VALUE_SEPARATOR); + int bar = entry.indexOf(BASE_DELTA_SEPARATOR); + if (eq <= 0 || bar < eq) { + continue; + } + try { + String type = entry.substring(0, eq); + int base = Integer.parseInt(entry.substring(eq + 1, bar)); + int delta = Integer.parseInt(entry.substring(bar + 1)); + int height = clamp(base + delta); + result.put(type, new ZoomState(base, height - base)); + } catch (NumberFormatException e) { + // ignore malformed entry + } + } + return result; + } + + /** + * Persists the current per-type zoom state. + */ + private static void persistZoomStates() { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : sZoomByType.entrySet()) { + if (sb.length() > 0) { + sb.append(TYPE_ENTRY_SEPARATOR); + } + ZoomState state = entry.getValue(); + sb.append(entry.getKey()).append(TYPE_VALUE_SEPARATOR).append(state.base()) + .append(BASE_DELTA_SEPARATOR).append(state.delta()); + } + IEclipsePreferences preferences = getPreferences(); + preferences.put(PREF_ZOOM_FONT_HEIGHTS, sb.toString()); + try { + preferences.flush(); + } catch (BackingStoreException e) { + ConsolePlugin.log(e); + } + } + + private static IEclipsePreferences getPreferences() { + return InstanceScope.INSTANCE.getNode(ConsolePlugin.getUniqueIdentifier()); + } + + private static int clamp(int height) { + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, height)); + } + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + IWorkbenchPart part = HandlerUtil.getActivePart(event); + applyZoom(part, STEP); + return Status.OK_STATUS; + } + + /** + * Applies a font zoom step to every registered console of the same type as + * the one in the given console view, and remembers the new zoom level for + * that type. Must run on the UI thread; re-dispatches itself otherwise. + * + * @param part the active part; must be an {@link IConsoleView} for anything to happen + * @param delta the font size delta to apply, in points + */ + public static void applyZoom(IWorkbenchPart part, int delta) { + if (Display.getCurrent() == null) { + Display.getDefault().asyncExec(() -> applyZoom(part, delta)); + return; + } + + if (!(part instanceof IConsoleView consoleView)) { + return; + } + IConsole console = consoleView.getConsole(); + if (!(console instanceof TextConsole textConsole)) { + return; + } + + String type = typeKey(textConsole); + ZoomState currentState = sZoomByType.get(type); + int base; + int currentDelta; + if (currentState != null) { + base = currentState.base(); + currentDelta = currentState.delta(); + } else { + Integer currentHeight = getFontHeight(textConsole); + if (currentHeight == null) { + return; + } + base = currentHeight.intValue(); + currentDelta = 0; + } + + int newHeight = clamp(base + currentDelta + delta); + int newDelta = newHeight - base; + if (currentState != null && newDelta == currentState.delta()) { + return; + } + sZoomByType.put(type, new ZoomState(base, newDelta)); + persistZoomStates(); + + IConsole[] consoles = ConsolePlugin.getDefault().getConsoleManager().getConsoles(); + for (IConsole registered : consoles) { + if (registered instanceof TextConsole registeredTextConsole && type.equals(typeKey(registeredTextConsole))) { + applyHeight(registeredTextConsole, newHeight); + } + } + } + + /** + * Returns the current font height (in points) of the given console, or + * null if it cannot be determined. Must run on the UI thread. + * + * @param textConsole the console + * @return the font height in points, or null + */ + private static Integer getFontHeight(TextConsole textConsole) { + Font font = textConsole.getFont(); + if (font == null || font.isDisposed()) { + return null; + } + FontData[] fontData = font.getFontData(); + if (fontData == null || fontData.length == 0) { + return null; + } + return Integer.valueOf(fontData[0].getHeight()); + } + + /** + * Applies the given font height to the console, preserving its font + * family/style, and disposes the previous zoom font. Must run on the UI + * thread; re-dispatches itself otherwise. + * + * @param textConsole the console to update + * @param height the font height to apply, in points + */ + private static void applyHeight(TextConsole textConsole, int height) { + if (Display.getCurrent() == null) { + Display.getDefault().asyncExec(() -> applyHeight(textConsole, height)); + return; + } + + // make sure this console's font is (still) being watched, in case it was + // registered before the zoom handler class got loaded, or the listener + // was otherwise not yet attached + textConsole.addPropertyChangeListener(FONT_ENFORCER); + + Font currentFont = textConsole.getFont(); + if (currentFont == null || currentFont.isDisposed()) { + return; + } + FontData[] fontData = currentFont.getFontData(); + if (fontData == null || fontData.length == 0 || fontData[0].getHeight() == height) { + return; + } + // getFontData() already returns a fresh array/copy that is safe to mutate; + // only the height is changed so that every other attribute (including any + // platform-specific data) is preserved as-is + for (FontData fd : fontData) { + fd.setHeight(height); + } + + Object oldAttribute = textConsole.getAttribute(ZOOM_FONT_ATTRIBUTE); + Font oldZoomFont = oldAttribute instanceof Font f ? f : null; + + Font newZoomFont = new Font(currentFont.getDevice(), fontData); + // remember/apply before disposing the old one, in case they are the same object + textConsole.setAttribute(ZOOM_FONT_ATTRIBUTE, newZoomFont); + textConsole.setFont(newZoomFont); + + if (oldZoomFont != null && !oldZoomFont.isDisposed()) { + disposeLater(oldZoomFont); + } + } + + /** + * Disposes the custom zoom font remembered on the given console, if any. + * Dispatches to the UI thread if necessary. + * + * @param textConsole the console whose zoom font should be disposed + */ + private static void disposeZoomFont(TextConsole textConsole) { + Object attribute = textConsole.getAttribute(ZOOM_FONT_ATTRIBUTE); + if (!(attribute instanceof Font font) || font.isDisposed()) { + return; + } + if (Display.getCurrent() == null) { + // see applyHeight(...) above for why asyncExec (not syncExec) is used + Display.getDefault().asyncExec(() -> disposeZoomFont(textConsole)); + return; + } + disposeLater(font); + textConsole.setAttribute(ZOOM_FONT_ATTRIBUTE, null); + } + + /** + * Disposes the given font on a later UI cycle rather than immediately. + *

+ * A font that was just replaced on a console (e.g. via + * {@link TextConsole#setFont(Font)}) may still be referenced for a little + * while by the viewer's internal rendering caches (e.g. + * {@code StyledText}/{@code TextLayout} keep per-line layouts that are only + * refreshed on their next repaint). Disposing it synchronously can therefore + * cause a later, asynchronously dispatched repaint to fail with an + * {@code IllegalArgumentException} ("Argument not valid") when it tries to + * use the now-disposed font. Deferring the actual disposal by one UI cycle + * gives any such pending repaint a chance to pick up the new font first. + *

+ * + * @param font the font to dispose; must be called on the UI thread + */ + private static void disposeLater(Font font) { + Display.getCurrent().asyncExec(() -> { + if (!font.isDisposed()) { + font.dispose(); + } + }); + } +} + diff --git a/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomOutHandler.java b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomOutHandler.java new file mode 100644 index 00000000000..aef9296a733 --- /dev/null +++ b/debug/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleZoomOutHandler.java @@ -0,0 +1,38 @@ +/******************************************************************************* + * Copyright (c) 2026 Advantest Europe 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: + * Raghunandana Murthappa + *******************************************************************************/ +package org.eclipse.ui.internal.console; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.core.runtime.Status; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.handlers.HandlerUtil; + +/** + * Command handler to decrease the font size of the console currently shown in + * the active console view. + * + * @see ConsoleZoomInHandler + */ +public class ConsoleZoomOutHandler extends AbstractHandler { + private static final int STEP = 1; + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + IWorkbenchPart part = HandlerUtil.getActivePart(event); + ConsoleZoomInHandler.applyZoom(part, -STEP); + return Status.OK_STATUS; + } +}