add variants button in the tree

This commit is contained in:
Bane Stojkovic
2026-11-03 18:18:12 +01:00
parent 75d0accc5a
commit 1ae9c17cb0
12 changed files with 331 additions and 48 deletions
@@ -573,6 +573,10 @@
<changeType>IMPROVEMENT</changeType>
<description>Added B-E-C, C-E-B and S-D-G transistor pinouts and ADJ-IN-OUT, IN-OUT-GND and IN-OUT-ADJ regulator pinouts</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>The '+N' variant count next to a component in the component tree is now a button - click it to pick one of the component's variants or datasheet parts from a drop-down menu, without having to right-click the component</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>'DIP IC' with 'Display Pin #s' set to 'No' numbered its pins in the wrong order in netlists and AI analysis, e.g. pin 8 of an 8-pin IC was reported as pin 5</description>
@@ -17,10 +17,18 @@
*/
package org.diylc.swing.plugins.tree;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.dnd.DnDConstants;
import java.awt.dnd.DragSource;
import java.awt.dnd.DropTarget;
@@ -28,6 +36,7 @@ import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionAdapter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -35,11 +44,13 @@ import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.JTextField;
@@ -53,6 +64,7 @@ import javax.swing.event.TreeModelEvent;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreeCellRenderer;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;
import org.apache.log4j.Logger;
@@ -106,6 +118,9 @@ public class TreePanel extends JPanel {
*/
private TreePath suppressLeafActivationForPath;
private int hoveredVariantsButtonRow = -1;
private boolean variantsButtonPressed;
@SuppressWarnings("unchecked")
public TreePanel(IPlugInPort plugInPort, ISwingUI swingUI) {
this.plugInPort = plugInPort;
@@ -135,7 +150,7 @@ public class TreePanel extends JPanel {
add(getTreeScroll(), gbc);
setPreferredSize(new Dimension(260, 200));
setPreferredSize(new Dimension(290, 200));
ConfigurationManager.getInstance().addConfigListener(IPlugInPort.FAVORITES_KEY,
new IConfigListener() {
@@ -211,7 +226,34 @@ public class TreePanel extends JPanel {
public JTree getTree() {
if (tree == null) {
tree = new JTree(getTreeModel());
tree = new JTree(getTreeModel()) {
private static final long serialVersionUID = 1L;
@Override
protected void processMouseEvent(MouseEvent e) {
if (!processVariantsButtonEvent(e)) {
super.processMouseEvent(e);
}
}
@Override
protected void processMouseMotionEvent(MouseEvent e) {
if (!processVariantsButtonEvent(e)) {
super.processMouseMotionEvent(e);
}
}
@Override
public String getToolTipText(MouseEvent e) {
TreePath path = getVariantsButtonPathAt(e.getX(), e.getY());
if (path != null) {
return String.format(LangUtil.translate("Click to choose from %s variants"),
countVariants(((TreeNode) path.getLastPathComponent()).getComponentType()));
}
return super.getToolTipText(e);
}
};
tree.setRootVisible(false);
tree.setCellRenderer(new ComponentCellRenderer());
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
@@ -255,6 +297,11 @@ public class TreePanel extends JPanel {
getPopup().show(e.getComponent(), e.getX(), e.getY());
}
@Override
public void mouseExited(MouseEvent e) {
updateVariantsButtonHover(-1, -1);
}
/**
* Prefer release over click: {@code mouseClicked} is not posted if the cursor moves
* between press and release (common with DnD or hand jitter), while selection may still
@@ -292,6 +339,14 @@ public class TreePanel extends JPanel {
}
}
});
tree.addMouseMotionListener(new MouseMotionAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
updateVariantsButtonHover(e.getX(), e.getY());
}
});
}
return tree;
}
@@ -411,30 +466,8 @@ public class TreePanel extends JPanel {
popup.add(shortcutSubmenu);
popup.add(new JSeparator());
if (componentType.getDatasheet() != null) {
List<Component> items = ComponentButtonFactory.createDatasheetItems(plugInPort, componentType, new ArrayList<String>(), model -> {
LOG.info("Creating datasheet model " + String.join(", ", model));
plugInPort.setNewComponentTypeSlot(componentType, null, model, false);
});
for (Component item : items) {
popup.add(item);
}
popup.add(new JSeparator());
}
List<Template> templates = plugInPort.getVariantsFor(componentType);
if (templates == null || templates.isEmpty()) {
JMenuItem item = new JMenuItem("<no variants>");
item.setEnabled(false);
popup.add(item);
} else {
for (Template template : templates) {
JMenuItem item =
ComponentButtonFactory.createVariantItem(plugInPort, template, componentType);
popup.add(item);
}
}
addVariantItems(popup, componentType, true);
} else if (getTreeModel().isLeaf(selectedNode)) {
popup.add(new InsertBlockAsGroupAction(plugInPort, payload.toString()));
popup.add(shortcutSubmenu);
@@ -462,13 +495,168 @@ public class TreePanel extends JPanel {
return searchField;
}
public class ComponentCellRenderer extends DefaultTreeCellRenderer {
private int countVariants(ComponentType componentType) {
int count = 0;
if (componentType.getDatasheet() != null) {
count += componentType.getDatasheet().size();
}
List<Template> variants = plugInPort.getVariantsFor(componentType);
if (variants != null) {
count += variants.size();
}
return count;
}
/**
* Returns the on-screen bounds of the variants button drawn in the row under {@code path}, in
* tree coordinates, or null when that row has no button. The renderer is a rubber stamp, so it
* is configured and laid out for the row on demand to find where the button lands.
*/
private Rectangle getVariantsButtonBounds(TreePath path) {
Object node = path.getLastPathComponent();
if (!(node instanceof TreeNode) || ((TreeNode) node).getComponentType() == null) {
return null;
}
int row = tree.getRowForPath(path);
Rectangle rowBounds = tree.getPathBounds(path);
if (rowBounds == null) {
return null;
}
ComponentCellRenderer renderer = (ComponentCellRenderer) tree.getCellRenderer();
renderer.getTreeCellRendererComponent(tree, node, tree.isRowSelected(row),
tree.isExpanded(row), getTreeModel().isLeaf(node), row, false);
if (!renderer.variantsButton.isVisible()) {
return null;
}
renderer.setBounds(rowBounds);
renderer.doLayout();
Rectangle bounds = renderer.variantsButton.getButtonBounds();
bounds.translate(rowBounds.x + renderer.variantsButton.getX(),
rowBounds.y + renderer.variantsButton.getY());
return bounds;
}
private TreePath getVariantsButtonPathAt(int x, int y) {
TreePath path = tree.getPathForLocation(x, y);
if (path == null) {
return null;
}
Rectangle bounds = getVariantsButtonBounds(path);
return bounds != null && bounds.contains(x, y) ? path : null;
}
/**
* A left press on the variants button opens the variants menu and is kept away from the tree
* UI and the drag recognizer, so it neither selects the row (which would instantiate the
* component) nor starts a drag. Returns true when the event was handled here.
*/
private boolean processVariantsButtonEvent(MouseEvent e) {
if (e.getID() == MouseEvent.MOUSE_PRESSED) {
variantsButtonPressed = false;
if (!SwingUtilities.isLeftMouseButton(e) || e.isPopupTrigger()) {
return false;
}
TreePath path = getVariantsButtonPathAt(e.getX(), e.getY());
if (path == null) {
return false;
}
variantsButtonPressed = true;
Rectangle bounds = getVariantsButtonBounds(path);
ComponentType componentType = ((TreeNode) path.getLastPathComponent()).getComponentType();
JPopupMenu variantsPopup = new JPopupMenu();
addVariantItems(variantsPopup, componentType, false);
variantsPopup.show(tree, bounds.x, bounds.y + bounds.height);
return true;
}
if (variantsButtonPressed && (e.getID() == MouseEvent.MOUSE_RELEASED
|| e.getID() == MouseEvent.MOUSE_CLICKED || e.getID() == MouseEvent.MOUSE_DRAGGED)) {
return true;
}
return false;
}
private void updateVariantsButtonHover(int x, int y) {
int row = -1;
if (x >= 0 && y >= 0) {
TreePath path = getVariantsButtonPathAt(x, y);
if (path != null) {
row = tree.getRowForPath(path);
}
}
if (row == hoveredVariantsButtonRow) {
return;
}
repaintRow(hoveredVariantsButtonRow);
hoveredVariantsButtonRow = row;
repaintRow(row);
tree.setCursor(row >= 0 ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
}
private void repaintRow(int row) {
if (row < 0) {
return;
}
Rectangle bounds = tree.getRowBounds(row);
if (bounds != null) {
tree.repaint(bounds);
}
}
/**
* Datasheet models first, then saved variants - the same items that the count on the variants
* button adds up.
*/
private void addVariantItems(JPopupMenu menu, final ComponentType componentType,
boolean showEmpty) {
if (componentType.getDatasheet() != null) {
List<Component> items = ComponentButtonFactory.createDatasheetItems(plugInPort,
componentType, new ArrayList<String>(), model -> {
LOG.info("Creating datasheet model " + String.join(", ", model));
plugInPort.setNewComponentTypeSlot(componentType, null, model, false);
});
for (Component item : items) {
menu.add(item);
}
}
List<Template> templates = plugInPort.getVariantsFor(componentType);
if (templates == null || templates.isEmpty()) {
if (showEmpty) {
if (componentType.getDatasheet() != null) {
menu.add(new JSeparator());
}
JMenuItem item = new JMenuItem("<no variants>");
item.setEnabled(false);
menu.add(item);
}
} else {
if (componentType.getDatasheet() != null) {
menu.add(new JSeparator());
}
for (Template template : templates) {
JMenuItem item =
ComponentButtonFactory.createVariantItem(plugInPort, template, componentType);
menu.add(item);
}
}
}
public class ComponentCellRenderer extends JPanel implements TreeCellRenderer {
private static final long serialVersionUID = 1L;
private DefaultTreeCellRenderer label;
private VariantsButton variantsButton;
public ComponentCellRenderer() {
setVerticalAlignment(SwingConstants.CENTER);
setVerticalTextPosition(SwingConstants.CENTER);
super(new BorderLayout());
setOpaque(false);
label = new DefaultTreeCellRenderer();
label.setVerticalAlignment(SwingConstants.CENTER);
label.setVerticalTextPosition(SwingConstants.CENTER);
variantsButton = new VariantsButton();
add(label, BorderLayout.CENTER);
add(variantsButton, BorderLayout.EAST);
}
@Override
@@ -486,26 +674,27 @@ public class TreePanel extends JPanel {
final boolean selected, final boolean expanded, final boolean leaf, final int row,
final boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus);
label.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus);
variantsButton.setVisible(false);
setToolTipText(null);
Object obj = value;
if (obj != null && obj.getClass().equals(TreeNode.class)) {
TreeNode payload = (TreeNode) obj;
setPreferredSize(null);
label.setPreferredSize(null);
if (payload.getComponentType() == null) {
setToolTipText(null);
if (leaf)
setIcon(IconLoader.Component.getIcon());
if (leaf) {
label.setIcon(IconLoader.Component.getIcon());
}
} else {
setToolTipText("<html><b>" + payload.getComponentType().getName() + "</b><br>"
+ payload.getComponentType().getDescription() + "<br>Author: "
+ payload.getComponentType().getAuthor() + "<br><br>" + CLICK_TO_INSTANTIATE
+ "</html>");
setIcon(payload.getComponentType().getIcon());
label.setIcon(payload.getComponentType().getIcon());
}
String shortCutHtml = "";
String variantsHtml = "";
HashMap<String, String> shortcutMap = (HashMap<String, String>) ConfigurationManager
.getInstance().readObject(TreePanel.COMPONENT_SHORTCUT_KEY, null);
@@ -523,18 +712,12 @@ public class TreePanel extends JPanel {
}
if (payload.getComponentType() != null) {
List<Template> variants = plugInPort.getVariantsFor(payload.getComponentType());
int count = 0;
if (payload.getComponentType().getDatasheet() != null) {
count += payload.getComponentType().getDatasheet().size();
}
if (variants != null && !variants.isEmpty()) {
count += variants.size();
}
int count = countVariants(payload.getComponentType());
if (count > 0) {
variantsHtml =
" <a style=\"text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; background-color: #D7FFC6; color: #666666;\">[+"
+ count + "]</a>";
variantsButton.setFont(tree.getFont());
variantsButton.setCount(count);
variantsButton.setHovered(row == hoveredVariantsButtonRow);
variantsButton.setVisible(true);
}
}
@@ -548,17 +731,32 @@ public class TreePanel extends JPanel {
if (viewportWidth <= 0) {
viewportWidth = 240;
}
// the tree caches row sizes and does not re-measure them when the vertical scroll bar
// appears, so its width is reserved up front to keep it off the variants button
JScrollBar scrollBar = getTreeScroll().getVerticalScrollBar();
if (!scrollBar.isVisible()) {
viewportWidth -= scrollBar.getPreferredSize().width;
}
int overhead = (payload.getComponentType() != null) ? 95 : 45;
// the button sits beside the text rather than inside it, so it takes its width out of
// the wrap width to keep the row as wide as before
if (variantsButton.isVisible()) {
overhead += variantsButton.getPreferredSize().width;
}
int wrapWidth = Math.max(100, viewportWidth - overhead);
// translate categories
setText("<html><div style=\"width: " + wrapWidth + "px;\">"
label.setText("<html><div style=\"width: " + wrapWidth + "px;\">"
+ (payload.getComponentType() == null ? LangUtil.translate(payload.forDisplay())
: payload.forDisplay())
+ shortCutHtml + variantsHtml + "</div></html>");
+ shortCutHtml + "</div></html>");
}
// consecutive rows are often stamped at the same size, which CellRendererPane takes as
// still valid; without this the label would keep the width it had next to the previous
// row's button
invalidate();
return this;
}
}
@@ -651,4 +849,75 @@ public class TreePanel extends JPanel {
plugInPort.deleteBlock(blockName);
}
}
/**
* Pill-shaped "+N" drop-down button painted at the end of a component row. It is only ever
* painted through the cell renderer, so the clicks it appears to take are hit-tested and
* handled by the tree in {@link TreePanel#processVariantsButtonEvent(MouseEvent)}.
*/
private static class VariantsButton extends JComponent {
private static final long serialVersionUID = 1L;
private static final Color FILL_COLOR = new Color(0xD7FFC6);
private static final Color HOVER_FILL_COLOR = new Color(0xB8F0A0);
private static final Color BORDER_COLOR = new Color(0x7DBA63);
private static final Color TEXT_COLOR = new Color(0x3F6B2C);
private static final int LEFT_MARGIN = 4;
private static final int RIGHT_MARGIN = 6;
private static final int PADDING = 5;
private static final int ARROW_GAP = 4;
private static final int ARROW_WIDTH = 7;
private static final int BUTTON_HEIGHT = 16;
private String text = "";
private boolean hovered;
public void setCount(int count) {
this.text = "+" + count;
}
public void setHovered(boolean hovered) {
this.hovered = hovered;
}
@Override
public Dimension getPreferredSize() {
int textWidth = getFontMetrics(getFont()).stringWidth(text);
return new Dimension(LEFT_MARGIN + PADDING + textWidth + ARROW_GAP + ARROW_WIDTH + PADDING
+ RIGHT_MARGIN, BUTTON_HEIGHT);
}
public Rectangle getButtonBounds() {
return new Rectangle(LEFT_MARGIN, (getHeight() - BUTTON_HEIGHT) / 2,
getWidth() - LEFT_MARGIN - RIGHT_MARGIN, BUTTON_HEIGHT);
}
@Override
protected void paintComponent(Graphics g) {
Graphics2D g2d = (Graphics2D) g.create();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
Rectangle b = getButtonBounds();
g2d.setColor(hovered ? HOVER_FILL_COLOR : FILL_COLOR);
g2d.fillRoundRect(b.x, b.y, b.width - 1, b.height - 1, b.height, b.height);
g2d.setColor(BORDER_COLOR);
g2d.drawRoundRect(b.x, b.y, b.width - 1, b.height - 1, b.height, b.height);
g2d.setFont(getFont());
FontMetrics fm = g2d.getFontMetrics();
int textX = b.x + PADDING;
int textY = b.y + (b.height - fm.getHeight()) / 2 + fm.getAscent();
g2d.setColor(TEXT_COLOR);
g2d.drawString(text, textX, textY);
int arrowX = textX + fm.stringWidth(text) + ARROW_GAP;
int arrowY = b.y + b.height / 2 - 1;
g2d.fillPolygon(new int[] {arrowX, arrowX + ARROW_WIDTH, arrowX + ARROW_WIDTH / 2},
new int[] {arrowY - 1, arrowY - 1, arrowY + 3}, 3);
g2d.dispose();
}
}
}
@@ -720,3 +720,4 @@ Hardware|Hardware
Sides|Seiten
All|Alle
Front and Back|Vorne und hinten
Click to choose from %s variants|Klicken, um aus %s Varianten zu wählen
@@ -713,3 +713,4 @@ Hardware
Sides
All
Front and Back
Click to choose from %s variants
@@ -716,3 +716,4 @@ Hardware|Herrajes
Sides|Lados
All|Todos
Front and Back|Frente y parte trasera
Click to choose from %s variants|Haga clic para elegir entre %s variantes
@@ -715,3 +715,4 @@ Hardware|Quincaillerie
Sides|Côtés
All|Tous
Front and Back|Avant et arrière
Click to choose from %s variants|Cliquez pour choisir parmi %s variantes
@@ -717,3 +717,4 @@ Hardware|Minuteria
Sides|Lati
All|Tutti
Front and Back|Fronte e retro
Click to choose from %s variants|Fai clic per scegliere tra %s varianti
@@ -721,3 +721,4 @@ Hardware|Hardware
Sides|Zijden
All|Alle
Front and Back|Voor- en achterkant
Click to choose from %s variants|Klik om te kiezen uit %s varianten
@@ -716,3 +716,4 @@ Hardware|Ferragens
Sides|Lados
All|Todos
Front and Back|Frente e traseira
Click to choose from %s variants|Clique para escolher entre %s variantes
@@ -712,3 +712,4 @@ Hardware|Mehanički delovi
Sides|Strane
All|Sve
Front and Back|Prednja i zadnja
Click to choose from %s variants|Kliknite da izaberete jednu od %s varijanti
@@ -714,3 +714,4 @@ Hardware|हार्डवेयर
Sides|भुजाएं
All|सभी
Front and Back|आगे और पीछे
Click to choose from %s variants|%s वेरिएंट में से चुनने के लिए क्लिक करें
@@ -714,3 +714,4 @@ Hardware|五金件
Sides|侧面
All|全部
Front and Back|前后
Click to choose from %s variants|点击从 %s 个变体中选择