Merge branch 'JAL-3878_ws-overhaul-3' into mmw/Release_2_12_ws_merge
[jalview.git] / src / jalview / ws2 / gui / WebServicesMenuManager.java
diff --git a/src/jalview/ws2/gui/WebServicesMenuManager.java b/src/jalview/ws2/gui/WebServicesMenuManager.java
new file mode 100644 (file)
index 0000000..2c405ff
--- /dev/null
@@ -0,0 +1,490 @@
+package jalview.ws2.gui;
+
+import java.awt.Color;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.lang.ref.WeakReference;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.concurrent.CompletionStage;
+
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JLabel;
+import javax.swing.JMenu;
+import javax.swing.JMenuItem;
+import javax.swing.ToolTipManager;
+import javax.swing.border.EmptyBorder;
+
+import jalview.gui.AlignFrame;
+import jalview.gui.Desktop;
+import jalview.gui.JvSwingUtils;
+import jalview.gui.WsJobParameters;
+import jalview.util.MessageManager;
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws.params.WsParamSetI;
+import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.annotation.AnnotationAction;
+import jalview.ws2.actions.api.ActionI;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.WebService;
+import jalview.ws2.client.api.WebServiceProviderI;
+
+import static java.lang.String.format;
+import static java.util.Objects.requireNonNullElse;
+
+public class WebServicesMenuManager
+{
+  private final JMenu menu;
+
+  private final AlignFrame frame;
+
+  private JMenuItem inProgressItem = new JMenuItem("Service discovery in progress");
+
+  private JMenuItem noServicesItem = new JMenuItem("No services available");
+  {
+    inProgressItem.setEnabled(false);
+    inProgressItem.setVisible(false);
+    noServicesItem.setEnabled(false);
+  }
+
+  private Map<String, WeakReference<TaskI<?>>> interactiveTasks = new HashMap<>();
+
+  public WebServicesMenuManager(String name, AlignFrame frame)
+  {
+    this.frame = frame;
+    menu = new JMenu(name);
+    menu.add(inProgressItem);
+    menu.add(noServicesItem);
+  }
+
+  public JMenu getMenu()
+  {
+    return menu;
+  }
+
+  public void setNoServices(boolean noServices)
+  {
+    noServicesItem.setVisible(noServices);
+  }
+
+  public void setInProgress(boolean inProgress)
+  {
+    inProgressItem.setVisible(inProgress);
+  }
+
+  public void setServices(WebServiceProviderI services)
+  {
+    menu.removeAll();
+    // services grouped by their category
+    Map<String, List<WebService<?>>> oneshotServices = new HashMap<>();
+    Map<String, List<WebService<?>>> interactiveServices = new HashMap<>();
+    for (WebService<?> service : services.getServices())
+    {
+      var map = service.isInteractive() ? interactiveServices : oneshotServices;
+      map.computeIfAbsent(service.getCategory(), k -> new ArrayList<>())
+          .add(service);
+    }
+    var allKeysSet = new HashSet<>(oneshotServices.keySet());
+    allKeysSet.addAll(interactiveServices.keySet());
+    var allKeys = new ArrayList<>(allKeysSet);
+    allKeys.sort(Comparator.naturalOrder());
+    for (String category : allKeys)
+    {
+      var categoryMenu = new JMenu(category);
+      var oneshot = oneshotServices.get(category);
+      if (oneshot != null)
+        addOneshotEntries(oneshot, categoryMenu);
+      var interactive = interactiveServices.get(category);
+      if (interactive != null)
+      {
+        if (oneshot != null)
+          categoryMenu.addSeparator();
+        addInteractiveEntries(interactive, categoryMenu);
+      }
+      menu.add(categoryMenu);
+    }
+    menu.add(inProgressItem);
+    menu.add(noServicesItem);
+  }
+
+  private void addOneshotEntries(List<WebService<?>> services, JMenu menu)
+  {
+    services.sort(Comparator
+        .<WebService<?>, String> comparing(s -> s.getUrl().toString())
+        .thenComparing(WebService::getName));
+    URL lastHost = null;
+    for (WebService<?> service : services)
+    {
+      // if new host differs from the last one, add entry separating them
+      URL host = service.getUrl();
+      if (!host.equals(lastHost))
+      {
+        if (lastHost != null)
+          menu.addSeparator();
+        var item = new JMenuItem(host.toString());
+        item.setForeground(Color.BLUE);
+        item.addActionListener(e -> Desktop.showUrl(host.toString()));
+        menu.add(item);
+        lastHost = host;
+      }
+      menu.addSeparator();
+      // group actions by their subcategory, sorted
+      var actionsByCategory = new TreeMap<String, List<ActionI<?>>>();
+      for (ActionI<?> action : service.getActions())
+      {
+        actionsByCategory
+            .computeIfAbsent(
+                Objects.requireNonNullElse(action.getSubcategory(), ""),
+                k -> new ArrayList<>())
+            .add(action);
+      }
+      actionsByCategory.forEach((k, v) -> {
+        // create submenu named {subcategory} with {service} or use root menu
+        var atMenu = k.isEmpty() ? menu : new JMenu(String.format("%s with %s", k, service.getName()));
+        if (atMenu != menu)
+          menu.add(atMenu); // add only if submenu
+        // sort actions by name pulling nulls to the front
+        v.sort(Comparator.comparing(
+            ActionI::getName, Comparator.nullsFirst(Comparator.naturalOrder())));
+        for (ActionI<?> action : v)
+        {
+          addEntriesForAction(action, atMenu, atMenu == menu);
+        }
+      });
+    }
+  }
+
+  private void addEntriesForAction(ActionI<?> action, JMenu menu, boolean isTopLevel)
+  {
+    var service = action.getWebService();
+    String itemName;
+    if (isTopLevel)
+    {
+      itemName = service.getName();
+      if (action.getName() != null && !action.getName().isEmpty())
+        itemName += " " + action.getName();
+    }
+    else
+    {
+      if (action.getName() == null || action.getName().isEmpty())
+        itemName = "Run";
+      else
+        itemName = action.getName();
+    }
+    var datastore = service.getParamDatastore();
+    {
+      String text = itemName;
+      if (datastore.hasParameters() || datastore.hasPresets())
+        text += " with defaults";
+      JMenuItem item = new JMenuItem(text);
+      item.addActionListener(e -> {
+        runAction(action, frame.getCurrentView(), Collections.emptyList(),
+            Credentials.empty());
+      });
+      menu.add(item);
+    }
+    if (datastore.hasParameters())
+    {
+      JMenuItem item = new JMenuItem("Edit settings and run...");
+      item.addActionListener(e -> {
+        openEditParamsDialog(datastore, null, null).thenAccept(args -> {
+          if (args != null)
+            runAction(action, frame.getCurrentView(), args, Credentials.empty());
+        });
+      });
+      menu.add(item);
+    }
+    var presets = datastore.getPresets();
+    if (presets != null && presets.size() > 0)
+    {
+      final var presetsMenu = new JMenu(MessageManager.formatMessage(
+          "label.run_with_preset_params", service.getName()));
+      final int dismissDelay = ToolTipManager.sharedInstance()
+          .getDismissDelay();
+      final int QUICK_TOOLTIP = 1500;
+      for (var preset : presets)
+      {
+        var item = new JMenuItem(preset.getName());
+        item.addMouseListener(new MouseAdapter()
+        {
+          @Override
+          public void mouseEntered(MouseEvent evt)
+          {
+            ToolTipManager.sharedInstance().setDismissDelay(QUICK_TOOLTIP);
+          }
+
+          @Override
+          public void mouseExited(MouseEvent evt)
+          {
+            ToolTipManager.sharedInstance().setDismissDelay(dismissDelay);
+          }
+        });
+        String tooltipTitle = MessageManager.getString(
+            preset.isModifiable() ? "label.user_preset" : "label.service_preset");
+        String tooltip = String.format("<strong>%s</strong><br/>%s",
+            tooltipTitle, preset.getDescription());
+        tooltip = JvSwingUtils.wrapTooltip(true, tooltip);
+        item.setToolTipText(tooltip);
+        item.addActionListener(event -> {
+          runAction(action, frame.getCurrentView(), preset.getArguments(),
+              Credentials.empty());
+        });
+        presetsMenu.add(item);
+      }
+      menu.add(presetsMenu);
+    }
+  }
+
+  private void addInteractiveEntries(List<WebService<?>> services, JMenu menu)
+  {
+    Map<String, List<WebService<?>>> byServiceName = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+    for (var service : services)
+    {
+      byServiceName.computeIfAbsent(service.getName(), k -> new ArrayList<>())
+          .add(service);
+    }
+    for (var entry : byServiceName.entrySet())
+    {
+      var group = new InteractiveServiceEntryGroup(entry.getKey(), entry.getValue());
+      group.appendTo(menu);
+    }
+  }
+
+  private class InteractiveServiceEntryGroup
+  {
+    JLabel serviceLabel;
+
+    JMenuItem urlItem = new JMenuItem();
+
+    JCheckBoxMenuItem serviceItem = new JCheckBoxMenuItem();
+
+    JMenuItem editParamsItem = new JMenuItem("Edit parameters...");
+
+    JMenu presetsMenu = new JMenu("Change preset");
+
+    JMenu alternativesMenu = new JMenu("Choose action");
+    {
+      urlItem.setForeground(Color.BLUE);
+      urlItem.setVisible(false);
+      serviceItem.setVisible(false);
+      editParamsItem.setVisible(false);
+      presetsMenu.setVisible(false);
+    }
+
+    InteractiveServiceEntryGroup(String name, List<WebService<?>> services)
+    {
+      serviceLabel = new JLabel(name);
+      serviceLabel.setBorder(new EmptyBorder(0, 6, 0, 6));
+      buildAlternativesMenu(services);
+    }
+
+    private void buildAlternativesMenu(List<WebService<?>> services)
+    {
+      var menu = alternativesMenu;
+      services.sort(Comparator
+          .<WebService<?>, String> comparing(s -> s.getUrl().toString())
+          .thenComparing(s -> s.getName()));
+      URL lastHost = null;
+      for (var service : services)
+      {
+        // Adding url "separator" before each group
+        URL host = service.getUrl();
+        if (!host.equals(lastHost))
+        {
+          if (lastHost != null)
+            menu.addSeparator();
+          var item = new JMenuItem(host.toString());
+          item.setForeground(Color.BLUE);
+          item.addActionListener(e -> Desktop.showUrl(host.toString()));
+          menu.add(item);
+          lastHost = host;
+        }
+        menu.addSeparator();
+        var actionsByCategory = new TreeMap<String, List<ActionI<?>>>();
+        for (ActionI<?> action : service.getActions())
+        {
+          actionsByCategory
+              .computeIfAbsent(
+                  requireNonNullElse(action.getSubcategory(), ""),
+                  k -> new ArrayList<>())
+              .add(action);
+        }
+        actionsByCategory.forEach((key, actions) -> {
+          var atMenu = key.isEmpty() ? menu : new JMenu(key + " with " + service.getName());
+          boolean topLevel = atMenu == menu;
+          if (!topLevel)
+            menu.add(atMenu);
+          actions.sort(Comparator.comparing(
+              a -> a.getName(),
+              Comparator.nullsFirst(Comparator.naturalOrder())));
+          for (ActionI<?> action : actions)
+          {
+            var item = new JMenuItem(action.getFullName());
+            item.addActionListener(e -> setAlternative(action));
+            atMenu.add(item);
+          }
+        });
+      }
+    }
+
+    private void setAlternative(ActionI<?> action)
+    {
+      final var arguments = new ArrayList<ArgumentI>();
+      final WsParamSetI[] lastPreset = { null };
+
+      // update selected url menu item
+      String url = action.getWebService().getUrl().toString();
+      urlItem.setText(url);
+      urlItem.setVisible(true);
+      for (var l : urlItem.getActionListeners())
+        urlItem.removeActionListener(l);
+      urlItem.addActionListener(e -> Desktop.showUrl(url));
+
+      // update selected service menu item
+      serviceItem.setText(action.getFullName());
+      serviceItem.setVisible(true);
+      for (var l : serviceItem.getActionListeners())
+        serviceItem.removeActionListener(l);
+      WebService<?> service = action.getWebService();
+      serviceItem.addActionListener(e -> {
+        if (serviceItem.getState())
+        {
+          cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+              Credentials.empty());
+        }
+        else
+        {
+          cancelInteractive(service.getName());
+        }
+      });
+      serviceItem.setSelected(true);
+
+      // update edit parameters menu item
+      var datastore = service.getParamDatastore();
+      editParamsItem.setVisible(datastore.hasParameters());
+      for (var l : editParamsItem.getActionListeners())
+        editParamsItem.removeActionListener(l);
+      if (datastore.hasParameters())
+      {
+        editParamsItem.addActionListener(e -> {
+          openEditParamsDialog(service.getParamDatastore(), lastPreset[0], arguments)
+              .thenAccept(args -> {
+                if (args != null)
+                {
+                  lastPreset[0] = null;
+                  arguments.clear();
+                  arguments.addAll(args);
+                  cancelAndRunInteractive(action, frame.getCurrentView(),
+                      arguments, Credentials.empty());
+                }
+              });
+        });
+      }
+
+      // update presets menu
+      presetsMenu.removeAll();
+      presetsMenu.setEnabled(datastore.hasPresets());
+      if (datastore.hasPresets())
+      {
+        for (WsParamSetI preset : datastore.getPresets())
+        {
+          var item = new JMenuItem(preset.getName());
+          item.addActionListener(e -> {
+            lastPreset[0] = preset;
+            cancelAndRunInteractive(action, frame.getCurrentView(),
+                preset.getArguments(), Credentials.empty());
+          });
+          presetsMenu.add(item);
+        }
+      }
+
+      cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+          Credentials.empty());
+    }
+
+    void appendTo(JMenu menu)
+    {
+      menu.add(serviceLabel);
+      menu.add(urlItem);
+      menu.add(serviceItem);
+      menu.add(editParamsItem);
+      menu.add(presetsMenu);
+      menu.add(alternativesMenu);
+    }
+  }
+
+  private void cancelInteractive(String wsName)
+  {
+    var taskRef = interactiveTasks.get(wsName);
+    if (taskRef != null && taskRef.get() != null)
+      taskRef.get().cancel();
+    interactiveTasks.put(wsName, null);
+  }
+
+  private void cancelAndRunInteractive(ActionI<?> action,
+      AlignmentViewport viewport, List<ArgumentI> args, Credentials credentials)
+  {
+    var wsName = action.getWebService().getName();
+    cancelInteractive(wsName);
+    var task = runAction(action, viewport, args, credentials);
+    interactiveTasks.put(wsName, new WeakReference<>(task));
+  }
+
+  private TaskI<?> runAction(ActionI<?> action, AlignmentViewport viewport,
+      List<ArgumentI> args, Credentials credentials)
+  {
+    // casting and instance checks can be avoided with some effort,
+    // let them be for now.
+    if (action instanceof AlignmentAction)
+    {
+      // TODO: test if selection contains enough sequences
+      var _action = (AlignmentAction) action;
+      var handler = new AlignmentServiceGuiHandler(_action, frame);
+      return _action.perform(viewport, args, credentials, handler);
+    }
+    if (action instanceof AnnotationAction)
+    {
+      var _action = (AnnotationAction) action;
+      var handler = new AnnotationServiceGuiHandler(_action, frame);
+      return _action.perform(viewport, args, credentials, handler);
+    }
+    throw new IllegalArgumentException(
+        String.format("Illegal action type %s", action.getClass().getName()));
+  }
+
+  private static CompletionStage<List<ArgumentI>> openEditParamsDialog(
+      ParamDatastoreI paramStore, WsParamSetI preset, List<ArgumentI> arguments)
+  {
+    final WsJobParameters jobParams;
+    if (preset == null && arguments != null && arguments.size() > 0)
+      jobParams = new WsJobParameters(paramStore, null, arguments);
+    else
+      jobParams = new WsJobParameters(paramStore, preset, null);
+    if (preset != null)
+      jobParams.setName(MessageManager.getString(
+          "label.adjusting_parameters_for_calculation"));
+    var stage = jobParams.showRunDialog();
+    return stage.thenApply(startJob -> {
+      if (!startJob)
+        return null; // null if cancelled
+      if (jobParams.getPreset() != null)
+        return jobParams.getPreset().getArguments();
+      if (jobParams.isServiceDefaults())
+        return Collections.emptyList();
+      else
+        return jobParams.getJobParams();
+    });
+  }
+}