/* * This file is part of ELKI: * Environment for Developing KDD-Applications Supported by Index-Structures * * Copyright (C) 2017 * ELKI Development Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package de.lmu.ifi.dbs.elki.gui.minigui; import java.awt.Dimension; import java.awt.Event; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import javax.swing.AbstractAction; import javax.swing.AbstractListModel; import javax.swing.BoxLayout; import javax.swing.ComboBoxModel; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JFrame; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextField; import javax.swing.KeyStroke; import javax.swing.SwingWorker; import javax.swing.event.TableModelEvent; import javax.swing.event.TableModelListener; import de.lmu.ifi.dbs.elki.KDDTask; import de.lmu.ifi.dbs.elki.application.AbstractApplication; import de.lmu.ifi.dbs.elki.application.KDDCLIApplication; import de.lmu.ifi.dbs.elki.gui.GUIUtil; import de.lmu.ifi.dbs.elki.gui.util.DynamicParameters; import de.lmu.ifi.dbs.elki.gui.util.LogPanel; import de.lmu.ifi.dbs.elki.gui.util.ParameterTable; import de.lmu.ifi.dbs.elki.gui.util.ParametersModel; import de.lmu.ifi.dbs.elki.gui.util.SavedSettingsFile; import de.lmu.ifi.dbs.elki.logging.CLISmartHandler; import de.lmu.ifi.dbs.elki.logging.Logging; import de.lmu.ifi.dbs.elki.logging.LoggingConfiguration; import de.lmu.ifi.dbs.elki.utilities.Alias; import de.lmu.ifi.dbs.elki.utilities.ELKIServiceRegistry; import de.lmu.ifi.dbs.elki.utilities.io.FormatUtil; import de.lmu.ifi.dbs.elki.utilities.optionhandling.ParameterException; import de.lmu.ifi.dbs.elki.utilities.optionhandling.UnspecifiedParameterException; import de.lmu.ifi.dbs.elki.utilities.optionhandling.parameterization.SerializedParameterization; import de.lmu.ifi.dbs.elki.utilities.optionhandling.parameterization.TrackParameters; import de.lmu.ifi.dbs.elki.utilities.pairs.Pair; import de.lmu.ifi.dbs.elki.workflow.LoggingStep; import de.lmu.ifi.dbs.elki.workflow.OutputStep; /** * Minimal GUI built around a table-based parameter editor. * * @author Erich Schubert * @since 0.3 * * @apiviz.composedOf SettingsComboboxModel * @apiviz.composedOf LoggingStep * @apiviz.owns ParameterTable * @apiviz.owns DynamicParameters */ @Alias({ "mini", "minigui" }) public class MiniGUI extends AbstractApplication { /** * Filename for saved settings. */ public static final String SAVED_SETTINGS_FILENAME = "MiniGUI-saved-settings.txt"; /** * Newline used in output. */ public static final String NEWLINE = System.getProperty("line.separator"); /** * ELKI logger for the GUI. */ private static final Logging LOG = Logging.getLogger(MiniGUI.class); /** * Quit action, for mnemonics. */ protected static final String ACTION_QUIT = "quit"; /** * The frame */ JFrame frame; /** * The main panel. */ JPanel panel; /** * Logging output area. */ protected LogPanel outputArea; /** * The parameter table. */ protected ParameterTable parameterTable; /** * Parameter storage. */ protected DynamicParameters parameters; /** * Settings storage. */ protected SavedSettingsFile store = new SavedSettingsFile(SAVED_SETTINGS_FILENAME); /** * Combo box for choosing the application to run. */ protected JComboBox<String> appCombo; /** * Combo box for saved settings. */ protected JComboBox<String> savedCombo; /** * Model to link the combobox with. */ protected SettingsComboboxModel savedSettingsModel; /** * The "run" button. */ protected JButton runButton; /** * Application to configure / run. */ private Class<? extends AbstractApplication> maincls = KDDCLIApplication.class; /** * Command line output field. */ private JTextField commandLine; /** * Prefix for application package. */ private String APP_PREFIX = AbstractApplication.class.getPackage().getName() + "."; /** * Constructor. */ public MiniGUI() { super(); // Create and set up the window. frame = new JFrame("ELKI MiniGUI Command Line Builder"); Dimension screen = java.awt.Toolkit.getDefaultToolkit().getScreenSize(); int ppi = java.awt.Toolkit.getDefaultToolkit().getScreenResolution(); frame.setPreferredSize(new Dimension(Math.min(10 * ppi, screen.width), Math.min(10 * ppi, screen.height - 32))); frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); try { frame.setIconImage(new ImageIcon(KDDTask.class.getResource("elki-icon.png")).getImage()); } catch(Exception e) { // Ignore - icon not found is not fatal. } panel = new JPanel(); panel.setOpaque(true); // content panes must be opaque panel.setLayout(new GridBagLayout()); setupAppChooser(); setupParameterTable(); setupLoadSaveButtons(); setupCommandLine(); setupLoggingArea(); // load saved settings (we wanted to have the logger first!) try { store.load(); savedSettingsModel.update(); } catch(FileNotFoundException e) { // Ignore - probably didn't save any settings yet. } catch(IOException e) { LOG.exception(e); } { KeyStroke key = KeyStroke.getKeyStroke(KeyEvent.VK_Q, Event.CTRL_MASK); panel.getInputMap().put(key, ACTION_QUIT); } { KeyStroke key = KeyStroke.getKeyStroke(KeyEvent.VK_W, Event.CTRL_MASK); panel.getInputMap().put(key, ACTION_QUIT); } panel.getActionMap().put(ACTION_QUIT, new AbstractAction() { /** * Serial version */ private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { frame.dispose(); } }); // Finalize the frame. frame.setContentPane(panel); frame.pack(); } /** * Setup the application chooser. */ private void setupAppChooser() { // Configurator to choose the main application appCombo = new JComboBox<>(); for(Class<?> clz : ELKIServiceRegistry.findAllImplementations(AbstractApplication.class)) { String nam = clz.getCanonicalName(); if(nam == null || clz.getCanonicalName().contains("GUI")) { continue; } if(nam.startsWith(APP_PREFIX)) { nam = nam.substring(APP_PREFIX.length()); } appCombo.addItem(nam); } appCombo.setEditable(true); String sel = maincls.getCanonicalName(); if(sel.startsWith(APP_PREFIX)) { sel = sel.substring(APP_PREFIX.length()); } appCombo.setSelectedItem(sel); appCombo.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { if("comboBoxChanged".equals(e.getActionCommand())) { Class<? extends AbstractApplication> clz = ELKIServiceRegistry.findImplementation(AbstractApplication.class, (String) appCombo.getSelectedItem()); if(clz != null) { maincls = clz; updateParameterTable(); } else { LOG.warning("Main class name not found."); } } } }); GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.BOTH; constraints.gridx = 0; constraints.gridy = 0; constraints.weightx = 1; constraints.weighty = .01; panel.add(appCombo, constraints); } /** * Setup the parameter table */ private void setupParameterTable() { // Setup parameter storage and table model this.parameters = new DynamicParameters(); ParametersModel parameterModel = new ParametersModel(parameters); parameterModel.addTableModelListener(new TableModelListener() { @Override public void tableChanged(TableModelEvent e) { // logger.debug("Change event."); updateParameterTable(); } }); // Create parameter table parameterTable = new ParameterTable(frame, parameterModel, parameters); // Create the scroll pane and add the table to it. JScrollPane scrollPane = new JScrollPane(parameterTable); // Add the scroll pane to this panel. GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.BOTH; constraints.gridx = 0; constraints.gridy = 1; constraints.weightx = 1; constraints.weighty = 1; panel.add(scrollPane, constraints); } /** * Create the load and save buttons. */ private void setupLoadSaveButtons() { // Button panel JPanel buttonPanel = new JPanel(); buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); // Combo box for saved settings savedSettingsModel = new SettingsComboboxModel(store); savedCombo = new JComboBox<>(savedSettingsModel); savedCombo.setEditable(true); savedCombo.setSelectedItem("[Saved Settings]"); buttonPanel.add(savedCombo); // button to load settings JButton loadButton = new JButton("Load"); loadButton.setMnemonic(KeyEvent.VK_L); loadButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { String key = savedSettingsModel.getSelectedItem(); ArrayList<String> settings = store.get(key); if(settings != null) { doSetParameters(settings); } } }); buttonPanel.add(loadButton); // button to save settings JButton saveButton = new JButton("Save"); saveButton.setMnemonic(KeyEvent.VK_S); saveButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { String key = savedSettingsModel.getSelectedItem(); // Stop editing the table. parameterTable.editCellAt(-1, -1); ArrayList<String> list = new ArrayList<>(parameters.size() * 2 + 1); list.add(maincls.getCanonicalName()); parameters.serializeParameters(list); store.put(key, list); try { store.save(); } catch(IOException e1) { LOG.exception(e1); } savedSettingsModel.update(); } }); buttonPanel.add(saveButton); // button to remove saved settings JButton removeButton = new JButton("Remove"); removeButton.setMnemonic(KeyEvent.VK_E); removeButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { String key = savedSettingsModel.getSelectedItem(); store.remove(key); try { store.save(); } catch(IOException e1) { LOG.exception(e1); } savedCombo.setSelectedItem("[Saved Settings]"); savedSettingsModel.update(); } }); buttonPanel.add(removeButton); // button to launch the task runButton = new JButton("Run Task"); runButton.setMnemonic(KeyEvent.VK_R); runButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { startTask(); } }); buttonPanel.add(runButton); GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.HORIZONTAL; constraints.gridx = 0; constraints.gridy = 2; constraints.weightx = 1.0; constraints.weighty = 0.01; panel.add(buttonPanel, constraints); } /** * Setup command line field */ private void setupCommandLine() { // setup text output area commandLine = new JTextField(); commandLine.setEditable(false); // FIXME: Make editable! // Add the output pane to the bottom GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.BOTH; constraints.gridx = 0; constraints.gridy = 3; constraints.weightx = 1; constraints.weighty = .01; panel.add(commandLine, constraints); } /** * Setup logging area */ private void setupLoggingArea() { // setup text output area outputArea = new LogPanel(); // Create the scroll pane and add the table to it. JScrollPane outputPane = new JScrollPane(outputArea); outputPane.setPreferredSize(new Dimension(800, 400)); // Add the output pane to the bottom GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.BOTH; constraints.gridx = 0; constraints.gridy = 4; constraints.weightx = 1; constraints.weighty = 1; panel.add(outputPane, constraints); } /** * Serialize the parameter table and run setParameters(). */ protected void updateParameterTable() { parameterTable.setEnabled(false); ArrayList<String> list = new ArrayList<String>(parameters.size() * 2 + 1); list.add(maincls.getCanonicalName()); parameters.serializeParameters(list); doSetParameters(list); parameterTable.setEnabled(true); } /** * Do the actual setParameters invocation. * * @param params Parameters */ protected void doSetParameters(List<String> params) { if(!params.isEmpty()) { String first = params.get(0); if(!first.startsWith("-")) { Class<? extends AbstractApplication> c = ELKIServiceRegistry.findImplementation(AbstractApplication.class, first); if(c != null) { maincls = c; params.remove(0); } } } SerializedParameterization config = new SerializedParameterization(params); TrackParameters track = new TrackParameters(config); track.tryInstantiate(LoggingStep.class); track.tryInstantiate(maincls); config.logUnusedParameters(); // config.logAndClearReportedErrors(); final boolean hasErrors = (config.getErrors().size() > 0); if(hasErrors && !params.isEmpty()) { reportErrors(config); } runButton.setEnabled(!hasErrors); List<String> remainingParameters = config.getRemainingParameters(); outputArea.clear(); String mainnam = maincls.getCanonicalName(); if(mainnam.startsWith(APP_PREFIX)) { mainnam = mainnam.substring(APP_PREFIX.length()); } commandLine.setText(format(mainnam, params)); // update table: parameterTable.removeEditor(); parameterTable.setEnabled(false); parameters.updateFromTrackParameters(track); // Add remaining parameters if(remainingParameters != null && !remainingParameters.isEmpty()) { DynamicParameters.RemainingOptions remo = new DynamicParameters.RemainingOptions(); try { remo.setValue(FormatUtil.format(remainingParameters, " ")); } catch(ParameterException e) { LOG.exception(e); } int bits = DynamicParameters.BIT_INVALID | DynamicParameters.BIT_SYNTAX_ERROR; parameters.addParameter(remo, remo.getValue(), bits, 0); } config.clearErrors(); parameterTable.revalidate(); parameterTable.setEnabled(true); } /** * Format objects to a command line. * * @param params Parameters to format (Strings, or list of strings) * @return Formatted string */ private String format(Object... params) { StringBuilder buf = new StringBuilder(); for(Object p : params) { if(p instanceof String) { formatTo(buf, (String) p); } else if(p instanceof List) { formatTo(buf, (List<?>) p); } else { LOG.warning("Incorrect object type: " + p.getClass()); } } return buf.toString(); } /** * Format a list of strings to a buffer. * * @param buf Output buffer * @param params List of strings */ private void formatTo(StringBuilder buf, List<?> params) { for(Object p : params) { if(p instanceof String) { formatTo(buf, (String) p); } else { LOG.warning("Incorrect object type: " + p.getClass()); } } } /** * Format a single string for the command line. * * @param buf Output buffer * @param s String */ private void formatTo(StringBuilder buf, String s) { if(s == null || s.length() == 0) { return; } if(buf.length() > 0) { buf.append(' '); } // Test for escaping necessary int escape = 0; for(int i = 0, l = s.length(); i < l; i++) { char c = s.charAt(i); if(c == '\\') { escape |= 8; } else if(c <= ' ' || c >= 128 || c == '<' || c == '>' || c == '|' || c == '$') { escape |= 1; } else if(c == '"') { escape |= 2; } else if(c == '\'') { escape |= 4; } } if(escape == 0) { buf.append(s); // No escaping. } else if((escape & 10) == 0) { buf.append('"').append(s).append('"'); } else if((escape & 12) == 0) { buf.append('\'').append(s).append('\''); } else { // Full escaping. buf.append('"'); for(int i = 0, l = s.length(); i < l; i++) { char c = s.charAt(i); if(c == '"' || c == '\\' || c == '$') { buf.append('\\'); } buf.append(c); } buf.append('"'); } } /** * Auto-load the last task from the history file. */ protected void loadLatest() { int size = store.size(); if(size > 0) { final Pair<String, ArrayList<String>> pair = store.getElementAt(size - 1); savedSettingsModel.setSelectedItem(pair.first); doSetParameters(pair.second); } } /** * Do a full run of the KDDTask with the specified parameters. */ protected void startTask() { parameterTable.editCellAt(-1, -1); parameterTable.setEnabled(false); final ArrayList<String> params = new ArrayList<>(parameters.size() * 2); parameters.serializeParameters(params); parameterTable.setEnabled(true); runButton.setEnabled(false); outputArea.clear(); SwingWorker<Void, Void> r = new SwingWorker<Void, Void>() { @Override public Void doInBackground() { SerializedParameterization config = new SerializedParameterization(params); config.tryInstantiate(LoggingStep.class); AbstractApplication task = config.tryInstantiate(maincls); try { config.logUnusedParameters(); if(config.getErrors().size() == 0) { task.run(); } else { reportErrors(config); } LOG.debug("Task completed successfully."); } catch(Throwable e) { LOG.exception("Task failed", e); } return null; } @Override protected void done() { super.done(); runButton.setEnabled(true); } }; r.execute(); } /** * Report errors in a single error log record. * * @param config Parameterization */ protected void reportErrors(SerializedParameterization config) { StringBuilder buf = new StringBuilder(); buf.append("Task is not completely configured:" + NEWLINE + NEWLINE); for(ParameterException e : config.getErrors()) { if(e instanceof UnspecifiedParameterException) { buf.append("The parameter "); buf.append(((UnspecifiedParameterException) e).getParameterName()); buf.append(" is required.").append(NEWLINE); } else { buf.append(e.getMessage() + NEWLINE); } } LOG.warning(buf.toString()); config.clearErrors(); } @Override public void run() { frame.setVisible(true); outputArea.becomeDefaultLogger(); } /** * Main method that just spawns the UI. * * @param args command line parameters */ public static void main(final String[] args) { // Detect the common problem of an incomplete class path: try { Class<?> clz = Thread.currentThread().getContextClassLoader().loadClass("de.lmu.ifi.dbs.elki.database.ids.DBIDUtil"); clz.getMethod("newHashSet").invoke(null); } catch(ReflectiveOperationException e) { StringBuilder msg = new StringBuilder(); msg.append("Your Java class path is incomplete.\n"); if(e.getCause() != null) { for(Throwable t = e.getCause(); t != null; t = t.getCause()) { msg.append(t.toString()).append("\n"); } } else { msg.append(e.toString()).append("\n"); } msg.append("Make sure you have all the required jars on the classpath.\n"); msg.append("On the home page, you can find a 'elki-bundle' which should include everything."); JOptionPane.showMessageDialog(null, msg, "ClassPath incomplete", JOptionPane.ERROR_MESSAGE); return; } // Detect the broken Ubuntu jAyatana hack; String toolopt = System.getenv("JAVA_TOOL_OPTION"); if(toolopt != null && toolopt.indexOf("jayatana") >= 0) { StringBuilder msg = new StringBuilder(); msg.append("The Ubuntu JAyatana 'global menu support' hack is known to cause problems with many Java applications.\n"); msg.append("Please unset JAVA_TOOL_OPTION."); JOptionPane.showMessageDialog(null, msg, "Incompatible with JAyatana", JOptionPane.ERROR_MESSAGE); return; } GUIUtil.logUncaughtExceptions(LOG); GUIUtil.setLookAndFeel(); OutputStep.setDefaultHandlerVisualizer(); javax.swing.SwingUtilities.invokeLater(new Runnable() { @Override public void run() { try { final MiniGUI gui = new MiniGUI(); gui.run(); List<String> params = Collections.emptyList(); if(args != null && args.length > 0) { params = new ArrayList<>(Arrays.asList(args)); // TODO: it would be nicer to use the Parameterization API for this! if(!params.isEmpty()) { Class<? extends AbstractApplication> c = ELKIServiceRegistry.findImplementation(AbstractApplication.class, params.get(0)); if(c != null) { gui.maincls = c; params.remove(0); // on success } } if(params.remove("-minigui.last")) { javax.swing.SwingUtilities.invokeLater(new Runnable() { @Override public void run() { gui.loadLatest(); } }); } if(params.remove("-minigui.autorun")) { javax.swing.SwingUtilities.invokeLater(new Runnable() { @Override public void run() { gui.startTask(); } }); } } gui.doSetParameters(params); } catch(Exception | Error e) { // Restore error handler, as the GUI is likely broken. LoggingConfiguration.replaceDefaultHandler(new CLISmartHandler()); LOG.exception(e); } } }); } /** * Class to interface between the saved settings list and a JComboBox. * * @author Erich Schubert * * @apiviz.composedOf de.lmu.ifi.dbs.elki.gui.util.SavedSettingsFile */ class SettingsComboboxModel extends AbstractListModel<String> implements ComboBoxModel<String> { /** * Serial version. */ private static final long serialVersionUID = 1L; /** * Settings storage. */ protected SavedSettingsFile store; /** * Selected entry. */ protected String selected = null; /** * Constructor. * * @param store Store to access */ public SettingsComboboxModel(SavedSettingsFile store) { super(); this.store = store; } @Override public String getSelectedItem() { return selected; } @Override public void setSelectedItem(Object anItem) { if(anItem instanceof String) { selected = (String) anItem; } } @Override public String getElementAt(int index) { return store.getElementAt(store.size() - 1 - index).first; } @Override public int getSize() { return store.size(); } /** * Force an update. */ public void update() { fireContentsChanged(this, 0, getSize() + 1); } } }