diff --git a/key.core/src/main/java/de/uka/ilkd/key/settings/ProofIndependentSettings.java b/key.core/src/main/java/de/uka/ilkd/key/settings/ProofIndependentSettings.java
index d79edd787af..09c467b859c 100644
--- a/key.core/src/main/java/de/uka/ilkd/key/settings/ProofIndependentSettings.java
+++ b/key.core/src/main/java/de/uka/ilkd/key/settings/ProofIndependentSettings.java
@@ -59,6 +59,7 @@ public class ProofIndependentSettings {
private final ViewSettings viewSettings = new ViewSettings();
private final TermLabelSettings termLabelSettings = new TermLabelSettings();
private final FeatureSettings featureSettings = new FeatureSettings();
+ private final StrategyPresetsSettings strategyPresetsSettings = new StrategyPresetsSettings();
private File filename;
@@ -75,6 +76,7 @@ private ProofIndependentSettings() {
addSettings(generalSettings);
addSettings(viewSettings);
addSettings(featureSettings);
+ addSettings(strategyPresetsSettings);
}
private ProofIndependentSettings(File filename) {
@@ -193,6 +195,10 @@ public FeatureSettings getFeatureSettings() {
return featureSettings;
}
+ public StrategyPresetsSettings getStrategyPresetsSettings() {
+ return strategyPresetsSettings;
+ }
+
/**
* Checks if pretty printing is enabled or not.
*
diff --git a/key.core/src/main/java/de/uka/ilkd/key/settings/StrategyPresetsSettings.java b/key.core/src/main/java/de/uka/ilkd/key/settings/StrategyPresetsSettings.java
new file mode 100644
index 00000000000..f1cc81f8048
--- /dev/null
+++ b/key.core/src/main/java/de/uka/ilkd/key/settings/StrategyPresetsSettings.java
@@ -0,0 +1,213 @@
+/* This file is part of KeY - https://key-project.org
+ * KeY is licensed under the GNU General Public License Version 2
+ * SPDX-License-Identifier: GPL-2.0-only */
+package de.uka.ilkd.key.settings;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+
+import de.uka.ilkd.key.strategy.StrategyProperties;
+
+import org.jspecify.annotations.NonNull;
+
+/**
+ * User-defined strategy presets shown in the strategy options combo box.
+ *
+ * In addition to the built-in presets (defined in code by the profile's
+ * {@link de.uka.ilkd.key.strategy.definition.StrategySettingsDefinition}), the user may store the
+ * currently selected strategy options under a name and re-select them later. Each preset captures a
+ * complete {@link StrategyProperties} value set together with the maximal number of rule
+ * applications.
+ *
+ * A single reserved preset named {@value #STASH_NAME} is used for the "stash current settings"
+ * quick action: stashing again simply overwrites it.
+ *
+ * Presets are persisted as part of the proof-independent settings. Persistence uses the
+ * {@link Configuration} (JSON-like) format only; the deprecated flat {@link Properties} format is
+ * not supported for presets (KeY writes the {@link Configuration} format on every save anyway).
+ */
+public class StrategyPresetsSettings extends AbstractSettings {
+ /** Category / top-level key under which the presets are stored. */
+ public static final String CATEGORY = "StrategyPresets";
+
+ /** Name of the reserved single-slot stash preset. */
+ public static final String STASH_NAME = "Stash";
+
+ /** Property name fired whenever the set of presets changes. */
+ public static final String PROP_PRESETS = "presets";
+
+ /** Configuration key for a preset's display name. */
+ private static final String KEY_NAME = "name";
+
+ /** Configuration key for a preset's maximal rule applications. */
+ private static final String KEY_MAX_STEPS = "maxSteps";
+
+ /** Fallback max. rule applications if a stored preset lacks the entry. */
+ private static final int DEFAULT_MAX_STEPS = 10000;
+
+ /** The stored presets, in insertion order. */
+ private final List presets = new ArrayList<>();
+
+ /**
+ * A single named strategy preset.
+ *
+ * @param name display name; unique among the stored presets
+ * @param maxSteps captured maximal number of rule applications
+ * @param properties captured (complete) strategy properties
+ */
+ public record StrategyPreset(String name, int maxSteps, StrategyProperties properties) {
+ public StrategyPreset {
+ properties = (StrategyProperties) properties.clone();
+ }
+
+ @Override
+ public StrategyProperties properties() {
+ return (StrategyProperties) properties.clone();
+ }
+ }
+
+ /**
+ * @return an unmodifiable snapshot of the stored presets in insertion order
+ */
+ public List getPresets() {
+ return List.copyOf(presets);
+ }
+
+ /**
+ * Looks up a preset by name.
+ *
+ * @param name the preset name
+ * @return the preset, if present
+ */
+ public Optional getPreset(String name) {
+ return presets.stream().filter(p -> p.name().equals(name)).findFirst();
+ }
+
+ /**
+ * @param name a preset name
+ * @return {@code true} if a preset with the given name is stored
+ */
+ public boolean contains(String name) {
+ return getPreset(name).isPresent();
+ }
+
+ /**
+ * Adds a new preset or replaces an existing one with the same name. The preset keeps its
+ * original position when replaced, otherwise it is appended.
+ *
+ * @param preset the preset to store
+ */
+ public void saveOrUpdate(StrategyPreset preset) {
+ for (int i = 0; i < presets.size(); i++) {
+ if (presets.get(i).name().equals(preset.name())) {
+ presets.set(i, preset);
+ firePropertyChange(PROP_PRESETS, null, preset.name());
+ return;
+ }
+ }
+ presets.add(preset);
+ firePropertyChange(PROP_PRESETS, null, preset.name());
+ }
+
+ /**
+ * Overwrites the reserved {@value #STASH_NAME} preset with the given settings.
+ *
+ * @param maxSteps the maximal number of rule applications to capture
+ * @param properties the strategy properties to capture
+ */
+ public void stash(int maxSteps, StrategyProperties properties) {
+ saveOrUpdate(new StrategyPreset(STASH_NAME, maxSteps, properties));
+ }
+
+ /**
+ * Removes the preset with the given name (if present).
+ *
+ * @param name the preset name
+ * @return {@code true} if a preset was removed
+ */
+ public boolean remove(String name) {
+ if (presets.removeIf(p -> p.name().equals(name))) {
+ firePropertyChange(PROP_PRESETS, name, null);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Renames a preset, keeping its position and captured settings.
+ *
+ * @param oldName the current name
+ * @param newName the new name; must not already be in use by a different preset
+ * @return {@code true} if the rename succeeded
+ */
+ public boolean rename(String oldName, String newName) {
+ if (oldName.equals(newName)) {
+ return true;
+ }
+ if (contains(newName)) {
+ return false;
+ }
+ for (int i = 0; i < presets.size(); i++) {
+ StrategyPreset p = presets.get(i);
+ if (p.name().equals(oldName)) {
+ presets.set(i, new StrategyPreset(newName, p.maxSteps(), p.properties()));
+ firePropertyChange(PROP_PRESETS, oldName, newName);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void readSettings(@NonNull Configuration props) {
+ presets.clear();
+ List stored = props.getList(CATEGORY, Configuration.class);
+ if (stored == null) {
+ return;
+ }
+ for (Configuration entry : stored) {
+ String name = entry.getString(KEY_NAME);
+ if (name == null || name.isEmpty()) {
+ continue;
+ }
+ int maxSteps = entry.getInt(KEY_MAX_STEPS, DEFAULT_MAX_STEPS);
+ StrategyProperties properties = StrategyProperties.read(entry);
+ presets.add(new StrategyPreset(name, maxSteps, properties));
+ }
+ }
+
+ @Override
+ public void writeSettings(@NonNull Configuration props) {
+ List stored = new ArrayList<>(presets.size());
+ for (StrategyPreset preset : presets) {
+ Configuration entry = new Configuration();
+ entry.set(KEY_NAME, preset.name());
+ entry.set(KEY_MAX_STEPS, preset.maxSteps());
+ preset.properties().write(entry);
+ stored.add(entry);
+ }
+ props.set(CATEGORY, stored);
+ }
+
+ /**
+ * Presets are only persisted via the {@link Configuration} format; the deprecated flat
+ * {@link Properties} format cannot represent a list of presets and is intentionally not
+ * supported here.
+ */
+ @Override
+ public void readSettings(Properties props) {
+ // intentionally empty: see class documentation
+ }
+
+ /**
+ * Presets are only persisted via the {@link Configuration} format; the deprecated flat
+ * {@link Properties} format cannot represent a list of presets and is intentionally not
+ * supported here.
+ */
+ @Override
+ public void writeSettings(Properties props) {
+ // intentionally empty: see class documentation
+ }
+}
diff --git a/key.core/src/test/java/de/uka/ilkd/key/settings/StrategyPresetsSettingsTest.java b/key.core/src/test/java/de/uka/ilkd/key/settings/StrategyPresetsSettingsTest.java
new file mode 100644
index 00000000000..ad4477ceca6
--- /dev/null
+++ b/key.core/src/test/java/de/uka/ilkd/key/settings/StrategyPresetsSettingsTest.java
@@ -0,0 +1,134 @@
+/* This file is part of KeY - https://key-project.org
+ * KeY is licensed under the GNU General Public License Version 2
+ * SPDX-License-Identifier: GPL-2.0-only */
+package de.uka.ilkd.key.settings;
+
+import java.io.IOException;
+import java.io.StringWriter;
+
+import de.uka.ilkd.key.settings.StrategyPresetsSettings.StrategyPreset;
+import de.uka.ilkd.key.strategy.StrategyProperties;
+
+import org.antlr.v4.runtime.CharStreams;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for {@link StrategyPresetsSettings}: the in-memory bookkeeping (add / overwrite / stash /
+ * rename / remove) and the {@link Configuration} persistence round-trip.
+ */
+class StrategyPresetsSettingsTest {
+
+ private static StrategyProperties props(String splitting, String loop) {
+ StrategyProperties p = new StrategyProperties();
+ p.setProperty(StrategyProperties.SPLITTING_OPTIONS_KEY, splitting);
+ p.setProperty(StrategyProperties.LOOP_OPTIONS_KEY, loop);
+ return p;
+ }
+
+ @Test
+ void saveKeepsInsertionOrderAndOverwritesByName() {
+ StrategyPresetsSettings s = new StrategyPresetsSettings();
+ s.saveOrUpdate(new StrategyPreset("arith", 4000,
+ props(StrategyProperties.SPLITTING_OFF, StrategyProperties.LOOP_NONE)));
+ s.saveOrUpdate(new StrategyPreset("loops", 8000,
+ props(StrategyProperties.SPLITTING_NORMAL, StrategyProperties.LOOP_INVARIANT)));
+
+ assertEquals(2, s.getPresets().size());
+ assertEquals("arith", s.getPresets().get(0).name());
+ assertEquals("loops", s.getPresets().get(1).name());
+
+ // overwriting an existing name keeps its position but replaces the payload
+ s.saveOrUpdate(new StrategyPreset("arith", 1234,
+ props(StrategyProperties.SPLITTING_DELAYED, StrategyProperties.LOOP_EXPAND)));
+ assertEquals(2, s.getPresets().size());
+ assertEquals("arith", s.getPresets().get(0).name());
+ assertEquals(1234, s.getPreset("arith").orElseThrow().maxSteps());
+ }
+
+ @Test
+ void stashUsesReservedSingleSlot() {
+ StrategyPresetsSettings s = new StrategyPresetsSettings();
+ s.stash(5000, props(StrategyProperties.SPLITTING_OFF, StrategyProperties.LOOP_NONE));
+ s.stash(6000, props(StrategyProperties.SPLITTING_NORMAL, StrategyProperties.LOOP_EXPAND));
+
+ assertEquals(1, s.getPresets().size(), "stashing twice must not create a second slot");
+ StrategyPreset stash = s.getPreset(StrategyPresetsSettings.STASH_NAME).orElseThrow();
+ assertEquals(6000, stash.maxSteps());
+ assertEquals(StrategyProperties.SPLITTING_NORMAL,
+ stash.properties().getProperty(StrategyProperties.SPLITTING_OPTIONS_KEY));
+ }
+
+ @Test
+ void renameRejectsCollisionAndUpdatesOtherwise() {
+ StrategyPresetsSettings s = new StrategyPresetsSettings();
+ s.saveOrUpdate(new StrategyPreset("a", 1, props(StrategyProperties.SPLITTING_OFF,
+ StrategyProperties.LOOP_NONE)));
+ s.saveOrUpdate(new StrategyPreset("b", 2, props(StrategyProperties.SPLITTING_OFF,
+ StrategyProperties.LOOP_NONE)));
+
+ assertFalse(s.rename("a", "b"), "must not rename onto an existing name");
+ assertTrue(s.contains("a"));
+
+ assertTrue(s.rename("a", "c"));
+ assertFalse(s.contains("a"));
+ assertTrue(s.contains("c"));
+ assertEquals(1, s.getPreset("c").orElseThrow().maxSteps());
+ }
+
+ @Test
+ void removeDropsPreset() {
+ StrategyPresetsSettings s = new StrategyPresetsSettings();
+ s.saveOrUpdate(new StrategyPreset("a", 1, props(StrategyProperties.SPLITTING_OFF,
+ StrategyProperties.LOOP_NONE)));
+ assertTrue(s.remove("a"));
+ assertFalse(s.remove("a"));
+ assertTrue(s.getPresets().isEmpty());
+ }
+
+ @Test
+ void configurationRoundTripPreservesEverything() throws IOException {
+ StrategyPresetsSettings original = new StrategyPresetsSettings();
+ original.saveOrUpdate(new StrategyPreset("arith", 4000,
+ props(StrategyProperties.SPLITTING_OFF, StrategyProperties.LOOP_NONE)));
+ original.saveOrUpdate(new StrategyPreset("loops", 8000,
+ props(StrategyProperties.SPLITTING_NORMAL, StrategyProperties.LOOP_INVARIANT)));
+ original.stash(5000, props(StrategyProperties.SPLITTING_DELAYED,
+ StrategyProperties.LOOP_EXPAND));
+
+ // write -> serialize to text -> reparse -> read (mirrors the actual settings file path)
+ Configuration written = new Configuration();
+ original.writeSettings(written);
+ StringWriter out = new StringWriter();
+ written.save(out, "");
+ Configuration reparsed = Configuration.load(CharStreams.fromString(out.toString()));
+
+ StrategyPresetsSettings restored = new StrategyPresetsSettings();
+ restored.readSettings(reparsed);
+
+ assertEquals(3, restored.getPresets().size());
+ assertEquals("arith", restored.getPresets().get(0).name());
+ assertEquals("loops", restored.getPresets().get(1).name());
+ assertEquals(StrategyPresetsSettings.STASH_NAME, restored.getPresets().get(2).name());
+
+ StrategyPreset arith = restored.getPreset("arith").orElseThrow();
+ assertEquals(4000, arith.maxSteps());
+ assertEquals(StrategyProperties.SPLITTING_OFF,
+ arith.properties().getProperty(StrategyProperties.SPLITTING_OPTIONS_KEY));
+ assertEquals(StrategyProperties.LOOP_NONE,
+ arith.properties().getProperty(StrategyProperties.LOOP_OPTIONS_KEY));
+
+ StrategyPreset loops = restored.getPreset("loops").orElseThrow();
+ assertEquals(8000, loops.maxSteps());
+ assertEquals(StrategyProperties.LOOP_INVARIANT,
+ loops.properties().getProperty(StrategyProperties.LOOP_OPTIONS_KEY));
+ }
+
+ @Test
+ void readingEmptyConfigurationYieldsNoPresets() {
+ StrategyPresetsSettings s = new StrategyPresetsSettings();
+ s.readSettings(new Configuration());
+ assertTrue(s.getPresets().isEmpty());
+ }
+}
diff --git a/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java b/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java
index 442e098cc4d..652fb5823fa 100644
--- a/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java
+++ b/key.ui/src/main/java/de/uka/ilkd/key/gui/StrategySelectionView.java
@@ -4,10 +4,14 @@
package de.uka.ilkd.key.gui;
import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
import java.util.*;
import java.util.List;
import java.util.Map.Entry;
+import java.util.function.Supplier;
import javax.swing.*;
+import javax.swing.plaf.basic.ComboPopup;
import de.uka.ilkd.key.core.KeYMediator;
import de.uka.ilkd.key.core.KeYSelectionEvent;
@@ -20,7 +24,10 @@
import de.uka.ilkd.key.proof.Proof;
import de.uka.ilkd.key.proof.init.JavaProfile;
import de.uka.ilkd.key.proof.init.Profile;
+import de.uka.ilkd.key.settings.ProofIndependentSettings;
import de.uka.ilkd.key.settings.ProofSettings;
+import de.uka.ilkd.key.settings.StrategyPresetsSettings;
+import de.uka.ilkd.key.settings.StrategyPresetsSettings.StrategyPreset;
import de.uka.ilkd.key.strategy.JavaCardDLStrategy;
import de.uka.ilkd.key.strategy.Strategy;
import de.uka.ilkd.key.strategy.StrategyFactory;
@@ -92,6 +99,48 @@ public final class StrategySelectionView extends JPanel implements TabPanel {
*/
private boolean predefChanged = true;
+ /**
+ * Set while the preset combo box is being rebuilt or reselected programmatically. Guards the
+ * combo box listeners so that they do not (re-)apply a preset in reaction to our own changes.
+ */
+ private boolean adjustingPresetCombo = false;
+
+ /**
+ * One-shot guard spanning a delete-via-"x" interaction. The popup commits its selection on
+ * mouse release after our press handler has already deleted the preset; this flag
+ * keeps
+ * that trailing commit from applying a preset. Reset on the next idle EDT cycle.
+ */
+ private boolean suppressPresetApply = false;
+
+ /**
+ * The combo box popup list, cached once the delete handler is installed, so the delete action
+ * can keep the popup selection aligned with the combo box.
+ */
+ private JList> presetPopupList;
+
+ /**
+ * Width (in pixels) of the clickable delete zone drawn at the right edge of user-defined preset
+ * rows in the combo box popup.
+ */
+ private static final int PRESET_DELETE_ZONE_WIDTH = 26;
+
+ /**
+ * Colour of the "x" delete affordance while its zone is hovered.
+ */
+ private static final Color PRESET_DELETE_ACTIVE_COLOR = new Color(0xCC, 0x33, 0x33);
+
+ /**
+ * Whether the mouse handler for the preset combo box popup has already been installed. The
+ * popup list only exists once the popup has been shown for the first time.
+ */
+ private boolean presetDeleteHandlerInstalled = false;
+
+ /**
+ * Index of the combo box popup row whose delete zone is currently hovered, or {@code -1}.
+ */
+ private int presetDeleteRolloverIndex = -1;
+
/**
* Observe changes on {@link #mediator}.
*/
@@ -398,57 +447,377 @@ private void fixVerticalSpace(JComponent comp) {
private JPanel createDefaultPanel(StrategySelectionComponents components) {
final JPanel panel = new JPanel();
- final JButton defaultButton = new JButton("Choose Predef");
+ final JButton defaultButton = new JButton("Apply");
+ // Keep the button compact: trim the internal padding a little.
+ defaultButton.setMargin(new Insets(2, 8, 2, 8));
components.setDefaultButton(defaultButton);
- final String[] existingPredefs = new String[1 + DEFINITION.getFurtherDefaults().size()];
-
- existingPredefs[0] = "Defaults";
-
- int i = 1;
- for (StrategySettingsDefinition.StrategySettingEntry furtherDefault : DEFINITION
- .getFurtherDefaults()) {
- existingPredefs[i] = furtherDefault.name();
- i++;
- }
-
- final JComboBox strategyPredefSettingsCmb = new JComboBox<>(existingPredefs);
- strategyPredefSettingsCmb.setSelectedIndex(0);
+ final JComboBox strategyPredefSettingsCmb = new JComboBox<>();
+ strategyPredefSettingsCmb.setRenderer(new PresetListCellRenderer());
components.setPredefsChoiceCmb(strategyPredefSettingsCmb);
- defaultButton.addActionListener(e -> {
- int newMaxSteps = 0;
- StrategyProperties newProps = null;
+ // "Choose Predef" applies the currently selected preset.
+ defaultButton.addActionListener(e -> applySelectedPreset());
- final int selIndex = strategyPredefSettingsCmb.getSelectedIndex();
- if (selIndex == 0) {
- newMaxSteps = DEFINITION.getDefaultMaxRuleApplications();
- newProps =
- DEFINITION.getDefaultPropertiesFactory().createDefaultStrategyProperties();
- } else {
- var chosenDefault = DEFINITION.getFurtherDefaults().get(selIndex - 1);
- newMaxSteps = chosenDefault.order();
- newProps = chosenDefault.factory().createDefaultStrategyProperties();
+ // Selecting an entry in the combo box applies it immediately, unless we are rebuilding the
+ // model ourselves (adding, deleting or renaming a preset must not change the live
+ // settings).
+ strategyPredefSettingsCmb.addActionListener(e -> {
+ if (!adjustingPresetCombo && !suppressPresetApply) {
+ applySelectedPreset();
}
+ });
- mediator.getSelectedProof().getSettings().getStrategySettings()
- .setMaxSteps(newMaxSteps);
- updateStrategySettings(JAVACARDDL_STRATEGY_NAME, newProps);
+ // Clicking the small "x" on a user-defined row inside the popup deletes that preset.
+ installPresetDeleteHandler(strategyPredefSettingsCmb);
- predefChanged = false;
- refresh(mediator.getSelectedProof());
+ // Adjacent menu button offering save / stash / rename.
+ final JButton presetMenuButton = createPresetMenuButton();
+ components.setPresetMenuButton(presetMenuButton);
- });
+ rebuildPresetCombo(null);
- strategyPredefSettingsCmb
- .addItemListener(e -> defaultButton.getActionListeners()[0].actionPerformed(null));
+ // Lay the row out as [gear] [combo] [Apply] and give the two buttons the same height as
+ // the combo box so the row is flush instead of vertically staggered.
+ int rowHeight = strategyPredefSettingsCmb.getPreferredSize().height;
+ setPreferredHeight(presetMenuButton, rowHeight);
+ setPreferredHeight(defaultButton, rowHeight);
+ panel.add(presetMenuButton);
panel.add(strategyPredefSettingsCmb);
panel.add(defaultButton);
return panel;
}
+ /**
+ * Fixes the preferred height of the given component while keeping its preferred width, so it
+ * can
+ * be aligned in height with a neighbouring component.
+ */
+ private static void setPreferredHeight(JComponent comp, int height) {
+ Dimension pref = comp.getPreferredSize();
+ comp.setPreferredSize(new Dimension(pref.width, height));
+ }
+
+ /**
+ * @return the proof-independent settings storing the user-defined strategy presets
+ */
+ private static StrategyPresetsSettings getPresetsSettings() {
+ return ProofIndependentSettings.DEFAULT_INSTANCE.getStrategyPresetsSettings();
+ }
+
+ /**
+ * Builds the list of combo box entries: the built-in "Defaults", the further built-in presets
+ * defined by the profile, followed by the user-defined presets.
+ */
+ private List buildPresetEntries() {
+ List entries = new ArrayList<>();
+ entries.add(PresetEntry.defaults());
+ for (StrategySettingsDefinition.StrategySettingEntry e : DEFINITION.getFurtherDefaults()) {
+ entries.add(PresetEntry.builtIn(e));
+ }
+ for (StrategyPreset p : getPresetsSettings().getPresets()) {
+ entries.add(PresetEntry.user(p));
+ }
+ return entries;
+ }
+
+ /**
+ * Rebuilds the preset combo box model from the built-in and stored presets without touching the
+ * live strategy settings.
+ *
+ * @param selectName the name of the preset to select afterwards, or {@code null} to select the
+ * first entry ("Defaults")
+ */
+ private void rebuildPresetCombo(String selectName) {
+ JComboBox combo = components.getStrategyPredefSettingsCmb();
+ if (combo == null) {
+ return;
+ }
+ List entries = buildPresetEntries();
+ adjustingPresetCombo = true;
+ try {
+ combo.setModel(new DefaultComboBoxModel<>(entries.toArray(new PresetEntry[0])));
+ PresetEntry toSelect = null;
+ if (selectName != null) {
+ for (PresetEntry e : entries) {
+ if (e.name().equals(selectName)) {
+ toSelect = e;
+ break;
+ }
+ }
+ }
+ if (toSelect == null && !entries.isEmpty()) {
+ toSelect = entries.get(0);
+ }
+ combo.setSelectedItem(toSelect);
+ } finally {
+ adjustingPresetCombo = false;
+ }
+ }
+
+ /**
+ * Applies the preset currently selected in the combo box to the active proof.
+ */
+ private void applySelectedPreset() {
+ if (mediator == null || mediator.getSelectedProof() == null) {
+ return;
+ }
+ PresetEntry entry =
+ (PresetEntry) components.getStrategyPredefSettingsCmb().getSelectedItem();
+ if (entry == null) {
+ return;
+ }
+ mediator.getSelectedProof().getSettings().getStrategySettings()
+ .setMaxSteps(entry.maxSteps());
+ predefChanged = false;
+ updateStrategySettings(JAVACARDDL_STRATEGY_NAME, entry.properties());
+ }
+
+ /**
+ * Captures the strategy properties currently reflected by the UI. Starts from the complete
+ * active properties of the proof and overlays the values shown by the option controls, so that
+ * unsaved manual edits are included while properties without a control keep their value.
+ */
+ private StrategyProperties currentStrategyProperties() {
+ StrategyProperties base = mediator.getSelectedProof().getSettings().getStrategySettings()
+ .getActiveStrategyProperties();
+ StrategyProperties displayed = getProperties();
+ for (String key : displayed.stringPropertyNames()) {
+ base.setProperty(key, displayed.getProperty(key));
+ }
+ return base;
+ }
+
+ /**
+ * @return the maximal number of rule applications currently configured for the active proof
+ */
+ private int currentMaxSteps() {
+ return mediator.getSelectedProof().getSettings().getStrategySettings().getMaxSteps();
+ }
+
+ /**
+ * @param name a candidate preset name
+ * @return {@code true} if the name is used by a built-in preset (and thus reserved)
+ */
+ private boolean isBuiltInName(String name) {
+ if ("Defaults".equals(name)) {
+ return true;
+ }
+ for (StrategySettingsDefinition.StrategySettingEntry e : DEFINITION.getFurtherDefaults()) {
+ if (e.name().equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private JButton createPresetMenuButton() {
+ final JButton button = new JButton("⚙");
+ // The gear is a Unicode glyph; enlarge it relative to the default font so it reads well.
+ button.setFont(button.getFont().deriveFont(button.getFont().getSize2D() + 6f));
+ button.setMargin(new Insets(2, 8, 2, 8));
+ button.setToolTipText("Save, stash or rename presets");
+ button.addActionListener(e -> showPresetMenu(button));
+ return button;
+ }
+
+ private void showPresetMenu(JComponent invoker) {
+ final boolean hasProof = mediator != null && mediator.getSelectedProof() != null;
+ final PresetEntry selected =
+ (PresetEntry) components.getStrategyPredefSettingsCmb().getSelectedItem();
+
+ JPopupMenu menu = new JPopupMenu();
+
+ JMenuItem saveAs = new JMenuItem("Save current as…");
+ saveAs.setEnabled(hasProof);
+ saveAs.addActionListener(e -> saveCurrentAs());
+ menu.add(saveAs);
+
+ JMenuItem stash = new JMenuItem("Stash current");
+ stash.setToolTipText("Save the current settings to the reusable \"Stash\" preset");
+ stash.setEnabled(hasProof);
+ stash.addActionListener(e -> stashCurrent());
+ menu.add(stash);
+
+ menu.addSeparator();
+
+ JMenuItem rename = new JMenuItem("Rename selected…");
+ rename.setEnabled(selected != null && selected.userDefined());
+ rename.addActionListener(e -> renameSelected());
+ menu.add(rename);
+
+ menu.show(invoker, 0, invoker.getHeight());
+ }
+
+ private void saveCurrentAs() {
+ if (mediator == null || mediator.getSelectedProof() == null) {
+ return;
+ }
+ Object input = JOptionPane.showInputDialog(this, "Preset name:", "Save strategy preset",
+ JOptionPane.PLAIN_MESSAGE, null, null, "");
+ if (input == null) {
+ return;
+ }
+ String name = input.toString().trim();
+ if (name.isEmpty()) {
+ return;
+ }
+ if (isBuiltInName(name)) {
+ JOptionPane.showMessageDialog(this,
+ "\"" + name + "\" is the name of a built-in preset. Please choose another name.",
+ "Save strategy preset", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+ StrategyPresetsSettings settings = getPresetsSettings();
+ if (settings.contains(name)) {
+ int choice = JOptionPane.showConfirmDialog(this,
+ "A preset named \"" + name + "\" already exists. Overwrite it?",
+ "Overwrite strategy preset", JOptionPane.YES_NO_OPTION);
+ if (choice != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+ settings.saveOrUpdate(
+ new StrategyPreset(name, currentMaxSteps(), currentStrategyProperties()));
+ rebuildPresetCombo(name);
+ }
+
+ private void stashCurrent() {
+ if (mediator == null || mediator.getSelectedProof() == null) {
+ return;
+ }
+ getPresetsSettings().stash(currentMaxSteps(), currentStrategyProperties());
+ rebuildPresetCombo(StrategyPresetsSettings.STASH_NAME);
+ }
+
+ private void renameSelected() {
+ PresetEntry selected =
+ (PresetEntry) components.getStrategyPredefSettingsCmb().getSelectedItem();
+ if (selected == null || !selected.userDefined()) {
+ return;
+ }
+ Object input = JOptionPane.showInputDialog(this, "New name:", "Rename strategy preset",
+ JOptionPane.PLAIN_MESSAGE, null, null, selected.name());
+ if (input == null) {
+ return;
+ }
+ String newName = input.toString().trim();
+ if (newName.isEmpty() || newName.equals(selected.name())) {
+ return;
+ }
+ if (isBuiltInName(newName)) {
+ JOptionPane.showMessageDialog(this,
+ "\"" + newName + "\" is the name of a built-in preset. Please choose another name.",
+ "Rename strategy preset", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+ if (getPresetsSettings().contains(newName)) {
+ JOptionPane.showMessageDialog(this,
+ "A preset named \"" + newName + "\" already exists.",
+ "Rename strategy preset", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+ getPresetsSettings().rename(selected.name(), newName);
+ rebuildPresetCombo(newName);
+ }
+
+ private void deleteUserPreset(PresetEntry entry) {
+ if (entry == null || !entry.userDefined()) {
+ return;
+ }
+ JComboBox combo = components.getStrategyPredefSettingsCmb();
+ PresetEntry selected = (PresetEntry) combo.getSelectedItem();
+ String keepSelected =
+ (selected != null && !selected.name().equals(entry.name())) ? selected.name() : null;
+ getPresetsSettings().remove(entry.name());
+ rebuildPresetCombo(keepSelected);
+ // Keep the popup list's selection aligned with the combo box so that a trailing
+ // mouse-release commit (if any) re-selects the same entry instead of an arbitrary row.
+ if (presetPopupList != null) {
+ presetPopupList.setSelectedIndex(combo.getSelectedIndex());
+ }
+ }
+
+ /**
+ * Installs, lazily on first popup opening, a mouse handler on the combo box popup list that
+ * deletes a user-defined preset when its trailing "x" is clicked and highlights the delete zone
+ * on hover.
+ */
+ private void installPresetDeleteHandler(JComboBox combo) {
+ combo.addPopupMenuListener(new javax.swing.event.PopupMenuListener() {
+ @Override
+ public void popupMenuWillBecomeVisible(javax.swing.event.PopupMenuEvent e) {
+ if (presetDeleteHandlerInstalled) {
+ return;
+ }
+ var child = combo.getUI().getAccessibleChild(combo, 0);
+ if (child instanceof ComboPopup popup) {
+ attachPresetDeleteMouseListener(combo, popup.getList());
+ presetDeleteHandlerInstalled = true;
+ } else {
+ LOGGER.warn("Could not install preset delete handler: unexpected popup {}",
+ child);
+ }
+ }
+
+ @Override
+ public void popupMenuWillBecomeInvisible(javax.swing.event.PopupMenuEvent e) {
+ presetDeleteRolloverIndex = -1;
+ }
+
+ @Override
+ public void popupMenuCanceled(javax.swing.event.PopupMenuEvent e) {
+ presetDeleteRolloverIndex = -1;
+ }
+ });
+ }
+
+ private void attachPresetDeleteMouseListener(JComboBox combo, JList> list) {
+ presetPopupList = list;
+ list.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mousePressed(MouseEvent e) {
+ int index = list.locationToIndex(e.getPoint());
+ if (index < 0) {
+ return;
+ }
+ Object value = list.getModel().getElementAt(index);
+ Rectangle bounds = list.getCellBounds(index, index);
+ if (value instanceof PresetEntry entry && entry.userDefined() && bounds != null
+ && e.getX() >= bounds.x + bounds.width - PRESET_DELETE_ZONE_WIDTH) {
+ e.consume();
+ presetDeleteRolloverIndex = -1;
+ // Guard against the popup's trailing mouse-release committing a selection.
+ suppressPresetApply = true;
+ combo.setPopupVisible(false);
+ deleteUserPreset(entry);
+ SwingUtilities.invokeLater(() -> suppressPresetApply = false);
+ }
+ }
+ });
+ list.addMouseMotionListener(new MouseAdapter() {
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ int index = list.locationToIndex(e.getPoint());
+ int rollover = -1;
+ if (index >= 0) {
+ Object value = list.getModel().getElementAt(index);
+ Rectangle bounds = list.getCellBounds(index, index);
+ if (value instanceof PresetEntry entry && entry.userDefined() && bounds != null
+ && e.getX() >= bounds.x + bounds.width - PRESET_DELETE_ZONE_WIDTH) {
+ rollover = index;
+ }
+ }
+ if (rollover != presetDeleteRolloverIndex) {
+ presetDeleteRolloverIndex = rollover;
+ list.repaint();
+ }
+ }
+ });
+ }
+
public void setMediator(KeYMediator mediator) {
if (this.mediator != null) {
this.mediator.removeKeYSelectionListener(mediatorListener);
@@ -493,6 +862,9 @@ private void refreshDefaultButton() {
} else {
components.getDefaultButton().setEnabled(false);
components.getStrategyPredefSettingsCmb().setEnabled(false);
+ if (components.getPresetMenuButton() != null) {
+ components.getPresetMenuButton().setEnabled(false);
+ }
}
}
@@ -507,6 +879,9 @@ private void enableAll(boolean enable) {
}
components.getDefaultButton().setEnabled(enable);
components.getStrategyPredefSettingsCmb().setEnabled(enable);
+ if (components.getPresetMenuButton() != null) {
+ components.getPresetMenuButton().setEnabled(enable);
+ }
for (Entry> entry : components.getPropertyButtons().entrySet()) {
for (JRadioButton button : entry.getValue()) {
button.setEnabled(enable);
@@ -581,6 +956,125 @@ public Icon getIcon() {
return IconFactory.PROOF_SEARCH_STRATEGY.get(MainWindow.TAB_ICON_SIZE);
}
+ /**
+ * A single entry of the preset combo box: either the built-in "Defaults", one of the profile's
+ * further built-in presets, or a user-defined preset. User-defined entries can be deleted and
+ * renamed; built-in entries cannot.
+ */
+ private static final class PresetEntry {
+ private final String name;
+ private final boolean userDefined;
+ private final int maxSteps;
+ private final Supplier properties;
+
+ private PresetEntry(String name, boolean userDefined, int maxSteps,
+ Supplier properties) {
+ this.name = name;
+ this.userDefined = userDefined;
+ this.maxSteps = maxSteps;
+ this.properties = properties;
+ }
+
+ static PresetEntry defaults() {
+ return new PresetEntry("Defaults", false, DEFINITION.getDefaultMaxRuleApplications(),
+ () -> DEFINITION.getDefaultPropertiesFactory().createDefaultStrategyProperties());
+ }
+
+ static PresetEntry builtIn(StrategySettingsDefinition.StrategySettingEntry e) {
+ return new PresetEntry(e.name(), false, e.order(),
+ () -> e.factory().createDefaultStrategyProperties());
+ }
+
+ static PresetEntry user(StrategyPreset preset) {
+ return new PresetEntry(preset.name(), true, preset.maxSteps(), preset::properties);
+ }
+
+ String name() {
+ return name;
+ }
+
+ boolean userDefined() {
+ return userDefined;
+ }
+
+ int maxSteps() {
+ return maxSteps;
+ }
+
+ StrategyProperties properties() {
+ return properties.get();
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ /**
+ * Renders the preset combo box rows. User-defined rows show a trailing "x" delete affordance,
+ * and a separator line marks the transition from the built-in presets to the user-defined ones.
+ */
+ private final class PresetListCellRenderer implements ListCellRenderer {
+ private final JPanel panel = new JPanel(new BorderLayout());
+ private final JLabel nameLabel = new JLabel();
+ private final JLabel deleteLabel = new JLabel("✕", SwingConstants.CENTER);
+
+ PresetListCellRenderer() {
+ panel.setOpaque(true);
+ nameLabel.setOpaque(false);
+ nameLabel.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 4));
+ deleteLabel.setOpaque(false);
+ panel.add(nameLabel, BorderLayout.CENTER);
+ panel.add(deleteLabel, BorderLayout.EAST);
+ }
+
+ @Override
+ public Component getListCellRendererComponent(JList extends PresetEntry> list,
+ PresetEntry value, int index, boolean isSelected, boolean cellHasFocus) {
+ Color bg = isSelected ? list.getSelectionBackground() : list.getBackground();
+ Color fg = isSelected ? list.getSelectionForeground() : list.getForeground();
+ panel.setBackground(bg);
+ nameLabel.setFont(list.getFont());
+ nameLabel.setForeground(fg);
+ nameLabel.setText(value == null ? "" : value.name());
+
+ boolean showDelete = value != null && value.userDefined() && index >= 0;
+ deleteLabel.setPreferredSize(
+ new Dimension(showDelete ? PRESET_DELETE_ZONE_WIDTH : 0, 0));
+ if (showDelete) {
+ deleteLabel.setText("✕");
+ deleteLabel.setFont(list.getFont());
+ boolean rollover = index == presetDeleteRolloverIndex;
+ deleteLabel.setForeground(
+ rollover ? PRESET_DELETE_ACTIVE_COLOR : blend(fg, bg, 0.45f));
+ } else {
+ deleteLabel.setText("");
+ }
+
+ boolean separator = false;
+ if (index > 0 && value != null && value.userDefined()) {
+ Object prev = list.getModel().getElementAt(index - 1);
+ separator = (prev instanceof PresetEntry pe) && !pe.userDefined();
+ }
+ Color sepColor = UIManager.getColor("Separator.foreground");
+ if (sepColor == null) {
+ sepColor = blend(fg, bg, 0.6f);
+ }
+ panel.setBorder(
+ separator ? BorderFactory.createMatteBorder(1, 0, 0, 0, sepColor) : null);
+ return panel;
+ }
+ }
+
+ private static Color blend(Color a, Color b, float ratioB) {
+ float ratioA = 1f - ratioB;
+ return new Color(
+ Math.round(a.getRed() * ratioA + b.getRed() * ratioB),
+ Math.round(a.getGreen() * ratioA + b.getGreen() * ratioB),
+ Math.round(a.getBlue() * ratioA + b.getBlue() * ratioB));
+ }
+
/**
* Provided via {@link StrategySelectionView} for direct access
* to created user interface components.
@@ -609,7 +1103,12 @@ private static class StrategySelectionComponents {
/**
* The {@link JComboBox} for choosing a predefined value set.
*/
- private JComboBox strategyPredefSettingsCmb;
+ private JComboBox strategyPredefSettingsCmb;
+
+ /**
+ * The menu button offering save / stash / rename actions for presets.
+ */
+ private JButton presetMenuButton;
/**
* Returns the {@link MaxRuleAppSlider} in which the maximal number of steps is edited.
@@ -674,7 +1173,7 @@ public void setDefaultButton(JButton defaultButton) {
/**
* @return The {@link JComboBox} for choosing a predefined value set.
*/
- public JComboBox getStrategyPredefSettingsCmb() {
+ public JComboBox getStrategyPredefSettingsCmb() {
return strategyPredefSettingsCmb;
}
@@ -684,10 +1183,26 @@ public JComboBox getStrategyPredefSettingsCmb() {
* @param strategyPredefSettingsCmb The {@link JComboBox} for choosing a predefined value
* set.
*/
- public void setPredefsChoiceCmb(JComboBox strategyPredefSettingsCmb) {
+ public void setPredefsChoiceCmb(JComboBox strategyPredefSettingsCmb) {
this.strategyPredefSettingsCmb = strategyPredefSettingsCmb;
}
+ /**
+ * @return the menu button offering save / stash / rename actions
+ */
+ public JButton getPresetMenuButton() {
+ return presetMenuButton;
+ }
+
+ /**
+ * Sets the menu button offering save / stash / rename actions.
+ *
+ * @param presetMenuButton the menu button
+ */
+ public void setPresetMenuButton(JButton presetMenuButton) {
+ this.presetMenuButton = presetMenuButton;
+ }
+
/**
* Returns the {@link Map} of properties to {@link ButtonGroup}s.
*