diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java b/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java index 2fbd2288..0c8b8727 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java @@ -117,5 +117,11 @@ public enum EventType { * Called when the mouse hovers over sticky control points (nodes) in tooltip mode. * Parameters: String text (semicolon-separated component and node names, or null), Point point. */ - NODE_NAME_HOVER_TOOLTIP; + NODE_NAME_HOVER_TOOLTIP, + /** + * Called when the set of enabled component categories changes. The first parameter is a + * Set<String> of disabled category names. All categories not included are + * enabled. + */ + COMPONENT_CATEGORIES_CHANGED; } diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java index 03cf41c6..3118b19f 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java @@ -83,6 +83,7 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP public static final String HARDWARE_ACCELERATION = "hardwareAcceleration"; public static final String EXTRA_SPACE_KEY = "extraSpace"; public static final String FAVORITES_KEY = "favorites"; + public static final String DISABLED_CATEGORIES_KEY = "disabledComponentCategories"; public static final String RENUMBER_ON_PASTE_KEY = "renumberOnPaste"; public static final String RULER_IN_SUBDIVISION_KEY = "rulerInSubdivision"; public static final String CACHING_ENABLED_KEY = "cachingEnabled"; @@ -213,6 +214,20 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP */ Map> getComponentTypes(); + /** + * Returns the {@link ComponentType}s that should be offered to the user, i.e. the result of + * {@link #getComponentTypes()} without the categories the user disabled. Intended for component + * browsers; anything that needs to resolve an arbitrary component class must use + * {@link #getComponentTypes()} instead. + */ + Map> getVisibleComponentTypes(); + + boolean isCategoryEnabled(String category); + + void setCategoryEnabled(String category, boolean enabled); + + void resetCategoryVisibility(); + /** * Draws project on the provided {@link Graphics2D}. If the provided filter is not null, it will * be used to filter the components that are shown. diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index a1f36433..5968cbe7 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -202,6 +202,14 @@ public class Presenter implements IPlugInPort { messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, null, null); } }); + + configManager.addConfigListener(IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + @Override + public void valueChanged(String key, Object value) { + messageDispatcher.dispatchMessage(EventType.COMPONENT_CATEGORIES_CHANGED, + getDisabledCategories()); + } + }); } public void installPlugin(Supplier plugInSupplier) { @@ -408,12 +416,58 @@ public class Presenter implements IPlugInPort { return projectFileManager.isModified(); } + @SuppressWarnings("unchecked") + private Set getDisabledCategories() { + List list = (List) configManager.readObject( + IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + if (list == null) { + return new HashSet(); + } + return new HashSet(list); + } + @SuppressWarnings("unchecked") @Override public Map> getComponentTypes() { return ComponentProcessor.getInstance().getComponentTypes(); } + @Override + public Map> getVisibleComponentTypes() { + Set disabled = getDisabledCategories(); + Map> all = getComponentTypes(); + Map> visible = new HashMap>(); + for (Map.Entry> entry : all.entrySet()) { + if (!disabled.contains(entry.getKey())) { + visible.put(entry.getKey(), entry.getValue()); + } + } + return visible; + } + + @Override + public boolean isCategoryEnabled(String category) { + return !getDisabledCategories().contains(category); + } + + @Override + public void setCategoryEnabled(String category, boolean enabled) { + Set disabled = getDisabledCategories(); + if (enabled) { + disabled.remove(category); + } else { + disabled.add(category); + } + List list = new ArrayList(disabled); + Collections.sort(list); + configManager.writeValue(IPlugInPort.DISABLED_CATEGORIES_KEY, list); + } + + @Override + public void resetCategoryVisibility() { + configManager.writeValue(IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + } + @SuppressWarnings({"unchecked"}) private boolean isComponentVisible(IDIYComponent component) { ComponentType componentType = diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 139d6d97..0ee9ca77 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -557,6 +557,10 @@ NEW_FEATURE TFT Display - 2.8 inch and 2.4 inch ILI9341, 1.8 inch and 0.96 inch ST7735, 1.54 inch ST7789, 1.28 inch round GC9A01 + + NEW_FEATURE + Added ability to hide component categories in the component tree and toolbar from the Config menu + IMPROVEMENT Bill of Materials now lists the selected variant of microcontroller boards and displays, so different versions of the same board are no longer merged into a single row diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java index 5ac45f23..65813ab5 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java @@ -30,12 +30,14 @@ import org.diylc.core.ExpansionMode; import org.diylc.core.Theme; import org.diylc.swing.actions.CheckProximityAction; import org.diylc.swing.actions.ComponentBrowserAction; +import org.diylc.swing.actions.ComponentCategoryAction; import org.diylc.swing.actions.ConfigAction; import org.diylc.swing.actions.ExportGerberAction; import org.diylc.swing.actions.ExportPNGAction; import org.diylc.swing.actions.FindAction; import org.diylc.swing.actions.FlexibleLeadsAction; import org.diylc.swing.actions.RenumberAction; +import org.diylc.swing.actions.ResetCategoriesAction; import org.diylc.swing.actions.ThemeAction; import org.diylc.swing.actions.ToggleAction; import org.diylc.swing.actions.edit.BringToFrontAction; @@ -264,6 +266,15 @@ public class ActionFactory { return new ComponentBrowserAction(browserType); } + public ComponentCategoryAction createComponentCategoryAction(IPlugInPort plugInPort, + String category) { + return new ComponentCategoryAction(plugInPort, category); + } + + public ResetCategoriesAction createResetCategoriesAction(IPlugInPort plugInPort) { + return new ResetCategoriesAction(plugInPort); + } + public RenumberAction createRenumberAction(IPlugInPort plugInPort, boolean xAxisFirst) { return new RenumberAction(plugInPort, xAxisFirst); } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java new file mode 100644 index 00000000..3dbf9ef8 --- /dev/null +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java @@ -0,0 +1,63 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.swing.actions; + +import java.awt.event.ActionEvent; +import javax.swing.AbstractAction; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.appframework.miscutils.IConfigListener; + +import org.diylc.common.IPlugInPort; +import org.diylc.core.IView; + +public class ComponentCategoryAction extends AbstractAction { + + private static final long serialVersionUID = 1L; + + private final IPlugInPort plugInPort; + private final String category; + + public ComponentCategoryAction(IPlugInPort plugInPort, String category) { + super(); + this.plugInPort = plugInPort; + this.category = category; + putValue(AbstractAction.NAME, category); + putValue(IView.CHECK_BOX_MENU_ITEM, true); + putValue(AbstractAction.SELECTED_KEY, plugInPort.isCategoryEnabled(category)); + + ConfigurationManager.getInstance().addConfigListener( + IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + + @Override + public void valueChanged(String key, Object value) { + putValue(AbstractAction.SELECTED_KEY, plugInPort.isCategoryEnabled(category)); + } + }); + } + + @Override + public void actionPerformed(ActionEvent e) { + boolean selected = Boolean.TRUE.equals(getValue(AbstractAction.SELECTED_KEY)); + plugInPort.setCategoryEnabled(category, selected); + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java new file mode 100644 index 00000000..d1e176b3 --- /dev/null +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java @@ -0,0 +1,45 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.swing.actions; + +import java.awt.event.ActionEvent; +import javax.swing.AbstractAction; + +import org.diylc.common.IPlugInPort; + +public class ResetCategoriesAction extends AbstractAction { + + private static final long serialVersionUID = 1L; + + private final IPlugInPort plugInPort; + + public ResetCategoriesAction(IPlugInPort plugInPort) { + super(); + this.plugInPort = plugInPort; + putValue(AbstractAction.NAME, "Reset All"); + } + + @Override + public void actionPerformed(ActionEvent e) { + plugInPort.resetCategoryVisibility(); + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java index fa51c130..88becfee 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java @@ -58,6 +58,7 @@ public class ConfigPlugin implements IPlugIn { private static final String THEME_MENU = "Theme"; private static final String RULER_MENU = "Ruler Inch Subdivision..."; private static final String SNAP_MENU = "Snap To"; + private static final String CATEGORIES_MENU = "Component Categories"; private static final String COMPONENT_BROWSER_MENU = "Toolbox"; public static final String COMPONENT_BROWSER = "componentBrowser"; public static final String PROJECT_EXPLORER = "projectExplorer"; @@ -89,7 +90,15 @@ public class ConfigPlugin implements IPlugIn { IPlugInPort.AUTO_UNIT_CONVERSION_KEY, true), CONFIG_MENU); swingUI.injectMenuAction( ActionFactory.getInstance().createConfigAction(plugInPort, "Auto-Edit Mode", IPlugInPort.AUTO_EDIT_KEY, true), - CONFIG_MENU); + CONFIG_MENU); + swingUI.injectSubmenu(CATEGORIES_MENU, IconLoader.FolderPreferences.getIcon(), CONFIG_MENU); + swingUI.injectMenuAction( + ActionFactory.getInstance().createResetCategoriesAction(plugInPort), CATEGORIES_MENU); + swingUI.injectMenuAction(null, CATEGORIES_MENU); + plugInPort.getComponentTypes().keySet().stream().sorted() + .forEach(category -> swingUI.injectMenuAction( + ActionFactory.getInstance().createComponentCategoryAction(plugInPort, category), + CATEGORIES_MENU)); swingUI.injectMenuAction( ActionFactory.getInstance().createConfigAction(plugInPort, "Continuous Creation", IPlugInPort.CONTINUOUS_CREATION_KEY, false), CONFIG_MENU); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java index 4de76b9c..bccc4934 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java @@ -89,6 +89,9 @@ class ComponentTabbedPane extends JTabbedPane { private List pendingRecentComponents = null; private List blocks; + private final Map categoryPanels = new HashMap(); + private boolean refreshing = false; + public ComponentTabbedPane(IPlugInPort plugInPort, ISwingUI swingUI) { super(); this.plugInPort = plugInPort; @@ -100,23 +103,22 @@ class ComponentTabbedPane extends JTabbedPane { addTab(LangUtil.translate("(Favorites)"), createFavoritesPanel()); addTab(LangUtil.translate("(Recently Used)"), createRecentComponentsPanel()); addTab(LangUtil.translate("(Building Blocks)"), createBuildingBlocksPanel()); - Map> componentTypes = plugInPort.getComponentTypes(); - List categories = new ArrayList(componentTypes.keySet()); - Collections.sort(categories); - for (String category : categories) { - JPanel panel = createTab((componentTypes.get(category))); - addTab(category, panel); - } + + refreshCategoryTabs(); // restore last selected tab int lastSelectedTab = ConfigurationManager.getInstance().readInt(LAST_SELECTED_TAB, -1); - if (lastSelectedTab >= 0) + if (lastSelectedTab >= 0 && lastSelectedTab < getTabCount()) { setSelectedIndex(lastSelectedTab); + } addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { + if (refreshing) { + return; + } ConfigurationManager.getInstance().writeValue(LAST_SELECTED_TAB, getSelectedIndex()); ComponentTabbedPane.this.plugInPort.setNewComponentTypeSlot(null, null, null, false); // Refresh recent components if needed @@ -131,6 +133,50 @@ class ComponentTabbedPane extends JTabbedPane { setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); } + public void refreshCategoryTabs() { + refreshing = true; + int restoredIndex = -1; + try { + int previousIndex = getSelectedIndex(); + String selectedTitle = + previousIndex >= 0 && previousIndex < getTabCount() ? getTitleAt(previousIndex) : null; + + while (getTabCount() > 3) { + removeTabAt(3); + } + + Map> visibleComponentTypes = + plugInPort.getVisibleComponentTypes(); + List categories = new ArrayList(visibleComponentTypes.keySet()); + Collections.sort(categories); + for (String category : categories) { + Component panel = categoryPanels.get(category); + if (panel == null) { + panel = createTab(visibleComponentTypes.get(category)); + categoryPanels.put(category, panel); + } + addTab(category, panel); + } + + if (selectedTitle != null) { + for (int i = 0; i < getTabCount(); i++) { + if (selectedTitle.equals(getTitleAt(i))) { + restoredIndex = i; + break; + } + } + } + } finally { + refreshing = false; + } + + if (restoredIndex >= 0) { + setSelectedIndex(restoredIndex); + } else if (getTabCount() > 0) { + setSelectedIndex(0); + } + } + @SuppressWarnings("unchecked") private void initialize() { Map> componentTypes = plugInPort.getComponentTypes(); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java index a8024803..a099d917 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java @@ -79,13 +79,13 @@ public class ToolBox implements IPlugIn { @Override public EnumSet getSubscribedEventTypes() { - // TODO Auto-generated method stub - return null; + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); } @Override public void processMessage(EventType eventType, Object... params) { - // TODO Auto-generated method stub - + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getComponentTabbedPane().refreshCategoryTabs(); + } } } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java index db14d14d..4b79e43f 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java @@ -103,13 +103,13 @@ public class ComponentTree implements IPlugIn { @Override public EnumSet getSubscribedEventTypes() { - // TODO Auto-generated method stub - return null; + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); } @Override public void processMessage(EventType eventType, Object... params) { - // TODO Auto-generated method stub - + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getTreePanel().getTreeModel().refresh(); + } } } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java index 44ae7323..081462c1 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java @@ -76,20 +76,26 @@ public class CustomTreeModel implements TreeModel { public CustomTreeModel(IPlugInPort plugInPort) { super(); this.plugInPort = plugInPort; - this.componentTypes = plugInPort.getComponentTypes(); + this.visibleLeaves = new HashMap>(); + + this.typesByClass = new HashMap(); + for (Map.Entry> e : plugInPort.getComponentTypes().entrySet()) { + for (ComponentType c : e.getValue()) { + typesByClass.put(c.getInstanceClass().getCanonicalName(), c); + } + } + + this.initialize(); + refresh(); + } + + public void refresh() { + this.componentTypes = plugInPort.getVisibleComponentTypes(); this.categories = new ArrayList(this.componentTypes.keySet()); Collections.sort(this.categories); this.categories.add(0, BUILDING_BLOCKS); this.categories.add(0, RECENTLY_USED); this.categories.add(0, FAVORITES); - this.visibleLeaves = new HashMap>(); - - this.typesByClass = new HashMap(); - for (Map.Entry> e : componentTypes.entrySet()) - for (ComponentType c : e.getValue()) - typesByClass.put(c.getInstanceClass().getCanonicalName(), c); - - this.initialize(); updateVisibleCategories(); } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java index 38c263c6..2a67f525 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java @@ -89,7 +89,7 @@ public enum IconLoader { "trace_mask.png"), TraceProximity("trace_proximity.png"), Undo("undo.png"), Ungroup( "ungroup.png"), Unlock("lock_open.png"), Upload("upload.png"), User("user1.png"), Warning( "warning.png"), Web("web.png"), WindowColors("window_colors.png"), WindowGear( - "window_gear.png"), Wrench("wrench.png"), ZoomSmall("zoom_small.png"); + "window_gear.png"), Wrench("wrench.png"), ZoomSmall("zoom_small.png"), FolderPreferences("folder_preferences.png"); protected String name; diff --git a/diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png b/diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png new file mode 100644 index 00000000..2f7fad44 Binary files /dev/null and b/diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png differ diff --git a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java index 53debf6b..989fb3b1 100644 --- a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java +++ b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java @@ -2,12 +2,27 @@ package org.diylc; import java.awt.Point; import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Map; +import java.util.Set; -import org.junit.Test; import junit.framework.Assert; +import org.junit.Test; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.appframework.miscutils.InMemoryConfigurationManager; import org.diylc.common.ComponentType; +import org.diylc.common.EventType; +import org.diylc.common.IPlugIn; +import org.diylc.common.IPlugInPort; import org.diylc.core.Template; +import org.diylc.presenter.Presenter; +import org.diylc.swing.ActionFactory; +import org.diylc.swing.actions.ComponentCategoryAction; +import org.diylc.swing.actions.ResetCategoriesAction; +import org.diylc.swing.plugins.tree.CustomTreeModel; public class PresenterTests extends TestBase { @@ -98,6 +113,114 @@ public class PresenterTests extends TestBase { presenter.mouseMoved(new Point(1000, 1000), false, false, false); Assert.assertNull(receivedTooltip[0]); } + + @Test + public void testCategoryVisibilityDefaults() { + presenter.resetCategoryVisibility(); + Map> all = presenter.getComponentTypes(); + Map> visible = presenter.getVisibleComponentTypes(); + Assert.assertEquals(all.keySet(), visible.keySet()); + for (String category : all.keySet()) { + Assert.assertTrue(presenter.isCategoryEnabled(category)); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testDisableAndEnableCategory() { + presenter.resetCategoryVisibility(); + + final Set[] lastEventDisabled = new Set[1]; + IPlugIn testPlugin = new IPlugIn() { + @Override + public void connect(IPlugInPort plugInPort) {} + + @Override + public EnumSet getSubscribedEventTypes() { + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); + } + + @Override + public void processMessage(EventType eventType, Object... params) { + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED && params.length > 0) { + lastEventDisabled[0] = (Set) params[0]; + } + } + }; + presenter.installPlugin(() -> testPlugin); + + // Disable "Tubes" + presenter.setCategoryEnabled("Tubes", false); + Assert.assertFalse(presenter.isCategoryEnabled("Tubes")); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Tubes")); + Assert.assertTrue(presenter.getComponentTypes().containsKey("Tubes")); + Assert.assertNotNull(lastEventDisabled[0]); + Assert.assertTrue(lastEventDisabled[0].contains("Tubes")); + + // Also disable "Robotics" + presenter.setCategoryEnabled("Robotics", false); + Assert.assertFalse(presenter.isCategoryEnabled("Robotics")); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Robotics")); + Assert.assertTrue(lastEventDisabled[0].contains("Robotics")); + Assert.assertTrue(lastEventDisabled[0].contains("Tubes")); + + // Check config stores sorted list + List stored = (List) InMemoryConfigurationManager.getInstance() + .readObject(IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + Assert.assertEquals(2, stored.size()); + Assert.assertEquals("Robotics", stored.get(0)); + Assert.assertEquals("Tubes", stored.get(1)); + + // Re-enable "Tubes" + presenter.setCategoryEnabled("Tubes", true); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + Assert.assertTrue(presenter.getVisibleComponentTypes().containsKey("Tubes")); + Assert.assertFalse(lastEventDisabled[0].contains("Tubes")); + Assert.assertTrue(lastEventDisabled[0].contains("Robotics")); + + // Reset all + presenter.resetCategoryVisibility(); + Assert.assertTrue(presenter.isCategoryEnabled("Robotics")); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + Assert.assertTrue(lastEventDisabled[0].isEmpty()); + } + + @Test + public void testComponentCategoryActions() { + Presenter prodPresenter = new Presenter(view, ConfigurationManager.getInstance()); + prodPresenter.resetCategoryVisibility(); + + ComponentCategoryAction catAction = + ActionFactory.getInstance().createComponentCategoryAction(prodPresenter, "Tubes"); + Assert.assertEquals(true, catAction.getValue(javax.swing.AbstractAction.SELECTED_KEY)); + + // Toggle off + catAction.putValue(javax.swing.AbstractAction.SELECTED_KEY, false); + catAction.actionPerformed(null); + Assert.assertFalse(prodPresenter.isCategoryEnabled("Tubes")); + + // Reset via ResetCategoriesAction + ResetCategoriesAction resetAction = + ActionFactory.getInstance().createResetCategoriesAction(prodPresenter); + resetAction.actionPerformed(null); + Assert.assertTrue(prodPresenter.isCategoryEnabled("Tubes")); + Assert.assertEquals(true, catAction.getValue(javax.swing.AbstractAction.SELECTED_KEY)); + } + + @Test + public void testCustomTreeModelRefresh() { + presenter.resetCategoryVisibility(); + CustomTreeModel treeModel = new CustomTreeModel(presenter); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + + presenter.setCategoryEnabled("Tubes", false); + treeModel.refresh(); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Tubes")); + + presenter.resetCategoryVisibility(); + treeModel.refresh(); + Assert.assertTrue(presenter.getVisibleComponentTypes().containsKey("Tubes")); + } // @Test // public void testLoadVariants() { diff --git a/diylc/docs/plans/category-visibility.md b/diylc/docs/plans/category-visibility.md new file mode 100644 index 00000000..2fbf91e5 --- /dev/null +++ b/diylc/docs/plans/category-visibility.md @@ -0,0 +1,288 @@ +# Component Category Visibility + +A `Config` submenu that lets the user hide whole component categories from the +component tree and the tabbed component bar, with the filtering applied in the +`Presenter` and announced to the views through a new event. + +## 1. Goal + +The library ships sixteen categories (`Boards`, `Connectivity`, `Controllers`, +`Displays & Outputs`, `Electro-Mechanical`, `Guitar`, `Misc`, +`Modules & Breakouts`, `Passive`, `Robotics`, `SMD`, `Schematic Symbols`, +`Semiconductors`, `Sensors`, `Shapes`, `Tubes`). A user who only ever draws +guitar wiring has to scroll past tabs and tree nodes they will never open. The +feature is a per-user toggle list: every category visible by default, any subset +hidden, the change visible immediately in whichever browser is active. + +New in `ConfigPlugin`: + +``` +Config + ... + Auto-Edit Mode + Component Categories > + Reset All + --------------- + [x] Boards + [x] Connectivity + ... + [x] Tubes + Continuous Creation + ... +``` + +Disabling a category changes **only what the two browsers list**. Nothing is +hidden on the canvas, no existing project is altered, and no `.diy` file is +affected, so the serialized format and the regression references are untouched. + +## 2. Four decisions taken up front + +**The filtered view is a second accessor, not a change to `getComponentTypes()`.** +That method has six callers and only two of them are browsers: + +| Caller | Uses the map for | Needs every category | +| --- | --- | --- | +| `CustomTreeModel` | tree nodes | no - filter here | +| `ComponentTabbedPane` | category tabs | no - filter here | +| `CanvasPanel` | type cache, keyboard-shortcut lookup | yes | +| `ExplorerPane` | list cell renderer | yes | +| `NetlistImportDialog` | netlist type mapping | yes | +| `ExportVariantsAction` | variant export tree | yes | + +Had the existing method started filtering, a hidden category would silently +break the explorer's rendering of components already on the canvas and the +netlist importer's type lookup. So `getComponentTypes()` keeps its contract and +a new `getVisibleComponentTypes()` is added for the browsers. + +**Favorites, Recently Used and keyboard shortcuts stay reachable.** Only the +per-category listings are filtered. A part the user explicitly pinned does not +vanish because its category was collapsed, and `CustomTreeModel`'s favorites +branch - which dereferences `typesByClass.get(fav.getName())` without a null +check - keeps working because that map is still built from the complete type +list. + +**One config key holds the disabled categories**, as a `List` written +through `writeValue` / read through `readObject`, the same shape as +`FAVORITES_KEY` and `RECENT_COMPONENTS_KEY`: + +``` +disabledComponentCategories = ["Robotics", "Tubes"] +``` + +Storing the *disabled* set rather than the enabled one means an empty value is +the default state, and a category introduced in a future release shows up +enabled for every existing user without a migration. "Reset All" is then simply +writing an empty list. The alternative - one boolean key per category - would +have let `ConfigAction` be reused verbatim, at the cost of config keys derived +from display names and one listener per category inside the `Presenter`. + +**The submenu sits directly under `Config`**, a sibling of `Snap To`, `Theme` +and `Toolbox`, rather than nested inside `Toolbox`. + +## 3. Core: the event + +`diylc-core/src/main/java/org/diylc/common/EventType.java` gains a constant at +the end of the enum, documented like its neighbours: + +```java +/** + * Called when the set of enabled component categories changes. The first parameter is a + * Set<String> of disabled category names. All categories not included are + * enabled. + */ +COMPONENT_CATEGORIES_CHANGED +``` + +The payload mirrors `LAYER_VISIBILITY_CHANGED`, which passes the set of hidden +layers. Views are free to ignore it and re-read `getVisibleComponentTypes()`, +which is what both of them will do. + +## 4. Core: `IPlugInPort` + +Next to `FAVORITES_KEY`: + +```java +public static final String DISABLED_CATEGORIES_KEY = "disabledComponentCategories"; +``` + +and four methods, the accessor documented immediately after +`getComponentTypes()` so the difference between the two is impossible to miss: + +```java +/** + * Returns the {@link ComponentType}s that should be offered to the user, i.e. the result of + * {@link #getComponentTypes()} without the categories the user disabled. Intended for component + * browsers; anything that needs to resolve an arbitrary component class must use + * {@link #getComponentTypes()} instead. + */ +Map> getVisibleComponentTypes(); + +boolean isCategoryEnabled(String category); + +void setCategoryEnabled(String category, boolean enabled); + +void resetCategoryVisibility(); +``` + +Keeping the set arithmetic behind these three mutators means the Swing action +never parses the stored list and never touches the config key, so the meaning of +"disabled" lives in exactly one place. + +## 5. Core: `Presenter` + +The disabled set is read from `configManager` on demand rather than cached in a +field. None of these calls is on a hot path - they run when a browser rebuilds +or a menu item is toggled - and a stateless read cannot go stale: + +```java +@SuppressWarnings("unchecked") +private Set getDisabledCategories() { + return new HashSet( + (List) configManager.readObject(DISABLED_CATEGORIES_KEY, new ArrayList())); +} +``` + +`getVisibleComponentTypes()` copies the entries it keeps into a new map; +`ComponentProcessor` hands back its own cached instance, which must not be +mutated. + +`setCategoryEnabled` adds or removes one name and writes a sorted +`ArrayList` back, so the config file stays stable across sessions. +`resetCategoryVisibility()` writes an empty list. + +The event is fired from a config listener, registered in the constructor next to +the existing `SHOW_NODE_NAME_TOOLTIPS_KEY` one, rather than from the setters: + +```java +configManager.addConfigListener(IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + @Override + public void valueChanged(String key, Object value) { + messageDispatcher.dispatchMessage(EventType.COMPONENT_CATEGORIES_CHANGED, + getDisabledCategories()); + } +}); +``` + +Any writer then refreshes the views, not just the menu. The dispatcher is +constructed with `new MessageDispatcher(true)` - synchronous - so the +views are rebuilt on the same EDT call that toggled the checkbox, and no +`invokeLater` is needed. + +## 6. Swing: the menu + +Two new action classes beside `ConfigAction` and `ComponentBrowserAction` in +`org.diylc.swing.actions`: + +- **`ComponentCategoryAction`** - one per category. Modelled on `ConfigAction`: + `NAME` is the category, `IView.CHECK_BOX_MENU_ITEM` is `true`, + `SELECTED_KEY` is seeded from `plugInPort.isCategoryEnabled(category)`, and a + listener on `DISABLED_CATEGORIES_KEY` re-reads that flag so "Reset All" + re-checks every item in the submenu. `actionPerformed` calls + `setCategoryEnabled`. +- **`ResetCategoriesAction`** - a plain item calling + `resetCategoryVisibility()`. + +`ActionFactory` gets `createComponentCategoryAction(plugInPort, category)` and +`createResetCategoriesAction(plugInPort)` next to +`createComponentBrowserAction`. + +`ConfigPlugin` gains `CATEGORIES_MENU = "Component Categories"` and, placed +alphabetically between "Auto-Edit Mode" and "Continuous Creation" to match the +ordering of the rest of the menu: + +```java +swingUI.injectSubmenu(CATEGORIES_MENU, IconLoader.FolderPreferences.getIcon(), CONFIG_MENU); +swingUI.injectMenuAction( + ActionFactory.getInstance().createResetCategoriesAction(plugInPort), CATEGORIES_MENU); +swingUI.injectMenuAction(null, CATEGORIES_MENU); +plugInPort.getComponentTypes().keySet().stream().sorted() + .forEach(category -> swingUI.injectMenuAction( + ActionFactory.getInstance().createComponentCategoryAction(plugInPort, category), + CATEGORIES_MENU)); +``` + +The items are built from the **complete** keyset - a disabled category still +needs a checkbox to turn it back on. `injectMenuAction(null, ...)` is the +established way to add a separator, and `MainFrame.injectMenuAction` already +pushes action names through `LangUtil.translate`, so category names are +translated like every other menu label. + +## 7. Swing: the component tree reacts + +`CustomTreeModel` takes its categories from the filtered map while keeping the +class lookup complete: + +```java +this.componentTypes = plugInPort.getVisibleComponentTypes(); +... +for (Map.Entry> e : plugInPort.getComponentTypes().entrySet()) + for (ComponentType c : e.getValue()) + typesByClass.put(c.getInstanceClass().getCanonicalName(), c); +``` + +and gains a `refresh()` that re-reads the visible map, rebuilds `categories` +with `(Favorites)`, `(Recently Used)` and `(Building Blocks)` back on front, and +calls the existing `updateVisibleCategories()`, which already clears the leaf +cache and fires `treeStructureChanged`. The constructor's category setup moves +into that method so there is one copy of it. `TreePanel` exposes it through its +existing `getTreeModel()`. + +`ComponentTree` currently returns `null` from `getSubscribedEventTypes()` with a +TODO and does nothing in `processMessage`. Both are filled in: + +```java +@Override +public EnumSet getSubscribedEventTypes() { + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); +} + +@Override +public void processMessage(EventType eventType, Object... params) { + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getTreePanel().getTreeModel().refresh(); + } +} +``` + +Search text survives the refresh because `updateVisibleCategories()` re-applies +it; tree expansion state does not, which is acceptable for an explicit menu +action. + +## 8. Swing: the tabbed toolbar reacts + +`ComponentTabbedPane` builds its category tabs in the constructor, after the +three fixed tabs. That loop moves into `refreshCategoryTabs()`, which removes +every tab from index 3 onwards and re-adds one per visible category. Each +category's panel is cached in a `Map`, because +`createComponentPanel` instantiates a `ComponentButtonFactory` button - with its +icon and variant popup - for every type in the category; toggling a category +must not pay that cost again for the fifteen categories that did not change. + +The selected tab is remembered by title and restored if it is still present. A +`refreshing` guard keeps the existing `ChangeListener` from persisting +intermediate `lastSelectedTab` indices while tabs are being removed. + +`ToolBox` fills in the same `null`/TODO pair as `ComponentTree`, delegating to +`getComponentTabbedPane().refreshCategoryTabs()`. + +## 9. What is deliberately not touched + +`CanvasPanel`, `ExplorerPane`, `NetlistImportDialog` and `ExportVariantsAction` +keep calling `getComponentTypes()` and keep seeing every category, so keyboard +shortcuts, the project explorer's renderer, netlist import and variant export +behave identically whatever is hidden. + +## 10. Verification + +```bash +mvn -q -pl diylc-swing -am compile # core + library + swing +mvn -q test # reactor, since core changed +``` + +Then a manual pass in the app: toggle a category with the tree active and with +the tabbed toolbar active, confirm both update without reopening the menu, +confirm "Reset All" re-checks every item, confirm a favorited part from a hidden +category is still clickable, and confirm the setting survives a restart. + +The change touches neither rendering, serialization nor netlist output, so a +regression-suite run is not called for.