PluginMenuBuilder.java

package org.hammer.audio.pluginhost;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.WindowConstants;
import org.hammer.audio.plugin.AudioAnalyzerPlugin;
import org.hammer.audio.plugin.MenuContribution;
import org.hammer.audio.plugin.PluginDescriptor;
import org.hammer.audio.plugin.ViewContribution;

/**
 * Builds a Swing <i>Plugins</i> {@link JMenu} from a {@link PluginRegistry}.
 *
 * <p>The host application uses this helper to surface plugin contributions in its menu bar without
 * referencing concrete plugin classes. Each plugin gets its own submenu containing its view
 * contributions (rendered as plain dialogs), its menu contributions (rendered as menu items) and a
 * descriptor/info entry. Failed plugins are listed under a disabled "Failed plugins" submenu.
 */
public final class PluginMenuBuilder {

  private static final Logger LOGGER = Logger.getLogger(PluginMenuBuilder.class.getName());

  private final PluginRegistry registry;

  /** Wrap the given registry for menu construction. */
  public PluginMenuBuilder(PluginRegistry registry) {
    this.registry = Objects.requireNonNull(registry, "registry");
  }

  /**
   * Build a <i>Plugins</i> menu. The menu always exists (even if empty) so the UI consistently
   * exposes a plugin integration surface; an empty placeholder item is shown when no plugins are
   * available.
   *
   * @param parent parent frame for plugin view dialogs (may be {@code null})
   */
  public JMenu buildMenu(Frame parent) {
    JMenu menu = new JMenu("Plugins");
    List<AudioAnalyzerPlugin> plugins = registry.plugins();
    if (plugins.isEmpty() && registry.failures().isEmpty()) {
      JMenuItem none = new JMenuItem("(no plugins installed)");
      none.setEnabled(false);
      menu.add(none);
      return menu;
    }
    for (AudioAnalyzerPlugin plugin : plugins) {
      menu.add(safeBuildPluginSubmenu(parent, plugin));
    }
    if (!registry.failures().isEmpty()) {
      menu.addSeparator();
      JMenu failed = new JMenu("Failed plugins");
      for (PluginLoadResult result : registry.failures()) {
        JMenuItem item = new JMenuItem(result.descriptor().name());
        item.setEnabled(false);
        item.setToolTipText(result.failure().map(Throwable::toString).orElse("unknown error"));
        failed.add(item);
      }
      menu.add(failed);
    }
    return menu;
  }

  private JMenu safeBuildPluginSubmenu(Frame parent, AudioAnalyzerPlugin plugin) {
    try {
      return buildPluginSubmenu(parent, plugin);
    } catch (RuntimeException ex) {
      String label = safePluginLabel(plugin);
      LOGGER.log(Level.WARNING, "Plugin submenu for " + label + " failed to build", ex);
      JMenu fallback = new JMenu(label + " (unavailable)");
      JMenuItem error = new JMenuItem("Plugin failed to initialize");
      error.setEnabled(false);
      error.setToolTipText(ex.toString());
      fallback.add(error);
      return fallback;
    }
  }

  private static String safePluginLabel(AudioAnalyzerPlugin plugin) {
    try {
      PluginDescriptor descriptor = plugin.descriptor();
      if (descriptor != null) {
        return descriptor.name();
      }
    } catch (RuntimeException ignored) {
      // fall through to class-name based label
    }
    return plugin.getClass().getSimpleName();
  }

  private JMenu buildPluginSubmenu(Frame parent, AudioAnalyzerPlugin plugin) {
    PluginDescriptor d = plugin.descriptor();
    String title = d.experimental() ? d.name() + " (experimental)" : d.name();
    JMenu submenu = new JMenu(title);
    for (ViewContribution view : plugin.viewContributions()) {
      submenu.add(buildViewItem(parent, d, view));
    }
    for (MenuContribution menu : plugin.menuContributions()) {
      submenu.add(buildMenuItem(menu));
    }
    JMenuItem info = new JMenuItem("About \u2026");
    info.addActionListener(e -> showDescriptorDialog(parent, d));
    submenu.add(info);
    return submenu;
  }

  private JMenuItem buildViewItem(Frame parent, PluginDescriptor d, ViewContribution view) {
    JMenuItem item = new JMenuItem("Open: " + view.title());
    item.addActionListener(
        e -> {
          try {
            JComponent component = view.componentFactory().get();
            if (component == null) {
              throw new IllegalStateException(
                  "View contribution " + view.id() + " returned null component");
            }
            JDialog dialog = new JDialog(parent, d.name() + " - " + view.title(), false);
            dialog.getContentPane().add(component, BorderLayout.CENTER);
            dialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
            dialog.setSize(new Dimension(640, 420));
            dialog.setLocationRelativeTo(parent);
            dialog.setVisible(true);
          } catch (RuntimeException ex) {
            LOGGER.log(Level.WARNING, "Plugin view " + d.id() + "/" + view.id() + " failed", ex);
            JOptionPane.showMessageDialog(
                parent,
                "Plugin view failed: " + ex.getMessage(),
                d.name(),
                JOptionPane.ERROR_MESSAGE);
          }
        });
    return item;
  }

  private JMenuItem buildMenuItem(MenuContribution contribution) {
    JMenuItem item = new JMenuItem(contribution.label());
    item.addActionListener(
        e ->
            SwingUtilities.invokeLater(
                () -> {
                  try {
                    contribution.action().run();
                  } catch (RuntimeException ex) {
                    LOGGER.log(
                        Level.WARNING, "Plugin menu action " + contribution.id() + " failed", ex);
                  }
                }));
    return item;
  }

  private void showDescriptorDialog(Frame parent, PluginDescriptor d) {
    JTextArea text = new JTextArea();
    text.setEditable(false);
    text.setLineWrap(true);
    text.setWrapStyleWord(true);
    StringBuilder sb = new StringBuilder();
    sb.append("Id: ").append(d.id()).append('\n');
    sb.append("Name: ").append(d.name()).append('\n');
    sb.append("Version: ").append(d.version()).append('\n');
    sb.append("Experimental: ").append(d.experimental()).append('\n');
    if (d.documentationPath() != null) {
      sb.append("Documentation: ").append(d.documentationPath()).append('\n');
    }
    sb.append('\n').append(d.description());
    text.setText(sb.toString());
    JScrollPane scroll = new JScrollPane(text);
    scroll.setPreferredSize(new Dimension(480, 240));
    JDialog dialog = new JDialog(parent, d.name(), false);
    dialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
    dialog.getContentPane().add(new JLabel("Plugin information"), BorderLayout.NORTH);
    dialog.getContentPane().add(scroll, BorderLayout.CENTER);
    dialog.pack();
    dialog.setLocationRelativeTo(parent);
    dialog.setVisible(true);
  }
}