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
+    }
+}