You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/10/20 08:18:52 UTC
(groovy) branch master updated: GROOVY-12436: GroovyConsole key bindings lost after runtime theme switch on JDK27
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new b9a167467f GROOVY-12436: GroovyConsole key bindings lost after runtime theme switch on JDK27
b9a167467f is described below
commit b9a167467f8a8d256b6dc6b52841827a377306b2
Author: Paul King <pa...@asert.com.au>
AuthorDate: Tue Oct 20 16:21:29 2026 +1000
GROOVY-12436: GroovyConsole key bindings lost after runtime theme switch on JDK27
---
.../main/groovy/groovy/console/ui/Console.groovy | 47 +++++++++-
.../console/ui/ThemeSwitchKeyBindingsTest.groovy | 101 +++++++++++++++++++++
2 files changed, 147 insertions(+), 1 deletion(-)
diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
index 5bd2705cee..367a379b52 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
@@ -31,6 +31,7 @@ import groovy.console.ui.text.SmartDocumentFilter
import groovy.swing.SwingBuilder
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
+import groovy.transform.PackageScope
import groovy.transform.ThreadInterrupt
import groovy.transform.TupleConstructor
import groovy.ui.GroovyMain
@@ -59,7 +60,10 @@ import org.codehaus.groovy.transform.ThreadInterruptibleASTTransformation
import org.codehaus.groovy.vmplugin.VMPluginFactory
import javax.swing.Action
+import javax.swing.ActionMap
import javax.swing.Icon
+import javax.swing.InputMap
+import javax.swing.JComponent
import javax.swing.JDialog
import javax.swing.JFileChooser
import javax.swing.JFrame
@@ -68,6 +72,7 @@ import javax.swing.JOptionPane
import javax.swing.JScrollPane
import javax.swing.JSplitPane
import javax.swing.JTextPane
+import javax.swing.KeyStroke
import javax.swing.RootPaneContainer
import javax.swing.SwingUtilities
import javax.swing.Timer
@@ -77,6 +82,7 @@ import javax.swing.event.DocumentListener
import javax.swing.event.HyperlinkEvent
import javax.swing.event.HyperlinkListener
import javax.swing.filechooser.FileFilter
+import javax.swing.plaf.UIResource
import javax.swing.text.AttributeSet
import javax.swing.text.DefaultStyledDocument
import javax.swing.text.Document
@@ -981,7 +987,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
}
// let FlatLaf update all Swing component UI delegates
- FlatLaf.updateUI()
+ updateUIPreservingKeyBindings(Window.windows as List<Container>) { FlatLaf.updateUI() }
// rebuild SVG icon rasters so accent-coloured and foreground-tracking icons pick up the new theme
Icons.refreshAll()
@@ -1443,6 +1449,45 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
viewer.background = dark ? new Color(43, 43, 43) : Color.WHITE
}
+ /**
+ * Runs a UI-delegate refresh, then restores the key bindings registered directly on the
+ * components under {@code roots}. From JDK 27, uninstalling a UI delegate clears the
+ * component's own ActionMap and InputMaps instead of only detaching the UI-installed parents,
+ * so a runtime theme switch would otherwise leave keys such as Enter and Backspace in the
+ * editor bound to actions that no longer exist.
+ */
+ @PackageScope
+ static void updateUIPreservingKeyBindings(List<Container> roots, Runnable updateUI) {
+ List<Runnable> restorers = []
+ roots.each { saveKeyBindings(it, restorers) }
+ updateUI.run()
+ restorers*.run()
+ }
+
+ private static final List<Integer> INPUT_MAP_CONDITIONS = [
+ JComponent.WHEN_FOCUSED, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, JComponent.WHEN_IN_FOCUSED_WINDOW]
+
+ private static void saveKeyBindings(Container container, List<Runnable> restorers) {
+ if (container instanceof JComponent) {
+ JComponent comp = (JComponent) container
+ ActionMap actionMap = comp.actionMap
+ if (!(actionMap instanceof UIResource) && actionMap.keys()) {
+ Map<Object, Action> actions = actionMap.keys().collectEntries { [it, actionMap.get(it)] }
+ restorers << { actions.each { k, v -> comp.actionMap.put(k, v) } } as Runnable
+ }
+ INPUT_MAP_CONDITIONS.each { int condition ->
+ InputMap inputMap = comp.getInputMap(condition)
+ if (!(inputMap instanceof UIResource) && inputMap.keys()) {
+ Map<KeyStroke, Object> bindings = inputMap.keys().collectEntries { [it, inputMap.get(it)] }
+ restorers << { bindings.each { k, v -> comp.getInputMap(condition).put(k, v) } } as Runnable
+ }
+ }
+ }
+ for (Component child : container.components) {
+ if (child instanceof Container) saveKeyBindings((Container) child, restorers)
+ }
+ }
+
private static TreeViewer findTreeViewer(Container container) {
for (Component comp : container.components) {
if (comp instanceof TreeViewer) return (TreeViewer) comp
diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ThemeSwitchKeyBindingsTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ThemeSwitchKeyBindingsTest.groovy
new file mode 100644
index 0000000000..3ed63a5e9e
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ThemeSwitchKeyBindingsTest.groovy
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.console.ui
+
+import org.junit.jupiter.api.Test
+
+import javax.swing.AbstractAction
+import javax.swing.JComponent
+import javax.swing.JRootPane
+import javax.swing.JTextPane
+import javax.swing.KeyStroke
+import javax.swing.SwingUtilities
+import java.awt.event.ActionEvent
+import java.awt.event.InputEvent
+import java.awt.event.KeyEvent
+
+import static groovy.util.HeadlessTestSupport.isHeadless
+import static org.junit.jupiter.api.Assumptions.assumeFalse
+
+/**
+ * A runtime theme switch refreshes every UI delegate. From JDK 27 that clears the
+ * component-owned ActionMap and InputMaps, so the console restores its own key bindings
+ * around the refresh.
+ */
+class ThemeSwitchKeyBindingsTest {
+
+ @Test
+ void testEditorKeysStillWorkAfterUIRefresh() {
+ // the editor's find/replace support creates a dialog
+ assumeFalse(isHeadless())
+ onEdt {
+ def editor = new ConsoleTextEditor()
+ JTextPane textEditor = editor.textEditor
+ Console.updateUIPreservingKeyBindings([editor]) { SwingUtilities.updateComponentTreeUI(editor) }
+
+ textEditor.text = 'abc'
+ textEditor.caretPosition = 3
+ press(textEditor, KeyEvent.VK_BACK_SPACE)
+ assert textEditor.text == 'ab'
+ press(textEditor, KeyEvent.VK_ENTER)
+ assert textEditor.text == 'ab\n'
+
+ def undo = KeyStroke.getKeyStroke(KeyEvent.VK_Z, InputEvent.CTRL_DOWN_MASK)
+ def undoName = textEditor.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(undo)
+ assert undoName != null
+ assert textEditor.actionMap.get(undoName).is(editor.undoAction)
+ }
+ }
+
+ @Test
+ void testRootPaneBindingSurvivesUIRefresh() {
+ onEdt {
+ def rootPane = new JRootPane()
+ def escape = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0)
+ def close = new AbstractAction() {
+ @Override
+ void actionPerformed(ActionEvent e) {}
+ }
+ rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(escape, 'exit')
+ rootPane.actionMap.put('exit', close)
+ Console.updateUIPreservingKeyBindings([rootPane]) { SwingUtilities.updateComponentTreeUI(rootPane) }
+
+ assert rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(escape) == 'exit'
+ assert rootPane.actionMap.get('exit').is(close)
+ }
+ }
+
+ private static void press(JComponent component, int keyCode) {
+ def event = new KeyEvent(component, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, KeyEvent.CHAR_UNDEFINED)
+ component.processKeyEvent(event)
+ assert event.consumed
+ }
+
+ private static void onEdt(Closure body) {
+ Throwable thrown = null
+ SwingUtilities.invokeAndWait {
+ try {
+ body()
+ } catch (Throwable t) {
+ thrown = t
+ }
+ }
+ if (thrown) throw thrown
+ }
+}