All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.praxislive.ide.pxr.graph.GraphEditor Maven / Gradle / Ivy

/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright 2024 Neil C Smith.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 3 only, as
 * published by the Free Software Foundation.
 *
 * This code 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
 * version 3 for more details.
 *
 * You should have received a copy of the GNU General Public License version 3
 * along with this work; if not, see http://www.gnu.org/licenses/
 *
 *
 * Please visit https://www.praxislive.org if you need additional information or
 * have any questions.
 */
package org.praxislive.ide.pxr.graph;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Point;
import java.awt.datatransfer.Transferable;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyVetoException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import javax.swing.*;
import javax.swing.border.LineBorder;
import javax.swing.text.DefaultEditorKit;
import org.praxislive.core.ComponentType;
import org.praxislive.core.ComponentInfo;
import org.praxislive.core.ControlInfo;
import org.praxislive.core.PortInfo;
import org.praxislive.core.protocols.ComponentProtocol;
import org.praxislive.core.protocols.ContainerProtocol;
import org.praxislive.ide.core.api.Syncable;
import org.praxislive.ide.core.ui.api.Actions;
import org.praxislive.ide.pxr.graph.scene.Alignment;
import org.praxislive.ide.pxr.graph.scene.EdgeID;
import org.praxislive.ide.pxr.graph.scene.EdgeWidget;
import org.praxislive.ide.pxr.graph.scene.NodeWidget;
import org.praxislive.ide.pxr.graph.scene.ObjectSceneAdaptor;
import org.praxislive.ide.pxr.graph.scene.PinID;
import org.praxislive.ide.pxr.graph.scene.PinWidget;
import org.praxislive.ide.pxr.graph.scene.PraxisGraphScene;
import org.praxislive.ide.model.ComponentProxy;
import org.praxislive.ide.model.ContainerProxy;
import org.praxislive.ide.model.RootProxy;
import org.praxislive.ide.pxr.api.ActionSupport;
import org.praxislive.ide.pxr.api.EditorUtils;
import org.praxislive.ide.pxr.spi.RootEditor;
import java.awt.AWTEvent;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.stream.Stream;
import org.netbeans.api.visual.action.AcceptProvider;
import org.netbeans.api.visual.action.ActionFactory;
import org.netbeans.api.visual.action.ConnectProvider;
import org.netbeans.api.visual.action.ConnectorState;
import org.netbeans.api.visual.action.EditProvider;
import org.netbeans.api.visual.action.PopupMenuProvider;
import org.netbeans.api.visual.model.ObjectSceneEvent;
import org.netbeans.api.visual.model.ObjectSceneEventType;
import org.netbeans.api.visual.widget.Scene;
import org.netbeans.api.visual.widget.Widget;
import org.openide.DialogDescriptor;
import org.openide.DialogDisplayer;
import org.openide.NotifyDescriptor;
import org.openide.explorer.ExplorerManager;
import org.openide.filesystems.FileObject;
import org.openide.nodes.Node;
import org.openide.nodes.NodeTransfer;
import org.openide.util.Exceptions;
import org.openide.util.ImageUtilities;
import org.openide.util.Lookup;
import org.openide.util.NbBundle.Messages;
import org.openide.util.Utilities;
import org.openide.util.actions.Presenter;
import org.openide.util.lookup.Lookups;
import org.praxislive.core.Connection;
import org.praxislive.ide.core.api.Disposable;
import org.praxislive.ide.core.api.Task;
import org.praxislive.ide.project.api.PraxisProject;

/**
 *
 */
@Messages({
    "LBL_PropertyModeAction=Properties",
    "LBL_PropertyModeDefault=Default",
    "LBL_PropertyModeShowAll=Show all",
    "LBL_PropertyModeHideAll=Hide all"
})
public final class GraphEditor implements RootEditor {

    private final static Logger LOG = Logger.getLogger(GraphEditor.class.getName());
    final static String ATTR_GRAPH_X = "graph.x";
    final static String ATTR_GRAPH_Y = "graph.y";
    final static String ATTR_GRAPH_MINIMIZED = "graph.minimized";
    final static String ATTR_GRAPH_COMMENT = "graph.comment";
    final static String ATTR_GRAPH_PROPERTIES = "graph.properties";

    private final PraxisProject project;
    private final FileObject file;
    private final RootProxy root;
    private final Map knownChildren;
    private final Set knownConnections;
    private final ContainerListener containerListener;
    private final ComponentListener infoListener;
    private final SelectionListener selectionListener;

    private final PraxisGraphScene scene;
    private final ExplorerManager manager;
    private final Lookup lookup;

    private final LocationAction location;
    private final Action addAction;
    private final Action goUpAction;
    private final Action deleteAction;
    private final Action sceneCommentAction;
    private final Action exportAction;
    private final Action copyAction;
    private final Action pasteAction;
    private final Action duplicateAction;
    private final Action sharedCodeAction;

    private JComponent panel;
    private ContainerProxy container;

    private final Point activePoint = new Point();
    private PropertyMode propertyMode = PropertyMode.Default;
    private boolean sync;
    private boolean ignoreAttributeChanges;

    public GraphEditor(RootProxy proxy, RootEditor.Context context) {
        this.project = context.project().orElseThrow();
        this.file = context.file().orElseThrow();
        this.root = proxy;
        knownChildren = new LinkedHashMap<>();
        knownConnections = new LinkedHashSet<>();

        scene = new PraxisGraphScene<>(new ConnectProviderImpl(), new MenuProviderImpl());
        scene.setOrthogonalRouting(false);
        manager = context.explorerManager();
        if (root instanceof ContainerProxy) {
            container = (ContainerProxy) root;
        }

        deleteAction = new DeleteAction();
        copyAction = ActionSupport.createCopyAction(this, manager);
        pasteAction = ActionSupport.createPasteAction(this, manager);
        duplicateAction = ActionSupport.createDuplicateAction(this, manager);
        exportAction = ActionSupport.createExportAction(this, manager);
        sharedCodeAction = context.sharedCodeAction().orElse(null);

        var rootNode = root.getNodeDelegate();
        manager.setRootContext(rootNode);
        manager.setExploredContext(rootNode, new Node[]{rootNode});

        lookup = Lookups.fixed(new PositionTransform.CopyExport(this),
                new PositionTransform.ImportPaste(this));

        addAction = org.openide.awt.Actions.forID("PXR", "org.praxislive.ide.pxr.AddChildAction");

        selectionListener = new SelectionListener();
        scene.addObjectSceneListener(selectionListener,
                ObjectSceneEventType.OBJECT_SELECTION_CHANGED);
        manager.addPropertyChangeListener(selectionListener);
        goUpAction = new GoUpAction();
        location = new LocationAction();
        containerListener = new ContainerListener();
        infoListener = new ComponentListener();

        sceneCommentAction = new CommentAction(scene);
        setupSceneActions();
    }

    private ActionMap buildActionMap(ActionMap parent) {
        ActionMap am = new ActionMap();
        am.setParent(parent);
        deleteAction.setEnabled(false);
        am.put("delete", deleteAction);
        am.put(DefaultEditorKit.copyAction, copyAction);
        am.put(DefaultEditorKit.pasteAction, pasteAction);
        am.put(Actions.DUPLICATE_KEY, duplicateAction);
        return am;
    }

    private void setupSceneActions() {
        scene.getActions().addAction(ActionFactory.createAcceptAction(new AcceptProviderImpl()));
        scene.getCommentWidget().getActions().addAction(ActionFactory.createEditAction((Widget widget) -> {
            sceneCommentAction.actionPerformed(new ActionEvent(scene, ActionEvent.ACTION_PERFORMED, "edit"));
        }));
    }

    private JPopupMenu getComponentPopup(NodeWidget widget) {
        List actions = new ArrayList<>();
        Object obj = scene.findObject(widget);
        if (obj instanceof String id) {
            ComponentProxy cmp = container.getChild(id);
            if (cmp instanceof ContainerProxy container) {
                actions.add(new ContainerOpenAction(container));
                actions.add(null);
            }
            if (cmp != null) {
                actions.addAll(Arrays.asList(cmp.getNodeDelegate().getActions(false)));
            }
        }
        actions.add(null);
        actions.add(copyAction);
        actions.add(duplicateAction);
        actions.add(deleteAction);
        actions.add(null);
        actions.add(exportAction);
        actions.add(null);
        actions.add(new CommentAction(widget));
        return Utilities.actionsToPopup(actions.toArray(Action[]::new), getEditorComponent());
    }

    private JPopupMenu getConnectionPopup() {
        return Utilities.actionsToPopup(new Action[]{deleteAction}, getEditorComponent());
    }

    private JPopupMenu getPinPopup(PinWidget widget) {
        PinID pin = (PinID) scene.findObject(widget);
        boolean enabled = (container.getInfo().controls().contains("ports"));
        Action action = new AddPortToParentAction(this, pin);
        action.setEnabled(enabled);
        return Utilities.actionsToPopup(new Action[]{action}, getEditorComponent());
    }

    private JPopupMenu getScenePopup() {
        List actions = new ArrayList<>();
        actions.add(addAction);
        actions.add(pasteAction);
        actions.add(null);
        Action[] containerActions = container.getNodeDelegate().getActions(true);
        if (containerActions.length != 0) {
            actions.addAll(Arrays.asList(containerActions));
            actions.add(null);
        }

        if (sharedCodeAction != null) {
            actions.add(sharedCodeAction);
            actions.add(null);
        }

        actions.add(new PropertyModeAction());
        actions.add(new CommentAction(scene));
        return Utilities.actionsToPopup(actions.toArray(Action[]::new), getEditorComponent());

    }

    PraxisGraphScene getScene() {
        return scene;
    }

    ContainerProxy getContainer() {
        return container;
    }

    Point getActivePoint() {
        return new Point(activePoint);
    }

    void resetActivePoint() {
        // @TODO properly layout added components
        activePoint.x = 100;
        activePoint.y = 100;
    }

    ExplorerManager getExplorerManager() {
        return manager;
    }

    @Override
    public void componentActivated() {
        requestFocus();
    }

    @Override
    public void dispose() {
        manager.removePropertyChangeListener(selectionListener);
        Disposable.dispose(addAction);
        Disposable.dispose(copyAction);
        Disposable.dispose(duplicateAction);
        Disposable.dispose(exportAction);
        Disposable.dispose(pasteAction);
    }

    @Override
    public JComponent getEditorComponent() {
        if (panel == null) {
            JPanel viewPanel = new JPanel(new BorderLayout());
            JComponent sceneView = scene.createView();
            sceneView.addMouseListener(new ActivePointListener());
            JScrollPane scroll = new JScrollPane(
                    sceneView,
                    JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
                    JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
            viewPanel.add(scroll, BorderLayout.CENTER);

            JPanel overlayPanel = new JPanel();
            overlayPanel.setLayout(new GridBagLayout());
            GridBagConstraints gbc = new GridBagConstraints();
            gbc.gridx = 1;
            gbc.gridy = 1;
            gbc.weightx = 1;
            gbc.weighty = 1;
            gbc.insets = new Insets(0, 0, 20, 20);
            gbc.anchor = GridBagConstraints.SOUTHEAST;
            JPanel satellitePanel = new JPanel(new BorderLayout());
            satellitePanel.setBorder(new LineBorder(Color.LIGHT_GRAY, 1));
            satellitePanel.add(scene.createSatelliteView());
            overlayPanel.add(satellitePanel, gbc);
            overlayPanel.setOpaque(false);

            JLayeredPane layered = new JLayeredPane();
            layered.setLayout(new OverlayLayout(layered));
            layered.add(viewPanel, JLayeredPane.DEFAULT_LAYER);
            layered.add(overlayPanel, JLayeredPane.PALETTE_LAYER);

            panel = new JPanel(new BorderLayout());
            panel.addFocusListener(new FocusAdapter() {
                @Override
                public void focusGained(FocusEvent e) {
                    scene.getView().requestFocusInWindow();
                }

            });
            panel.add(layered, BorderLayout.CENTER);

            if (sharedCodeAction != null) {
                JToggleButton sharedCodeButton = new JToggleButton(sharedCodeAction);
                gbc = new GridBagConstraints();
                gbc.gridx = 0;
                gbc.gridy = 1;
                gbc.weightx = 1;
                gbc.weighty = 1;
                gbc.insets = new Insets(0, 20, 20, 0);
                gbc.anchor = GridBagConstraints.SOUTHWEST;
                overlayPanel.add(sharedCodeButton, gbc);
            }

            InputMap im = panel.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
            ActionMap am = buildActionMap(panel.getActionMap());

            im.put(KeyStroke.getKeyStroke("alt shift F"), "format");
            am.put("format", new AbstractAction("format") {
                @Override
                public void actionPerformed(ActionEvent e) {
                    scene.layoutScene();
                }
            });
            im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0, true), "escape");
            am.put("escape", goUpAction);

            panel.setActionMap(am);

            if (container != null) {
                buildScene();
            }
        }
        return panel;
    }

    @Override
    public Lookup getLookup() {
        return lookup;
    }

    @Override
    public List getActions() {
        return List.of(goUpAction, location);
    }

    @Override
    public boolean requestFocus() {
        if (panel == null) {
            return false;
        }
        return scene.getView().requestFocusInWindow();
    }

    @Override
    public Set supportedToolActions() {
        return EnumSet.allOf(ToolAction.class);
    }

    @Override
    public void sync() {
        syncAllAttributes();
    }

    private void clearScene() {
        syncAllAttributes();
        container.removePropertyChangeListener(containerListener);
        Syncable syncable = container.getLookup().lookup(Syncable.class);
        if (syncable != null) {
            syncable.removeKey(this);
        }
        for (Map.Entry child : knownChildren.entrySet()) {
            removeChild(child.getKey(), child.getValue());
        }
        activePoint.setLocation(0, 0);
        knownChildren.clear();
        knownConnections.clear();
        location.address.setText("");
    }

    private void buildScene() {
        container.addPropertyChangeListener(containerListener);
        manager.setExploredContext(container.getNodeDelegate());
        Syncable syncable = container.getLookup().lookup(Syncable.class);
        if (syncable != null) {
            syncable.addKey(this);
        }

        try {
            propertyMode = PropertyMode.valueOf(
                    Utils.getAttr(root, ATTR_GRAPH_PROPERTIES, "Default"));
        } catch (Exception ex) {
            Exceptions.printStackTrace(ex);
            propertyMode = PropertyMode.Default;
            Utils.setAttr(root, ATTR_GRAPH_PROPERTIES, null);
        }

        container.getNodeDelegate().getChildren().getNodes();

        syncGraph(true);

        goUpAction.setEnabled(container.getParent() != null);
        location.address.setText(container.getAddress().toString());

        scene.setComment(Utils.getAttr(container, ATTR_GRAPH_COMMENT));
        scene.validate();
    }

    private void buildChild(String id, final ComponentProxy cmp) {
        String name = cmp instanceof ContainerProxy ? id + "/.." : id;
        NodeWidget widget = scene.addNode(id, name);
        widget.setSchemeColors(Utils.colorsForComponent(cmp).getSchemeColors());
        widget.setToolTipText(cmp.getType().toString());
        configureWidgetFromAttributes(widget, cmp);
        if (cmp instanceof ContainerProxy) {
            ContainerOpenAction containerOpenAction = new ContainerOpenAction((ContainerProxy) cmp);
            widget.getActions().addAction(ActionFactory.createEditAction(w -> {
                AWTEvent current = EventQueue.getCurrentEvent();
                if (current instanceof InputEvent && ((InputEvent) current).isShiftDown()) {
                    cmp.getNodeDelegate().getPreferredAction().actionPerformed(
                            new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "edit", ActionEvent.SHIFT_MASK));
                } else {
                    containerOpenAction.actionPerformed(new ActionEvent(this,
                            ActionEvent.ACTION_PERFORMED,
                            "edit"));
                }
            }));
        } else {
            widget.getActions().addAction(ActionFactory.createEditAction(w -> {
                AWTEvent current = EventQueue.getCurrentEvent();
                int modifiers = (current instanceof InputEvent)
                        ? ((InputEvent) current).getModifiers() : 0;
                cmp.getNodeDelegate().getPreferredAction().actionPerformed(
                        new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "edit", modifiers)
                );
            }));
        }
        final CommentAction commentAction = new CommentAction(widget);
        widget.getCommentWidget().getActions().addAction(ActionFactory.createEditAction(new EditProvider() {

            @Override
            public void edit(Widget widget) {
                commentAction.actionPerformed(new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "edit"));
            }
        }));
        ComponentInfo info = cmp.getInfo();
        for (String portID : info.ports()) {
            PortInfo pi = info.portInfo(portID);
            buildPin(id, cmp, portID, pi);
        }
        cmp.addPropertyChangeListener(infoListener);
        syncConnections();
    }

    private void rebuildChild(String id, ComponentProxy cmp) {
        // remove all connections to this component from known connections list
        Iterator itr = knownConnections.iterator();
        while (itr.hasNext()) {
            Connection con = itr.next();
            if (con.sourceComponent().equals(id) || con.targetComponent().equals(id)) {
                itr.remove();
            }
        }
        // match visual state by removing all pins and edges from graph node
        List> pins = new ArrayList<>(scene.getNodePins(id));
        for (PinID pin : pins) {
            scene.removePinWithEdges(pin);
        }
        ComponentInfo info = cmp.getInfo();
        for (String portID : info.ports()) {
            PortInfo pi = info.portInfo(portID);
            buildPin(id, cmp, portID, pi);
        }
        syncConnections();
    }

    private void removeChild(String id, ComponentProxy cmp) {
        cmp.removePropertyChangeListener(infoListener);
        scene.removeNodeWithEdges(id);
        // @TODO temporary fix for moving dynamic components?
        activePoint.x = 0;
        activePoint.y = 0;
    }

    private void configureWidgetFromAttributes(NodeWidget widget, ComponentProxy cmp) {
        widget.setPreferredLocation(resolveLocation(cmp));
        if ("true".equals(Utils.getAttr(cmp, ATTR_GRAPH_MINIMIZED))) {
            widget.setMinimized(true);
        }
        updateWidgetComment(widget,
                Utils.getAttr(cmp, ATTR_GRAPH_COMMENT, ""),
                cmp instanceof ContainerProxy);
        scene.validate();
    }

    private Point resolveLocation(ComponentProxy cmp) {
        int x = activePoint.x;
        int y = activePoint.y;
        try {
            String xStr = Utils.getAttr(cmp, ATTR_GRAPH_X);
            String yStr = Utils.getAttr(cmp, ATTR_GRAPH_Y);
            if (xStr != null) {
                x = Integer.parseInt(xStr);
            }
            if (yStr != null) {
                y = Integer.parseInt(yStr);
            }
        } catch (Exception ex) {

        }
        // @TODO what to do about importing components without positions?
        // if point not set, check for widget at point?
        return new Point(x, y);
    }

    private void buildPin(String cmpID, ComponentProxy cmp, String pinID, PortInfo info) {
        boolean primary = info.portType().startsWith("Audio")
                || info.portType().startsWith("Video");
        PinWidget pin = scene.addPin(cmpID, pinID, getPinAlignment(info));
        pin.setSchemeColors(Utils.colorsForPortType(info.portType()).getSchemeColors());
        Font font = pin.getFont();
        if (primary) {
            pin.setFont(font.deriveFont(Font.BOLD));
        } else {
//            pin.setFont(font.deriveFont(font.getSize2D() * 0.85f));
        }
        String category = info.properties().getString("category", "");
        if (category.isEmpty()) {
            pin.setToolTipText(pinID + " : " + info.portType());
        } else {
            pin.setToolTipText(pinID + " : " + info.portType() + " : " + category);
        }

        if (propertyMode == PropertyMode.Hide) {
            return;
        }

        ControlInfo control = cmp.getInfo().controls().contains(pinID)
                ? cmp.getInfo().controlInfo(pinID) : null;
        if (control != null && (control.controlType() == ControlInfo.Type.Property
                || control.controlType() == ControlInfo.Type.ReadOnlyProperty)
                && (propertyMode == PropertyMode.Show
                || control.properties().getBoolean("preferred", false))) {
            Node.Property matchingProp = Utils.findMatchingProperty(cmp, pinID);
            if (matchingProp != null) {
                pin.addChild(new PropertyWidget(scene, control, cmp.getNodeDelegate(), matchingProp));
            }
        }

    }

    private Alignment getPinAlignment(PortInfo info) {
        switch (info.direction()) {
            case IN:
                return Alignment.Left;
            case OUT:
                return Alignment.Right;
            default:
                return Alignment.Center;
        }
    }

    private boolean buildConnection(Connection connection) {
        PinID p1 = new PinID<>(connection.sourceComponent(), connection.sourcePort());
        PinID p2 = new PinID<>(connection.targetComponent(), connection.targetPort());
        if (scene.isPin(p1) && scene.isPin(p2)) {
            PinWidget pw1 = (PinWidget) scene.findWidget(p1);
            PinWidget pw2 = (PinWidget) scene.findWidget(p2);
            if (pw1.getAlignment() == Alignment.Left && pw2.getAlignment() == Alignment.Right) {
                EdgeWidget widget = scene.connect(connection.targetComponent(), connection.targetPort(),
                        connection.sourceComponent(), connection.sourcePort());
                widget.setToolTipText(connection.targetComponent() + "!" + connection.targetPort() + " -> "
                        + connection.sourceComponent() + "!" + connection.sourcePort());
            } else {
                EdgeWidget widget = scene.connect(connection.sourceComponent(), connection.sourcePort(),
                        connection.targetComponent(), connection.targetPort());
                widget.setToolTipText(connection.sourceComponent() + "!" + connection.sourcePort() + " -> "
                        + connection.targetComponent() + "!" + connection.targetPort());
            }
            return true;
        } else {
            return false;
        }

    }

    private boolean removeConnection(Connection connection) {
        EdgeID edge = new EdgeID<>(new PinID<>(connection.sourceComponent(), connection.sourcePort()),
                new PinID<>(connection.targetComponent(), connection.targetPort()));
        if (scene.isEdge(edge)) {
            scene.disconnect(connection.sourceComponent(), connection.sourcePort(),
                    connection.targetComponent(), connection.targetPort());
            return true;
        } else {
            return false;
        }

    }

    private void syncChildren(boolean updateSelection) {
        if (container == null) {
            return;
        }
        List ch = container.children().collect(Collectors.toList());
        Set tmp = new LinkedHashSet<>(knownChildren.keySet());
        tmp.removeAll(ch);
        // tmp now contains children that have been removed from model
        for (String id : tmp) {
            ComponentProxy cmp = knownChildren.remove(id);
            removeChild(id, cmp);

        }
        tmp.clear();
        tmp.addAll(ch);
        tmp.removeAll(knownChildren.keySet());
        // tmp now contains children that have been added to model
        for (String id : tmp) {
            ComponentProxy cmp = container.getChild(id);
            if (cmp != null) {
                buildChild(id, cmp);
                knownChildren.put(id, cmp);
            }
        }
        if (updateSelection && !tmp.isEmpty()) {
            scene.userSelectionSuggested(tmp, false);
            scene.setFocusedObject(tmp.iterator().next());
        }
        scene.validate();
    }

    private void syncConnections() {
        if (container == null) {
            return;
        }
        List cons = container.connections().collect(Collectors.toList());
        Set tmp = new LinkedHashSet<>(knownConnections);
        tmp.removeAll(cons);
        // tmp now contains connections that have been removed from model
        for (Connection con : tmp) {
            removeConnection(con);
            knownConnections.remove(con);
        }
        tmp.clear();
        tmp.addAll(cons);
        tmp.removeAll(knownConnections);
        // tmp now contains connections that have been added to model
        for (Connection con : tmp) {
            if (buildConnection(con)) {
                knownConnections.add(con);
            } else {
                // leave for later?
            }

        }
        scene.validate();
    }

    void syncGraph(boolean sync) {
        syncGraph(sync, false);
    }

    void syncGraph(boolean sync, boolean updateSelection) {
        if (sync) {
            this.sync = true;
            syncChildren(updateSelection);
            syncConnections();
        } else {
            this.sync = false;
        }
    }

    private void updateWidgetComment(final NodeWidget widget, final String text, final boolean container) {

        if (!container) {
            widget.setComment(text);
            return;
        }
        // OK, we have a container, trim text
        int delim = text.indexOf("\n\n");
        if (delim >= 0) {
            widget.setComment(text.substring(0, delim) + "...");
        } else {
            widget.setComment(text);
        }
        scene.revalidate();

    }

    private ComponentProxy findComponent(Widget widget) {
        if (widget == scene) {
            return container;
        }
        return findComponent(scene.findObject(widget));
    }

    private ComponentProxy findComponent(Object obj) {
        if (obj instanceof Widget) {
            return findComponent((Widget) obj);
        }
        if (obj instanceof String) {
            return container.getChild(obj.toString());
        }
        return null;
    }

    private void syncAllAttributes() {
        if (container == null) {
            return;
        }
        container.children().map(id -> container.getChild(id))
                .forEach(this::syncAttributes);
    }

    private void syncAttributes(ComponentProxy cmp) {
        ignoreAttributeChanges = true;
        Widget widget = scene.findWidget(cmp.getAddress().componentID());
        if (widget instanceof NodeWidget) {
            NodeWidget nodeWidget = (NodeWidget) widget;
            String x = Integer.toString((int) nodeWidget.getLocation().getX());
            String y = Integer.toString((int) nodeWidget.getLocation().getY());
            if (LOG.isLoggable(Level.FINE)) {
                LOG.log(Level.FINE, "Setting position attributes of {0} to x:{1} y:{2}",
                        new Object[]{cmp.getAddress(), x, y});
            }
            Utils.setAttr(cmp, ATTR_GRAPH_X, x);
            Utils.setAttr(cmp, ATTR_GRAPH_Y, y);
            Utils.setAttr(cmp, ATTR_GRAPH_MINIMIZED,
                    nodeWidget.isMinimized() ? "true" : null);
        }
        ignoreAttributeChanges = false;
    }

    void acceptComponentType(final ComponentType type) {
        NotifyDescriptor.InputLine dlg = new NotifyDescriptor.InputLine(
                "ID:", "Enter an ID for " + type);
        dlg.setInputText(getFreeID(type));
        Object retval = DialogDisplayer.getDefault().notify(dlg);
        if (retval == NotifyDescriptor.OK_OPTION) {
            final String id = dlg.getInputText();
            container.addChild(id, type)
                    .thenRun(() -> syncGraph(true, true))
                    .exceptionally(ex -> {
                        syncGraph(true);
                        DialogDisplayer.getDefault().notifyLater(new NotifyDescriptor.Message("Error creating component", NotifyDescriptor.ERROR_MESSAGE));
                        return null;
                    });

            syncGraph(false);

        }
    }

    void acceptImport(FileObject file) {
        Task task = ActionSupport.createImportTask(this, container, file);
        task.addPropertyChangeListener(e -> {
            if (task.getState() == Task.State.ERROR) {
                List log = task.log();
                String msg;
                if (log.isEmpty()) {
                    msg = "Import error";
                } else {
                    msg = log.stream().collect(Collectors.joining("\n"));
                    DialogDisplayer.getDefault().notifyLater(
                            new NotifyDescriptor.Message(msg, NotifyDescriptor.WARNING_MESSAGE));
                }
            }
        });
        task.execute();
    }

    private String getFreeID(ComponentType type) {
        Set existing = container.children().collect(Collectors.toSet());
        return EditorUtils.findFreeID(existing, EditorUtils.extractBaseID(type), true);

    }

    private class ContainerListener implements PropertyChangeListener {

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if (sync) {
                if (ContainerProtocol.CHILDREN.equals(evt.getPropertyName())) {
                    syncChildren(false);
                } else if (ContainerProtocol.CONNECTIONS.equals(evt.getPropertyName())) {
                    syncConnections();
                } else if (ComponentProtocol.META.equals(evt.getPropertyName())) {
                    String comment = Utils.getAttr(container, ATTR_GRAPH_COMMENT);
                        comment = comment == null ? "" : comment;
                        if (!comment.equals(scene.getComment())) {
                            scene.setComment(comment);
                        }
                }
            }

        }
    }

    private class ComponentListener implements PropertyChangeListener {

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if (ComponentProtocol.INFO.equals(evt.getPropertyName())) {
                Object src = evt.getSource();
                assert src instanceof ComponentProxy;
                if (src instanceof ComponentProxy) {
                    ComponentProxy cmp = (ComponentProxy) src;
                    String id = cmp.getAddress().componentID();
                    rebuildChild(id, cmp);
                }
            } else if (ComponentProtocol.META.equals(evt.getPropertyName())) {
                if (!ignoreAttributeChanges) {
                    Object src = evt.getSource();
                    assert src instanceof ComponentProxy;
                    if (src instanceof ComponentProxy cmp) {
                        Widget w = scene.findWidget(cmp.getAddress().componentID());
                        if (w instanceof NodeWidget node) {
                            configureWidgetFromAttributes(node, cmp);
                        }
                    }
                }
            }

        }

    }

    private class ConnectProviderImpl implements ConnectProvider {

        @Override
        public boolean isSourceWidget(Widget sourceWidget) {
            return sourceWidget instanceof PinWidget;
        }

        @Override
        public ConnectorState isTargetWidget(Widget sourceWidget, Widget targetWidget) {
            if (sourceWidget instanceof PinWidget && targetWidget instanceof PinWidget) {
                return ConnectorState.ACCEPT;
            } else {
                return ConnectorState.REJECT;
            }
        }

        @Override
        public boolean hasCustomTargetWidgetResolver(Scene scene) {
            return false;
        }

        @Override
        public Widget resolveTargetWidget(Scene scene, Point sceneLocation) {
            return null;
        }

        @Override
        public void createConnection(Widget sourceWidget, Widget targetWidget) {
            PinWidget pw1 = (PinWidget) sourceWidget;
            PinWidget pw2 = (PinWidget) targetWidget;
            if (pw1.getAlignment() == Alignment.Left || pw2.getAlignment() == Alignment.Right) {
                PinWidget tmp = pw2;
                pw2 = pw1;
                pw1 = tmp;
            }
            PinID p1 = (PinID) scene.findObject(pw1);
            PinID p2 = (PinID) scene.findObject(pw2);
            container.connect(Connection.of(
                    p1.getParent(),
                    p1.getName(),
                    p2.getParent(),
                    p2.getName())
            );
        }
    }

    private class MenuProviderImpl implements PopupMenuProvider {

        @Override
        public JPopupMenu getPopupMenu(Widget widget, Point localLocation) {
            if (widget instanceof NodeWidget) {
                return getComponentPopup((NodeWidget) widget);
            } else if (widget instanceof EdgeWidget) {
                return getConnectionPopup();
            } else if (widget instanceof PinWidget) {
                return getPinPopup((PinWidget) widget);
            } else if (widget == scene) {
                return getScenePopup();
            } else {
                return null;
            }

        }
    }

    private class ActivePointListener extends MouseAdapter {

        @Override
        public void mouseClicked(MouseEvent e) {
            updateActivePoint(e);
        }

        @Override
        public void mousePressed(MouseEvent e) {
            updateActivePoint(e);
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            updateActivePoint(e);
        }

        private void updateActivePoint(MouseEvent e) {
            activePoint.setLocation(scene.convertViewToScene(e.getPoint()));
            LOG.log(Level.FINEST, "Updated active point : {0}", activePoint);
        }
    }

    private class SelectionListener extends ObjectSceneAdaptor implements PropertyChangeListener {

        private boolean ignoreChanges;

        @Override
        public void selectionChanged(ObjectSceneEvent event, Set previousSelection, Set newSelection) {
            for (Object obj : previousSelection) {
                if (newSelection.contains(obj)) {
                    continue;
                }
                if (obj instanceof String id) {
                    ComponentProxy cmp = container.getChild(id);
                    if (cmp != null) {
                        syncAttributes(cmp);
                    }
                }
            }

            if (ignoreChanges) {
                return;
            }

            try {
                ignoreChanges = true;
                if (newSelection.isEmpty()) {
                    if (container != null) {
                        manager.setSelectedNodes(new Node[]{container.getNodeDelegate()});
                    } else {
                        manager.setSelectedNodes(new Node[]{manager.getRootContext()});
                    }
                    deleteAction.setEnabled(false);
                } else {
                    ArrayList sel = new ArrayList<>();
                    for (Object obj : newSelection) {
                        if (obj instanceof String id) {
                            ComponentProxy cmp = container.getChild(id);
                            if (cmp != null) {
                                sel.add(cmp.getNodeDelegate());
                                syncAttributes(cmp);
                            }
                        }
                    }
                    if (sel.isEmpty()) {
                        manager.setSelectedNodes(new Node[]{manager.getRootContext()});
                    } else {
                        manager.setSelectedNodes(sel.toArray(new Node[sel.size()]));
                    }
                    deleteAction.setEnabled(true);
                }
            } catch (PropertyVetoException ex) {
                LOG.log(Level.FINEST, "Received PropertyVetoException trying to set selected nodes", ex);
            } finally {
                ignoreChanges = false;
            }

        }

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if (ignoreChanges) {
                return;
            }
            try {
                ignoreChanges = true;
                Node context = manager.getExploredContext();
                Node[] selection = manager.getSelectedNodes();
                ContainerProxy container = context.getLookup().lookup(ContainerProxy.class);
                if (GraphEditor.this.container != container) {
                    if (GraphEditor.this.container != null) {
                        clearScene();
                    }
                    GraphEditor.this.container = container;
                    if (container == null) {
                        return;
                    }
                    buildScene();
                }
                Set selectedChildren = Stream.of(selection)
                        .map(n -> n.getLookup().lookup(ComponentProxy.class))
                        .filter(c -> c != null && c != container)
                        .map(c -> c.getID())
                        .filter(id -> id != null)
                        .collect(Collectors.toCollection(LinkedHashSet::new));
                scene.userSelectionSuggested(selectedChildren, false);
                if (!selectedChildren.isEmpty()) {
                    scene.setFocusedObject(selectedChildren.iterator().next());
                } else {
                    scene.setFocusedObject(null);
                }
                deleteAction.setEnabled(!selectedChildren.isEmpty());
            } finally {
                ignoreChanges = false;
            }
        }

    }

    private class DeleteAction extends AbstractAction {

        private DeleteAction() {
            super("Delete");
        }

        @Override
        public void actionPerformed(final ActionEvent e) {
            // GRRR! Built in delete action is asynchronous - replace?
            if (!EventQueue.isDispatchThread()) {
                EventQueue.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        actionPerformed(e);
                    }
                });
                return;
            }
            assert EventQueue.isDispatchThread();
            Set sel = scene.getSelectedObjects();
            if (sel.isEmpty()) {
                return;
            }
            List children = sel.stream()
                    .filter(String.class::isInstance)
                    .map(String.class::cast)
                    .toList();
            List connections = sel.stream()
                    .filter(EdgeID.class::isInstance)
                    .map(o -> {
                        EdgeID edge = (EdgeID) o;
                        PinID p1 = edge.getPin1();
                        PinID p2 = edge.getPin2();
                        return Connection.of(
                                p1.getParent().toString(), p1.getName(),
                                p2.getParent().toString(), p2.getName());
                    })
                    .toList();
            Task.run(ActionSupport.createDeleteTask(GraphEditor.this, container, children, connections));
        }

    }

    private class PropertyModeAction extends AbstractAction implements Presenter.Popup {

        private final PropertyMode mode;

        private PropertyModeAction() {
            this(Bundle.LBL_PropertyModeAction(), null);
        }

        private PropertyModeAction(String text, PropertyMode mode) {
            super(text);
            this.mode = mode;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            if (container == null || mode == null || propertyMode == mode) {
                return;
            }
            clearScene();
            Utils.setAttr(root, ATTR_GRAPH_PROPERTIES, mode.toString());
            buildScene();
        }

        @Override
        public Object getValue(String key) {
            if (Action.SELECTED_KEY.equals(key)) {
                return propertyMode == mode;
            } else {
                return super.getValue(key);
            }
        }

        @Override
        public JMenuItem getPopupPresenter() {
            JMenu submenu = new JMenu(Bundle.LBL_PropertyModeAction());
            submenu.add(new JRadioButtonMenuItem(
                    new PropertyModeAction(
                            Bundle.LBL_PropertyModeDefault(),
                            PropertyMode.Default)));
            submenu.add(new JRadioButtonMenuItem(
                    new PropertyModeAction(
                            Bundle.LBL_PropertyModeShowAll(),
                            PropertyMode.Show)));
            submenu.add(new JRadioButtonMenuItem(
                    new PropertyModeAction(
                            Bundle.LBL_PropertyModeHideAll(),
                            PropertyMode.Hide)));
            return submenu;
        }

    }

    private class CommentAction extends AbstractAction {

        private final Widget widget;

        private CommentAction(Widget widget) {
            super("Comment...");
            this.widget = widget;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    String comment = findInitialText(widget);
                    JTextArea editor = new JTextArea(comment);
                    JPanel panel = new JPanel(new BorderLayout());
                    panel.add(new JScrollPane(editor));
                    panel.setPreferredSize(new Dimension(400, 300));
                    DialogDescriptor dlg = new DialogDescriptor(panel, "Comment");
                    editor.selectAll();
                    editor.requestFocusInWindow();
                    Object result = DialogDisplayer.getDefault().notify(dlg);
                    if (result != NotifyDescriptor.OK_OPTION) {
                        return;
                    }
                    comment = editor.getText();
                    if (widget == scene) {
                        scene.setComment(comment);
                        Utils.setAttr(container, ATTR_GRAPH_COMMENT, comment.isEmpty() ? null : comment);
                    } else if (widget instanceof NodeWidget) {
                        ComponentProxy cmp = findComponent(widget);
                        updateWidgetComment((NodeWidget) widget, comment, cmp instanceof ContainerProxy);
                        Utils.setAttr(cmp, ATTR_GRAPH_COMMENT, comment.isEmpty() ? null : comment);
                        for (Object obj : scene.getSelectedObjects()) {
                            ComponentProxy additional = findComponent(obj);
                            if (additional != null) {
                                NodeWidget n = (NodeWidget) scene.findWidget(obj);
                                if (n != widget) {
                                    updateWidgetComment(n, comment, cmp instanceof ContainerProxy);
                                    Utils.setAttr(additional, ATTR_GRAPH_COMMENT, comment.isEmpty() ? null : comment);
                                }

                            }
                        }
                    }
                    scene.validate();
                }
            };
            EventQueue.invokeLater(runnable);
        }

        private String findInitialText(Widget widget) {
            ComponentProxy cmp = findComponent(widget);
            if (cmp != null) {
                String comment = Utils.getAttr(cmp, ATTR_GRAPH_COMMENT);
                return comment == null ? "" : comment;
            } else {
                return "";
            }
        }

    }

    private class GoUpAction extends AbstractAction {

        private GoUpAction() {
            super("Go Up",
                    ImageUtilities.loadImageIcon(
                            "org/praxislive/ide/pxr/graph/resources/go-up.png",
                            true));
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            ContainerProxy parent = container.getParent();
            if (parent != null) {
                clearScene();
                String childID = container.getAddress().componentID();
                container = parent;
                buildScene();
                scene.setSelectedObjects(Collections.singleton(childID));
                scene.setFocusedObject(childID);
            }
        }
    }

    private class LocationAction extends AbstractAction implements Presenter.Toolbar {

        private final JLabel address = new JLabel();

        @Override
        public void actionPerformed(ActionEvent e) {

        }

        @Override
        public Component getToolbarPresenter() {
            return address;
        }
    }

    private class ContainerOpenAction extends AbstractAction {

        private final ContainerProxy container;

        private ContainerOpenAction(ContainerProxy container) {
            super("Open");
            this.container = container;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            clearScene();
            GraphEditor.this.container = container;
            buildScene();
        }
    }

    private class AcceptProviderImpl implements AcceptProvider {

        @Override
        public ConnectorState isAcceptable(Widget widget, Point point, Transferable transferable) {
            if (extractType(transferable) != null || extractFile(transferable) != null) {
                return ConnectorState.ACCEPT;
            } else {
                return ConnectorState.REJECT;
            }
        }

        @Override
        public void accept(Widget widget, final Point point, Transferable transferable) {
            activePoint.setLocation(point);
            ComponentType type = extractType(transferable);
            if (type != null) {
                EventQueue.invokeLater(() -> acceptComponentType(type));
                return;
            }
            FileObject file = extractFile(transferable);
            if (file != null) {
                EventQueue.invokeLater(() -> acceptImport(file));
            }
        }

        private ComponentType extractType(Transferable transferable) {
            Node n = NodeTransfer.node(transferable, NodeTransfer.DND_COPY_OR_MOVE);
            if (n != null) {
                ComponentType t = n.getLookup().lookup(ComponentType.class);
                if (t != null) {
                    return t;
                }
            }
            return null;
        }

        private FileObject extractFile(Transferable transferable) {
            Node n = NodeTransfer.node(transferable, NodeTransfer.DND_COPY_OR_MOVE);
            if (n != null) {
                FileObject dob = n.getLookup().lookup(FileObject.class);
                if (dob != null) {
                    return dob;
                }
            }
            return null;
        }

    }
}