Added 'Node Name Tooltips' feature that can be toggled in the Config menu

This commit is contained in:
Bane Stojkovic
2026-08-16 23:39:11 +02:00
parent 63ad65152a
commit 98d9319938
8 changed files with 375 additions and 11 deletions
@@ -112,5 +112,10 @@ public enum EventType {
/**
* Signals that the Cloud account is logged out.
*/
CLOUD_LOGGED_OUT;
CLOUD_LOGGED_OUT,
/**
* 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;
}
@@ -100,6 +100,8 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP
public static final String SNAP_TO_COMPONENTS = "Components";
public static final String SNAP_TO_DEFAULT = SNAP_TO_GRID;
public static final String SHOW_NODE_NAME_TOOLTIPS_KEY = "showNodeNameTooltips";
public static final String LOCKED_ALPHA = "lockedAlpha";
public static final String LANGUAGE = "language";
@@ -37,6 +37,7 @@ import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.swing.JOptionPane;
import org.apache.log4j.Logger;
import org.diylc.appframework.miscutils.IConfigListener;
import org.diylc.appframework.miscutils.IConfigurationManager;
import org.diylc.appframework.miscutils.Utils;
import org.diylc.appframework.simplemq.MessageDispatcher;
@@ -161,6 +162,8 @@ public class Presenter implements IPlugInPort {
private DIYTest test = null;
private OperationMode operationMode;
private String lastHoveredNodeText = null;
public Presenter(IView view, IConfigurationManager<?> configManager) {
this(view, configManager, false);
@@ -191,6 +194,14 @@ public class Presenter implements IPlugInPort {
variantManager.importDefaultVariants();
buildingBlockManager.importDefaultBlocks();
}
configManager.addConfigListener(IPlugInPort.SHOW_NODE_NAME_TOOLTIPS_KEY, new IConfigListener() {
@Override
public void valueChanged(String key, Object value) {
lastHoveredNodeText = null;
messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, null, null);
}
});
}
public void installPlugin(Supplier<IPlugIn> plugInSupplier) {
@@ -886,6 +897,45 @@ public class Presenter implements IPlugInPort {
messageDispatcher.dispatchMessage(EventType.AVAILABLE_CTRL_POINTS_CHANGED,
new HashMap<IDIYComponent<?>, Set<Integer>>(components));
}
// Node name hover detection for sticky points
String nodeHoverText = null;
if (!dragInProgress && instantiationManager.getComponentTypeSlot() == null) {
List<String> nodeInfos = new ArrayList<String>();
for (int i = currentProject.getComponents().size() - 1; i >= 0; i--) {
IDIYComponent<?> component = currentProject.getComponents().get(i);
for (int pointIndex = 0; pointIndex < component.getControlPointCount(); pointIndex++) {
try {
if (component.isControlPointSticky(pointIndex)) {
Point2D controlPoint = component.getControlPoint(pointIndex);
if (controlPoint != null && previousScaledPoint.distance(controlPoint) < DrawingManager.CONTROL_POINT_SIZE) {
String nodeName = component.getControlPointNodeName(pointIndex);
if (nodeName != null && !nodeName.trim().isEmpty()) {
String info = "<b>" + component.getName() + "</b>: " + nodeName;
if (!nodeInfos.contains(info)) {
nodeInfos.add(info);
}
}
}
}
} catch (Exception e) {
LOG.warn("Error reading control point for component of type: " + component.getClass().getName());
}
}
}
if (!nodeInfos.isEmpty()) {
nodeHoverText = String.join("; ", nodeInfos);
}
}
if (!Objects.equals(nodeHoverText, lastHoveredNodeText)) {
lastHoveredNodeText = nodeHoverText;
if (nodeHoverText != null && configManager.readBoolean(IPlugInPort.SHOW_NODE_NAME_TOOLTIPS_KEY, true)) {
messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, nodeHoverText, point);
} else {
messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, null, null);
}
}
}
@Override
@@ -1084,6 +1134,8 @@ public class Presenter implements IPlugInPort {
return;
}
this.dragInProgress = true;
this.lastHoveredNodeText = null;
this.messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, null, null);
this.dragAction = dragAction;
this.preDragProject = currentProject.clone();
Point2D scaledPoint = scalePoint(point);
@@ -397,6 +397,10 @@
<releaseDate>2026-08-06 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Added 'Node Name Tooltips' feature that can be toggled in the Config menu; when enabled a tooltip is shown next to the cursor with names of component nodes under the cursor</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Significantly improved AI Assistant's knowledge base on DIYLC usage by ingesting most of the material from the eBook</description>
@@ -69,6 +69,7 @@ public class CanvasPlugin implements IPlugIn{
private CanvasPanel canvasPanel;
private ComponentPopupMenu popupMenu;
private ResizeInfoTooltip resizeInfoTooltip;
private NodeNameTooltip nodeNameTooltip;
private boolean resizingInProgress = false;
private IPlugInPort plugInPort;
@@ -196,6 +197,7 @@ public class CanvasPlugin implements IPlugIn{
if (canvasPanel == null) {
canvasPanel = new CanvasPanel(plugInPort, configManager);
resizeInfoTooltip = new ResizeInfoTooltip(canvasPanel, configManager);
nodeNameTooltip = new NodeNameTooltip(canvasPanel, configManager);
canvasPanel.addMouseListener(new MouseAdapter() {
private MouseEvent pressedEvent;
@@ -219,6 +221,9 @@ public class CanvasPlugin implements IPlugIn{
canvasPanel.requestFocus();
canvasPanel.setClickInProgress(true);
pressedEvent = e;
if (nodeNameTooltip != null) {
nodeNameTooltip.hideTooltip();
}
}
@Override
@@ -289,6 +294,10 @@ public class CanvasPlugin implements IPlugIn{
resizeInfoTooltip.hideTooltip();
}
}
if (nodeNameTooltip != null && nodeNameTooltip.isVisible()) {
nodeNameTooltip.updatePosition(e.getPoint());
}
}
});
@@ -469,7 +478,7 @@ public class CanvasPlugin implements IPlugIn{
@Override
public EnumSet<EventType> getSubscribedEventTypes() {
return EnumSet.of(EventType.PROJECT_LOADED, EventType.ZOOM_CHANGED, EventType.REPAINT,
EventType.SCROLL_TO);
EventType.SCROLL_TO, EventType.NODE_NAME_HOVER_TOOLTIP);
}
@SuppressWarnings("incomplete-switch")
@@ -567,6 +576,26 @@ public class CanvasPlugin implements IPlugIn{
(int) (targetRect.getCenterY() - visibleRect2.height / 2), visibleRect2.width,
visibleRect2.height));
break;
case NODE_NAME_HOVER_TOOLTIP:
final String nodeText = params.length > 0 ? (String) params[0] : null;
final Point mousePt = params.length > 1 && params[1] instanceof Point ? (Point) params[1] : null;
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
if (nodeText != null) {
Point pt = mousePt != null ? mousePt : getCanvasPanel().getMousePosition();
if (pt != null && nodeNameTooltip != null) {
nodeNameTooltip.update(nodeText, pt);
}
} else {
if (nodeNameTooltip != null) {
nodeNameTooltip.hideTooltip();
}
}
}
});
break;
}
// }
// });
@@ -0,0 +1,198 @@
/*
*
* 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
* <http://www.gnu.org/licenses/>.
*/
package org.diylc.swing.plugins.canvas;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.Window;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JWindow;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import org.diylc.appframework.miscutils.IConfigurationManager;
/**
* A lightweight tooltip window that displays node/terminal information and tracks the mouse cursor.
*/
public class NodeNameTooltip {
private static final int OFFSET_X = 16;
private static final int OFFSET_Y = 16;
private JWindow tooltipWindow;
private JLabel tooltipLabel;
private Component parentComponent;
private String currentText;
public NodeNameTooltip(Component parentComponent, IConfigurationManager<?> configManager) {
this.parentComponent = parentComponent;
initializeTooltip();
}
private void initializeTooltip() {
Window parentWindow = SwingUtilities.getWindowAncestor(parentComponent);
if (parentWindow == null) {
return;
}
tooltipWindow = new JWindow(parentWindow);
tooltipLabel = new JLabel() {
private static final long serialVersionUID = 1L;
@Override
protected void paintComponent(Graphics g) {
Graphics2D g2d = (Graphics2D) g.create();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
super.paintComponent(g2d);
g2d.dispose();
}
};
// Use system tooltip font and colors
Font tooltipFont = UIManager.getFont("ToolTip.font");
if (tooltipFont != null) {
tooltipLabel.setFont(tooltipFont);
}
Color tooltipBackground = UIManager.getColor("ToolTip.background");
Color tooltipForeground = UIManager.getColor("ToolTip.foreground");
Color tooltipBorderColor = UIManager.getColor("ToolTip.border");
if (tooltipBorderColor == null) {
tooltipBorderColor = Color.GRAY;
}
tooltipLabel.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(tooltipBorderColor, 1),
BorderFactory.createEmptyBorder(4, 6, 4, 6)));
tooltipLabel.setOpaque(true);
tooltipLabel.setBackground(tooltipBackground != null ? tooltipBackground : new Color(255, 255, 220));
tooltipLabel.setForeground(tooltipForeground != null ? tooltipForeground : Color.BLACK);
tooltipWindow.add(tooltipLabel);
tooltipWindow.setFocusableWindowState(false);
tooltipWindow.setAlwaysOnTop(true);
}
/**
* Updates the tooltip with text and mouse position.
*/
public void update(String text, Point mousePosition) {
this.currentText = text;
if (text == null || text.trim().isEmpty() || mousePosition == null) {
hideTooltip();
return;
}
if (tooltipWindow == null) {
initializeTooltip();
if (tooltipWindow == null) {
return;
}
}
tooltipLabel.setText("<html>" + text + "</html>");
tooltipLabel.setPreferredSize(null);
tooltipWindow.pack();
updateTooltipPosition(mousePosition);
tooltipWindow.setVisible(true);
}
/**
* Updates the tooltip position based on the current mouse position.
*/
public void updatePosition(Point mousePosition) {
if (currentText != null && mousePosition != null
&& tooltipWindow != null && tooltipWindow.isVisible()) {
updateTooltipPosition(mousePosition);
}
}
private void updateTooltipPosition(Point mousePosition) {
if (tooltipWindow == null || parentComponent == null) {
return;
}
// Convert mouse position to screen coordinates
Point screenPoint = new Point(mousePosition);
SwingUtilities.convertPointToScreen(screenPoint, parentComponent);
// Position tooltip 16 pixels down and right from cursor
int x = screenPoint.x + OFFSET_X;
int y = screenPoint.y + OFFSET_Y;
// Ensure tooltip stays on screen
Dimension tooltipSize = tooltipWindow.getSize();
if (tooltipSize == null) {
tooltipWindow.pack();
tooltipSize = tooltipWindow.getSize();
}
if (tooltipSize != null) {
java.awt.Toolkit toolkit = java.awt.Toolkit.getDefaultToolkit();
Dimension screenSize = toolkit.getScreenSize();
// Adjust if tooltip would go off screen
if (x + tooltipSize.width > screenSize.width) {
x = screenPoint.x - tooltipSize.width - OFFSET_X;
}
if (y + tooltipSize.height > screenSize.height) {
y = screenPoint.y - tooltipSize.height - OFFSET_Y;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
}
tooltipWindow.setLocation(x, y);
}
/**
* Hides the tooltip.
*/
public void hideTooltip() {
if (tooltipWindow != null) {
tooltipWindow.setVisible(false);
}
currentText = null;
}
public boolean isVisible() {
return tooltipWindow != null && tooltipWindow.isVisible();
}
/**
* Disposes the tooltip window.
*/
public void dispose() {
if (tooltipWindow != null) {
tooltipWindow.dispose();
tooltipWindow = null;
}
}
}
@@ -134,6 +134,9 @@ public class ConfigPlugin implements IPlugIn {
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Mouse Wheel Zoom", IPlugInPort.WHEEL_ZOOM_KEY,
false), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Node Name Tooltips",
IPlugInPort.SHOW_NODE_NAME_TOOLTIPS_KEY, true), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Outline Mode", IPlugInPort.OUTLINE_KEY, false),
CONFIG_MENU);
@@ -153,18 +156,22 @@ public class ConfigPlugin implements IPlugIn {
ActionFactory.getInstance().createConfigAction(plugInPort, "Show AI Assistant",
ConfigPlugin.CHATBOT, true), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Grid", IPlugInPort.SHOW_GRID_KEY, true),
CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Project Explorer",
ConfigPlugin.PROJECT_EXPLORER, false), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Rulers", IPlugInPort.SHOW_RULERS_KEY, true),
CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Resize Dimensions",
IPlugInPort.SHOW_RESIZE_DIMENSIONS_TOOLTIP_KEY, true), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Rulers", IPlugInPort.SHOW_RULERS_KEY, true),
CONFIG_MENU);
swingUI.injectSubmenu(SNAP_MENU, IconLoader.GraphEdgeDirected.getIcon(), CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createToggleAction(IPlugInPort.SNAP_TO_NONE, IPlugInPort.SNAP_TO_KEY, SNAP_MENU, IPlugInPort.SNAP_TO_DEFAULT),
@@ -175,11 +182,7 @@ public class ConfigPlugin implements IPlugIn {
swingUI.injectMenuAction(
ActionFactory.getInstance().createToggleAction(IPlugInPort.SNAP_TO_COMPONENTS, IPlugInPort.SNAP_TO_KEY, SNAP_MENU, IPlugInPort.SNAP_TO_DEFAULT),
SNAP_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance().createConfigAction(plugInPort, "Show Grid", IPlugInPort.SHOW_GRID_KEY, true),
CONFIG_MENU);
swingUI.injectMenuAction(
ActionFactory.getInstance()
.createConfigAction(plugInPort, "Sticky Points", IPlugInPort.STICKY_POINTS_KEY, true), CONFIG_MENU);
@@ -27,6 +27,77 @@ public class PresenterTests extends TestBase {
instantiateOneClick("Connectivity", "Solder Pad", new Point(1000, 1000));
Assert.assertEquals(1, presenter.getCurrentProject().getComponents().size());
}
@Test
public void testNodeNameHover() {
final String[] receivedTooltip = new String[1];
final Point[] receivedPoint = new Point[1];
org.diylc.common.IPlugIn testPlugin = new org.diylc.common.IPlugIn() {
@Override
public void connect(org.diylc.common.IPlugInPort plugInPort) {}
@Override
public java.util.EnumSet<org.diylc.common.EventType> getSubscribedEventTypes() {
return java.util.EnumSet.of(org.diylc.common.EventType.NODE_NAME_HOVER_TOOLTIP);
}
@Override
public void processMessage(org.diylc.common.EventType eventType, Object... params) {
if (eventType == org.diylc.common.EventType.NODE_NAME_HOVER_TOOLTIP) {
receivedTooltip[0] = params.length > 0 ? (String) params[0] : null;
receivedPoint[0] = params.length > 1 ? (Point) params[1] : null;
}
}
};
presenter.installPlugin(() -> testPlugin);
// Add first resistor at (1000, 1000) -> (1200, 1000)
instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000));
Assert.assertEquals(1, presenter.getCurrentProject().getComponents().size());
// Enable node names
org.diylc.appframework.miscutils.InMemoryConfigurationManager.getInstance().writeValue(
org.diylc.common.IPlugInPort.SHOW_NODE_NAME_TOOLTIPS_KEY, true);
// Hover over point 1 (1000, 1000)
presenter.mouseMoved(new Point(1000, 1000), false, false, false);
Assert.assertEquals("<b>R1</b>: 1", receivedTooltip[0]);
Assert.assertEquals(new Point(1000, 1000), receivedPoint[0]);
// Hover over point 2 (1200, 1000)
presenter.mouseMoved(new Point(1200, 1000), false, false, false);
Assert.assertEquals("<b>R1</b>: 2", receivedTooltip[0]);
// Add second resistor with pin 1 also at (1000, 1000)
instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1000, 1200));
Assert.assertEquals(2, presenter.getCurrentProject().getComponents().size());
// Hover over point (1000, 1000) - should show both R2 and R1 separated by semicolon
presenter.mouseMoved(new Point(1000, 1000), false, false, false);
Assert.assertEquals("<b>R2</b>: 1; <b>R1</b>: 1", receivedTooltip[0]);
// Hover away
presenter.mouseMoved(new Point(0, 0), false, false, false);
Assert.assertNull(receivedTooltip[0]);
// Hover back over (1000, 1000)
presenter.mouseMoved(new Point(1000, 1000), false, false, false);
Assert.assertNotNull(receivedTooltip[0]);
// Start drag - should hide tooltip
presenter.dragStarted(new Point(1000, 1000), java.awt.dnd.DnDConstants.ACTION_MOVE, false);
Assert.assertNull(receivedTooltip[0]);
// End drag
presenter.dragEnded(new Point(1000, 1000));
// Disable node names config
org.diylc.appframework.miscutils.InMemoryConfigurationManager.getInstance().writeValue(
org.diylc.common.IPlugInPort.SHOW_NODE_NAME_TOOLTIPS_KEY, false);
presenter.mouseMoved(new Point(1000, 1000), false, false, false);
Assert.assertNull(receivedTooltip[0]);
}
// @Test
// public void testLoadVariants() {