/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */

/*
  Part of the Processing project - http://processing.org

  Copyright (c) 2012-23 The Processing Foundation
  Copyright (c) 2004-12 Ben Fry and Casey Reas
  Copyright (c) 2001-04 Massachusetts Institute of Technology

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  version 2, as published by the Free Software Foundation.

  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 General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software Foundation,
  Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
*/

package processing.app;

import com.formdev.flatlaf.FlatDarkLaf;
import com.formdev.flatlaf.FlatLaf;
import com.formdev.flatlaf.FlatLightLaf;
import org.jetbrains.annotations.NotNull;
import processing.app.contrib.*;
import processing.app.tools.Tool;
import processing.app.ui.*;
import processing.app.ui.Toolkit;
import processing.core.PApplet;
import processing.data.StringList;

import javax.swing.*;
import javax.swing.tree.DefaultMutableTreeNode;
import java.awt.*;
import java.awt.event.ActionListener;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.List;
import java.util.Map.Entry;

/**
 * The base class for the main processing application.
 * Primary role of this class is for platform identification and
 * general interaction with the system (launching URLs, loading
 * files and images, etc.) that comes from that.
 */
public class Base {
  /**
   * Revision number, used for update checks and contribution compatibility.
   */
  static private final int REVISION = Integer.parseInt(System.getProperty("processing.revision", "1295"));
  /**
   * This might be replaced by main() if there's a lib/version.txt file.
   *
   */
  static private String VERSION_NAME = System.getProperty("processing.version", "1295");

  static final public String SKETCH_BUNDLE_EXT = ".pdez";
  static final public String CONTRIB_BUNDLE_EXT = ".pdex";

  /**
   * True if heavy debugging error/log messages are enabled. Set to true
   * if an empty file named 'debug' is found in the settings folder.
   * See implementation in createAndShowGUI().
   */
  static public boolean DEBUG = Boolean.parseBoolean(System.getenv().getOrDefault("DEBUG", "false"));


  /**
   * is Processing being run from the command line (true) or from the GUI (false)?
   */
  static private boolean commandLine;

  /**
   * If settings.txt is present inside lib, it will be used to override
   * the location of the settings folder so that "portable" versions
   * of the software are possible.
   */
  static private File settingsOverride;

  // A single instance of the preferences window
  PreferencesFrame preferencesFrame;

  // Location for untitled items
  static File untitledFolder;

  /** List of currently active editors. */
  final protected List<Editor> editors =
    Collections.synchronizedList(new ArrayList<>());
  protected Editor activeEditor;

  /** A lone file menu to be used when all sketch windows are closed. */
  protected JMenu defaultFileMenu;

  /**
   * The next Mode to be used with handleNew() or handleOpen()
   * (unless it's overridden by something else). Starts with the last
   * Mode used with the environment, or the default mode if not used.
   */
  private Mode nextMode;

  /** Only one built-in Mode these days, removing the extra fluff. */
  private Mode coreMode;

  // TODO can these be Set objects, or are they expected to be in order?
  private List<ModeContribution> contribModes;
  private List<ExamplesContribution> contribExamples;

  /** These aren't even dynamically loaded, they're hard-wired here. */
  private List<Tool> internalTools;

  // TODO can these be Set objects, or are they expected to be in order?
  private List<ToolContribution> coreTools;
  private List<ToolContribution> contribTools;

  /** Current tally of available updates (used for new Editor windows). */
  private int updatesAvailable = 0;

  // Used by handleOpen(), this saves the chooser to remember the directory.
  // Doesn't appear to be necessary with the AWT native file dialog.
  // https://github.com/processing/processing/pull/2366
  private JFileChooser openChooser;

  static public void main(final String[] args) {
    Messages.log("Starting Processing version" + VERSION_NAME + " revision "+ REVISION);
    EventQueue.invokeLater(() -> {
        run(args);
    });
  }

  /**
   * The main run() method, wrapped in a try/catch to
   * provide a graceful error message if something goes wrong.
   */
  private static void run(String[] args) {
    try {
      createAndShowGUI(args);
    } catch (Throwable t) {
      // Windows Defender has been insisting on destroying each new
      // release by removing core.jar and other files. Yay!
      // https://github.com/processing/processing/issues/5537
      if (Platform.isWindows()) {
        String mess = t.getMessage();
        String missing = null;
        if (mess.contains("Could not initialize class com.sun.jna.Native")) {
          //noinspection SpellCheckingInspection
          missing = "jnidispatch.dll";
        } else if (t instanceof NoClassDefFoundError &&
                   mess.contains("processing/core/PApplet")) {
          // Had to change how this was called
          // https://github.com/processing/processing4/issues/154
          missing = "core.jar";
        }
        if (missing != null) {
          Messages.showError("Necessary files are missing",
                             "A file required by Processing (" + missing + ") is missing.\n\n" +
                             "Make sure that you're not trying to run Processing from inside\n" +
                             "the .zip file you downloaded, and check that Windows Defender\n" +
                             "has not removed files from the Processing folder.\n\n" +
                             "(Defender sometimes flags parts of Processing as malware.\n" +
                             "It is not, but Microsoft has ignored our pleas for help.)", t);
        }
      }
      Messages.showTrace("Unknown Problem",
                         "A serious error happened during startup. Please report:\n" +
                         "http://github.com/processing/processing4/issues/new", t, true);
    }
  }


  static private void createAndShowGUI(String[] args) {
    checkVersion();

    checkPortable();

    Platform.init();
    // call after Platform.init() because we need the settings folder
    Console.startup();

    // Don't put anything above this line that might make GUI,
    // because the platform has to be inited properly first.

    // Make sure a full JDK is installed
    //initRequirements();

    // Load the languages
    Language.init();

    // run static initialization that grabs all the prefs
    Preferences.init();

      PreferencesEvents.onUpdated(Preferences::init);

    // boolean flag indicating whether to create new server instance or not
    boolean createNewInstance = DEBUG || !SingleInstance.alreadyRunning(args);

    // free up resources by terminating the JVM
    if(!createNewInstance){
      System.exit(0);
      return;
    }


    // Set the look and feel before opening the window
    setLookAndFeel();

    // Get the sketchbook path, and make sure it's set properly
    locateSketchbookFolder();

    // Load colors for UI elements. This must happen after Preferences.init()
    // (so that fonts are set) and locateSketchbookFolder() so that a
    // theme.txt file in the user's sketchbook folder is picked up.
    Theme.init();

    // Create a location for untitled sketches
    setupUntitleSketches();

    Messages.log("About to create Base...");
    try {
        final Base base = new Base(args);
        base.updateTheme();
        Messages.log("Base() constructor succeeded");

        // Prevent more than one copy of the PDE from running.
        SingleInstance.startServer(base);

        handleWelcomeScreen(base);
        handleCrustyDisplay();
        handleTempCleaning();

    } catch (Throwable t) {
        // Catch-all to pick up badness during startup.
        Throwable err = t;
        if (t.getCause() != null) {
          // Usually this is the more important piece of information. We'll
          // show this one so that it's not truncated in the error window.
          err = t.getCause();
        }
        Messages.showTrace("We're off on the wrong foot",
                       "An error occurred during startup.", err, true);
    }
    Messages.log("Done creating Base...");
  }

  private static void setupUntitleSketches() {
    try {
        // Users on a shared machine may also share a TEMP folder,
        // which can cause naming collisions; use a UUID as the name
        // for the subfolder to introduce another layer of indirection.
        // https://github.com/processing/processing4/issues/549
        // The UUID also prevents collisions when restarting the
        // software. Otherwise, after using up the a-z naming options
        // it was not possible for users to restart (without manually
        // finding and deleting the TEMP files).
        // https://github.com/processing/processing4/issues/582
        String uuid = UUID.randomUUID().toString();
        untitledFolder = new File(Util.getProcessingTemp(), uuid);

    } catch (IOException e) {
        Messages.showError("Trouble without a name",
                           "Could not create a place to store untitled sketches.\n" +
                           "That's gonna prevent us from continuing.", e);
    }
  }

  private static void setLookAndFeel() {
        try {
            // Use native popups to avoid looking crappy on macOS
            JPopupMenu.setDefaultLightWeightPopupEnabled(false);

            Platform.setLookAndFeel();
            Platform.setInterfaceZoom();
        } catch (Exception e) {
            Messages.err("Error while setting up the interface", e); //$NON-NLS-1$
        }
  }


  public void updateTheme() {
    try {
      FlatLaf laf = "dark".equals(Theme.get("laf.mode")) ?
        new FlatDarkLaf() : new FlatLightLaf();
      laf.setExtraDefaults(Collections.singletonMap("@accentColor",
        Theme.get("laf.accent.color")));
      //UIManager.setLookAndFeel(laf);
      FlatLaf.setup(laf);
      // updateUI() will wipe out our custom components
      // even if we do a setUI() call and invalidate/revalidate/repaint
//      FlatLaf.updateUI();
//      System.out.println("FlatLaf.updateUI() should be done");
//      FlatLaf.updateUILater();

    } catch (Exception e) {
      e.printStackTrace();
    }

    if (preferencesFrame != null) {
      preferencesFrame.updateTheme();
    }

    ContributionManager.updateTheme();
    for (Editor editor : getEditors()) {
      editor.updateTheme();

//      Component sb = editor.getPdeTextArea();
//      sb.invalidate();
//      sb.revalidate();
//      sb.repaint();
    }

    /*
    Window[] windows = Window.getWindows();
    for (Window w : windows) {
      SwingUtilities.updateComponentTreeUI(w);
    }
    */
  }


  static private void handleWelcomeScreen(Base base) {
    // Needs to be shown after the first editor window opens, so that it
    // shows up on top, and doesn't prevent an editor window from opening.
    if (Preferences.getBoolean("welcome.four.show")) {
        PDEWelcomeKt.showWelcomeScreen(base);
    }
  }


  /**
   * Temporary workaround as we try to sort out
   * <a href="https://github.com/processing/processing4/issues/231">231</a>
   * and <a href="https://github.com/processing/processing4/issues/226">226</a>.
   */
  static private void handleCrustyDisplay() {
    /*
    System.out.println("retina is " + Toolkit.isRetina());
    System.out.println("system zoom " + Platform.getSystemZoom());
    System.out.println("java2d param is " + System.getProperty("sun.java2d.uiScale.enabled"));
    System.out.println("toolkit res is " + java.awt.Toolkit.getDefaultToolkit().getScreenResolution());
    */
    if (Platform.isWindows()) {  // only an issue on Windows
      if (!Toolkit.isRetina() && !Splash.getDisableHiDPI()) {
        int res = java.awt.Toolkit.getDefaultToolkit().getScreenResolution();
        if (res % 96 != 0) {
          // fractional dpi scaling on a low-res screen
          System.out.println("If the editor cursor is in the wrong place or the interface is blocky or fuzzy,");
          System.out.println("open Preferences and select the “Disable HiDPI Scaling” option to fix it.");
        }
      }
    }
  }


  static private void handleTempCleaning() {
    new Thread(() -> {
      Console.cleanTempFiles();
      cleanTempFolders();
    }).start();
  }


  /**
   * Clean folders and files from the Processing subdirectory
   * of the user's temp folder (java.io.tmpdir).
   */
  static public void cleanTempFolders() {
    try {
      final File tempDir = Util.getProcessingTemp();
      final int days = Preferences.getInteger("temp.days");

      if (days > 0) {
        final long now = new Date().getTime();
        final long diff = days * 24 * 60 * 60 * 1000L;
        File[] expiredFiles =
          tempDir.listFiles(file -> (now - file.lastModified()) > diff);
        if (expiredFiles != null) {
          // Remove the files approved for deletion
          for (File file : expiredFiles) {
            try {
              // move to trash or delete if unavailable
              Platform.deleteFile(file);
            } catch (IOException e) {
              e.printStackTrace();
            }
          }
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

    /**
     * Check for a version.txt file in the lib folder to override
     */
    private static void checkVersion() {
        File versionFile = Platform.getContentFile("lib/version.txt");
        if (versionFile != null && versionFile.exists()) {
            String[] lines = PApplet.loadStrings(versionFile);
            if (lines != null && lines.length > 0) {
                if (!VERSION_NAME.equals(lines[0])) {
                    VERSION_NAME = lines[0];
                }
            }
        }
    }

    /**
     * Check for portable settings.txt file in the lib folder
     * to override the location of the settings folder.
     */
    static void checkPortable() {
        // Detect settings.txt in the lib folder for portable versions
        File settingsFile = Platform.getContentFile("lib/settings.txt");
        if (settingsFile != null && settingsFile.exists()) {
            try {
                Settings portable = new Settings(settingsFile);
                String path = portable.get("settings.path");
                File folder = new File(path);
                boolean success = true;
                if (!folder.exists()) {
                    success = folder.mkdirs();
                    if (!success) {
                        Messages.err("Could not create " + folder + " to store settings.");
                    }
                }
                if (success) {
                    if (!folder.canRead()) {
                        Messages.err("Cannot read from " + folder);
                    } else if (!folder.canWrite()) {
                        Messages.err("Cannot write to " + folder);
                    } else {
                        settingsOverride = folder.getAbsoluteFile();
                    }
                }
            } catch (IOException e) {
                Messages.err("Error while reading the settings.txt file", e);
            }
        }
    }

  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * @return the current revision number, safe to be used for update checks
   */
  static public int getRevision() {
    return REVISION;
  }


  /**
   * @return something like 2.2.1 or 3.0b4 (or 0213 if it's not a release)
   */
  static public String getVersionName() {
    return VERSION_NAME;
  }



  public static void setCommandLine() {
    commandLine = true;
  }


  static public boolean isCommandLine() {
    return commandLine;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  public Base(String[] args) throws Exception {
    ContributionManager.init(this);

    buildCoreModes();
    rebuildContribModes();
    rebuildContribExamples();
      rebuildToolList();

    // Needs to happen after the sketchbook folder has been located.
    // Also relies on the modes to be loaded, so it knows what can be
    // marked as an example.
    Recent.init(this);

    setupNextMode();

    //contributionManagerFrame = new ContributionManagerDialog();

    // Make sure ThinkDifferent has library examples too
    nextMode.rebuildLibraryList();

    // Put this after loading the examples, so that building the default file
    // menu works on Mac OS X (since it needs examplesFolder to be set).
    Platform.initBase(this);

    // check for updates
    UpdateCheck.doCheck(this);


    ContributionListing cl = ContributionListing.getInstance();
    cl.downloadAvailableList(this, new ContribProgress(null));

    openFilesOrNew(args);

  }

  private void openFilesOrNew(String[] args) {
    // Check if there were previously opened sketches to be restored
    // boolean opened = restoreSketches();
    boolean opened = false;

    // Check if any files were passed in on the command line
    for (int i = 0; i < args.length; i++) {
      Messages.logf("Parsing command line... args[%d] = '%s'", i, args[i]);

      String path = args[i];
      // Fix a problem with systems that use a non-ASCII languages. Paths are
      // being passed in with 8.3 syntax, which makes the sketch loader code
      // unhappy, since the sketch folder naming doesn't match up correctly.
      // https://download.processing.org/bugzilla/1089.html
      if (Platform.isWindows()) {
        try {
          File file = new File(args[i]);
          path = file.getCanonicalPath();
          Messages.logf("Changing %s to canonical %s", i, args[i], path);
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
      if (handleOpen(path) != null) {
        opened = true;
      }
    }

    // Create a new empty window (will be replaced with any files to be opened)
    if (!opened) {
      Messages.log("Calling handleNew() to open a new window");
      handleNew();
    } else {
      Messages.log("No handleNew(), something passed on the command line");
    }
  }

  private void setupNextMode() {
    String lastModeIdentifier = Preferences.get("mode.last"); //$NON-NLS-1$
    if (lastModeIdentifier == null) {
      nextMode = getDefaultMode();
      Messages.log("Nothing set for last.sketch.mode, using default."); //$NON-NLS-1$
    } else {
      for (Mode m : getModeList()) {
        if (m.getIdentifier().equals(lastModeIdentifier)) {
          Messages.logf("Setting next mode to %s.", lastModeIdentifier); //$NON-NLS-1$
          nextMode = m;
        }
      }
      if (nextMode == null) {
        nextMode = getDefaultMode();
        Messages.logf("Could not find mode %s, using default.", lastModeIdentifier); //$NON-NLS-1$
      }
    }
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Limited file menu to be used on OS X when no sketches are open.
   */
  public JMenu initDefaultFileMenu() {
    defaultFileMenu = new JMenu(Language.text("menu.file"));

    JMenuItem item = Toolkit.newJMenuItem(Language.text("menu.file.new"), 'N');
    item.addActionListener(e -> handleNew());
    defaultFileMenu.add(item);

    item = Toolkit.newJMenuItem(Language.text("menu.file.open"), 'O');
    item.addActionListener(e -> handleOpenPrompt());
    defaultFileMenu.add(item);

    item = Toolkit.newJMenuItemShift(Language.text("menu.file.sketchbook"), 'K');
    item.addActionListener(e -> showSketchbookFrame());
    defaultFileMenu.add(item);

    item = Toolkit.newJMenuItemShift(Language.text("menu.file.examples"), 'O');
    item.addActionListener(e -> showExamplesFrame());
    defaultFileMenu.add(item);

    return defaultFileMenu;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Tools require an 'Editor' object when they're instantiated, but
   * the activeEditor will be null when the first Editor that opens is
   * creating its Tools menu. This will temporarily set the activeEditor
   * to the one that's opening so that we don't go all NPE on startup.
   * If there's already an active editor, then this does nothing.
   */
  public void checkFirstEditor(Editor editor) {
    if (activeEditor == null) {
      activeEditor = editor;
    }
  }


  /** Returns the front most, active editor window. */
  public Editor getActiveEditor() {
    return activeEditor;
  }


  /** Get the list of currently active editor windows. */
  public List<Editor> getEditors() {
    return editors;
  }


  /**
   * Called when a window is activated. Because of variations in native
   * windowing systems, no guarantees about changes to the focused and active
   * Windows can be made. Never assume that this Window is the focused or
   * active Window until this Window actually receives a WINDOW_GAINED_FOCUS
   * or WINDOW_ACTIVATED event.
   */
  public void handleActivated(Editor whichEditor) {
    activeEditor = whichEditor;

    // set the current window to be the console that's getting output
    EditorConsole.setEditor(activeEditor);

    // make this the next mode to be loaded
    nextMode = whichEditor.getMode();
    Preferences.set("mode.last", nextMode.getIdentifier()); //$NON-NLS-1$
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  public void refreshContribs(ContributionType ct) {
    if (ct == ContributionType.LIBRARY) {
      for (Mode m : getModeList()) {
        m.rebuildImportMenu();
      }

    } else if (ct == ContributionType.MODE) {
      rebuildContribModes();
      for (Editor editor : editors) {
        editor.rebuildModePopup();
      }

    } else if (ct == ContributionType.TOOL) {
      rebuildToolList();
      for (Editor editor : editors) {
        populateToolsMenu(editor.getToolMenu());
      }

    } else if (ct == ContributionType.EXAMPLES) {
      rebuildContribExamples();
      for (Mode m : getModeList()) {
        m.rebuildExamplesFrame();
      }
    }
  }


  /**
   * Get all the contributed Modes, Libraries, Tools, and Examples.
   * Used by the Contribution Manager to report what's installed while
   * checking for updates and available contributions.
   */
  public Set<Contribution> getInstalledContribs() {
    List<ModeContribution> modeContribs = getContribModes();
    Set<Contribution> contributions = new HashSet<>(modeContribs);

    for (ModeContribution modeContrib : modeContribs) {
      Mode mode = modeContrib.getMode();
      contributions.addAll(mode.contribLibraries);
      contributions.addAll(mode.foundationLibraries);
    }

    contributions.addAll(getContribTools());
    contributions.addAll(getContribExamples());
    return contributions;
  }


  public void tallyUpdatesAvailable() {
    // Significant rewrite from previous version seen in
    // https://github.com/processing/processing4/commit/a2e8cd7
    // Also counting all updates, not just those for the current Mode.
    // (Too confusing otherwise, having different counts.)

    Set<Contribution> installed = getInstalledContribs();
    ContributionListing listing = ContributionListing.getInstance();

    int newCount = 0;
    for (Contribution contrib : installed) {
      if (listing.hasUpdates(contrib)) {
        newCount++;
      }
    }
    updatesAvailable = newCount;

    synchronized (editors) {
      for (Editor editor : editors) {
        editor.setUpdatesAvailable(updatesAvailable);
      }
    }
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  public List<Mode> getModeList() {
    List<Mode> outgoing = new ArrayList<>();
    outgoing.add(coreMode);
    if (contribModes != null) {
      for (ModeContribution contrib : contribModes) {
        outgoing.add(contrib.getMode());
      }
    }
    return outgoing;
  }


  void buildCoreModes() {
    ModeContribution javaModeContrib =
      ModeContribution.load(this, Platform.getContentFile("modes/java"),
                            getDefaultModeIdentifier());
    if (javaModeContrib == null) {
      Messages.showError("Startup Error",
                "Could not load Java Mode, please reinstall Processing.",
                         new Exception("ModeContribution.load() was null"));

    } else {
      // PDE X calls getModeList() while it's loading, so coreModes must be set
      //coreModes = new Mode[] { javaModeContrib.getMode() };
      coreMode = javaModeContrib.getMode();
    }
  }


  public List<ModeContribution> getContribModes() {
    return contribModes;
  }


  /**
   * Instantiates and adds new contributed modes to the contribModes list.
   * Checks for duplicates so the same mode isn't instantiated twice. Does not
   * remove modes because modes can't be removed once they are instantiated.
   */
  void rebuildContribModes() {
    if (contribModes == null) {
      contribModes = new ArrayList<>();
    }
    File modesFolder = getSketchbookModesFolder();
    Map<File, ModeContribution> known = new HashMap<>();
    for (ModeContribution contrib : getContribModes()) {
      known.put(contrib.getFolder(), contrib);
    }
    File[] potential = ContributionType.MODE.listCandidates(modesFolder);
    // If modesFolder does not exist or is inaccessible (folks might like to
    // mess with folders then report it as a bug) 'potential' will be null.
    if (potential != null) {
      for (File folder : potential) {
        if (!known.containsKey(folder)) {
          try {
            contribModes.add(new ModeContribution(this, folder, null));
          } catch (NoSuchMethodError | NoClassDefFoundError ne) {
            System.err.println(folder.getName() + " is not compatible with this version of Processing");
            if (DEBUG) ne.printStackTrace();
          } catch (InvocationTargetException ite) {
            System.err.println(folder.getName() + " could not be loaded and may not compatible with this version of Processing");
            if (DEBUG) ite.printStackTrace();
          } catch (IgnorableException ig) {
            Messages.log(ig.getMessage());
            if (DEBUG) ig.printStackTrace();
          } catch (Throwable e) {
            System.err.println("Could not load Mode from " + folder);
            e.printStackTrace();
          }
        } else {
          known.remove(folder);  // remove this item as already been seen
        }
      }
    }

    // This allows you to build and test a Mode from Eclipse
    // -Dusemode=com.foo.FrobMode:/path/to/FrobMode
    final String useMode = System.getProperty("usemode");
    if (useMode != null) {
      final String[] modeInfo = useMode.split(":", 2);
      final String modeClass = modeInfo[0];
      final String modeResourcePath = modeInfo[1];
      System.out.println("Attempting to load " + modeClass + " with resources at " + modeResourcePath);
      ModeContribution mc = ModeContribution.load(this, new File(modeResourcePath), modeClass);
      contribModes.add(mc);
      File key = getModeContribFile(mc, known);
      if (key != null) {
        known.remove(key);
      }
    }
    if (known.size() != 0) {
      for (ModeContribution mc : known.values()) {
        System.out.println("Extraneous Mode entry: " + mc.getName());
      }
    }
  }


  static private File getModeContribFile(ModeContribution contrib,
                                         Map<File, ModeContribution> known) {
    for (Entry<File, ModeContribution> entry : known.entrySet()) {
      if (entry.getValue() == contrib) {
        return entry.getKey();
      }
    }
    return null;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  public List<ToolContribution> getCoreTools() {
    return coreTools;
  }


  public List<ToolContribution> getContribTools() {
    return contribTools;
  }


  public void rebuildToolList() {
    // Only do these once because the list of internal tools will never change
    if (internalTools == null) {
      internalTools = new ArrayList<>();

      initInternalTool(processing.app.tools.Archiver.class);
      initInternalTool(processing.app.tools.ColorSelector.class);
      initInternalTool(processing.app.tools.CreateFont.class);

      if (Platform.isMacOS()) {
        initInternalTool(processing.app.tools.InstallCommander.class);
      }

      initInternalTool(processing.app.tools.ThemeSelector.class);
    }

    // Only init() these the first time they're loaded
    if (coreTools == null) {
      coreTools = ToolContribution.loadAll(Base.getToolsFolder());
      for (Tool tool : coreTools) {
        tool.init(this);
      }
    }

    // Reset the contributed tools and re-init() all of them.
    contribTools = ToolContribution.loadAll(Base.getSketchbookToolsFolder());
    for (Tool tool : contribTools) {
      try {
        tool.init(this);

        // With the exceptions, we can't call statusError because the window
        // isn't completely set up yet. Also not gonna pop up a warning because
        // people may still be running different versions of Processing.

      } catch (VerifyError | AbstractMethodError ve) {
        System.err.println("\"" + tool.getMenuTitle() + "\" is not " +
                           "compatible with this version of Processing");
        Messages.err("Incompatible Tool found during tool.init()", ve);

      } catch (NoSuchMethodError nsme) {
        System.err.println("\"" + tool.getMenuTitle() + "\" is not " +
                           "compatible with this version of Processing");
        System.err.println("The " + nsme.getMessage() + " method no longer exists.");
        Messages.err("Incompatible Tool found during tool.init()", nsme);

      } catch (NoClassDefFoundError ncdfe) {
        System.err.println("\"" + tool.getMenuTitle() + "\" is not " +
                           "compatible with this version of Processing");
        System.err.println("The " + ncdfe.getMessage() + " class is no longer available.");
        Messages.err("Incompatible Tool found during tool.init()", ncdfe);

      } catch (Error | Exception e) {
        System.err.println("An error occurred inside \"" + tool.getMenuTitle() + "\"");
        e.printStackTrace();
      }
    }
  }


  protected void initInternalTool(Class<?> toolClass) {
    try {
      final Tool tool = (Tool)
        toolClass.getDeclaredConstructor().newInstance();

      tool.init(this);
      internalTools.add(tool);

    } catch (Exception e) {
      e.printStackTrace();
    }
  }


  public void clearToolMenus() {
    for (Editor ed : editors) {
      ed.clearToolMenu();
    }
  }


  public void populateToolsMenu(JMenu toolsMenu) {
    // If this is the first run, need to build out the lists
    if (internalTools == null) {
      rebuildToolList();
    }

    toolsMenu.removeAll();
    for (Tool tool : internalTools) {
      toolsMenu.add(createToolItem(tool));
    }
    toolsMenu.addSeparator();

    if (!coreTools.isEmpty()) {
      for (Tool tool : coreTools) {
        toolsMenu.add(createToolItem(tool));
      }
      toolsMenu.addSeparator();
    }

    if (!contribTools.isEmpty()) {
      for (Tool tool : contribTools) {
        toolsMenu.add(createToolItem(tool));
      }
      toolsMenu.addSeparator();
    }

    JMenuItem manageTools =
      new JMenuItem(Language.text("menu.tools.manage_tools"));
    manageTools.addActionListener(e -> ContributionManager.openTools());
    toolsMenu.add(manageTools);
  }


  JMenuItem createToolItem(final Tool tool) { //, Map<String, JMenuItem> toolItems) {
    String title = tool.getMenuTitle();
    final JMenuItem item = new JMenuItem(title);
    item.addActionListener(e -> {
      try {
        tool.run();

      } catch (NoSuchMethodError | NoClassDefFoundError ne) {
        Messages.showWarning("Tool out of date",
                             tool.getMenuTitle() + " is not compatible with this version of Processing.\n" +
                             "Try updating the Mode or contact its author for a new version.", ne);
        Messages.err("Incompatible tool found during tool.run()", ne);
        item.setEnabled(false);

      } catch (Exception ex) {
        activeEditor.statusError("An error occurred inside \"" + tool.getMenuTitle() + "\"");
        ex.printStackTrace();
        item.setEnabled(false);
      }
    });
    //toolItems.put(title, item);
    return item;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  void rebuildContribExamples() {
    contribExamples =
      ExamplesContribution.loadAll(getSketchbookExamplesFolder());
  }


  public List<ExamplesContribution> getContribExamples() {
    return contribExamples;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  String getDefaultModeIdentifier() {
    // Used to initialize coreModes, so cannot use coreModes[0].getIdentifier()
    return "processing.mode.java.JavaMode";
  }


  public Mode getDefaultMode() {
    //return coreModes[0];
    return coreMode;
  }


  /**
   * @return true if mode is changed within this window (false if new window)
   */
  public boolean changeMode(Mode mode) {
    Mode oldMode = activeEditor.getMode();
    if (oldMode != mode) {
      Sketch sketch = activeEditor.getSketch();
      nextMode = mode;

      if (sketch.isModified()) {
        // If sketch is modified, simpler to just open a new window.
        // https://github.com/processing/processing4/issues/189
        handleNew();
        return false;

      } else if (sketch.isUntitled()) {
        // The current sketch is empty, just close and start fresh.
        // (Otherwise the editor would lose its 'untitled' status.)
        // Safe to do here because of the 'modified' check above.
        handleClose(activeEditor, true);
        handleNew();

      } else {
        // If the current sketch contains file extensions that the new Mode
        // can handle, then write a sketch.properties file with that Mode
        // specified, and reopen. Currently, only used for Java <-> Android.
        if (mode.canEdit(sketch)) {
          //final File props = new File(sketch.getFolder(), "sketch.properties");
          //saveModeSettings(props, nextMode);
          sketch.updateModeProperties(nextMode, getDefaultMode());
          handleClose(activeEditor, true);
          Editor editor = handleOpen(sketch.getMainPath());
          if (editor == null) {
            // the Mode change failed (probably code that's out of date)
            // re-open the sketch using the mode we were in before
            //saveModeSettings(props, oldMode);
            sketch.updateModeProperties(oldMode, getDefaultMode());
            handleOpen(sketch.getMainPath());
            return false;
          }
        } else {
          handleNew();  // create a new window with the new Mode
          return false;
        }
      }
    }
    // Against all (or at least most) odds, we were able to reassign the Mode
    return true;
  }


  protected Mode findMode(String id) {
    for (Mode mode : getModeList()) {
      if (mode.getIdentifier().equals(id)) {
        return mode;
      }
    }
    return null;
  }


  /**
   * Called when a Mode is uninstalled, in case it's the current Mode.
   */
  public void modeRemoved(Mode mode) {
    if (nextMode == mode) {
      nextMode = getDefaultMode();
    }
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Create a new untitled document in a new sketch window.
   */
  public void handleNew() {
//    long t1 = System.currentTimeMillis();
    try {
      // In 0126, untitled sketches will begin in the temp folder,
      // and then moved to a new location because Save will default to Save As.
      //File sketchbookDir = getSketchbookFolder();
      File newbieDir = SketchName.nextFolder(untitledFolder);

      // User was told to go outside or other problem happened inside naming.
      if (newbieDir == null) return;

      // Make the directory for the new sketch
      if (!newbieDir.mkdirs()) {
        throw new IOException("Could not create directory " + newbieDir);
      }

      // Retrieve the sketch name from the folder name (not a great
      // assumption for the future, but overkill to do otherwise for now.)
      String newbieName = newbieDir.getName();

      // Add any template files from the Mode itself
      File newbieFile = nextMode.addTemplateFiles(newbieDir, newbieName);

      // Create sketch properties file if it's not the default mode.
      if (!nextMode.equals(getDefaultMode())) {
        Sketch.updateModeProperties(newbieDir, nextMode, getDefaultMode());
      }

      String path = newbieFile.getAbsolutePath();
      handleOpenUntitled(path);

    } catch (IOException e) {
      Messages.showTrace("That's new to me",
                         "A strange and unexplainable error occurred\n" +
                         "while trying to create a new sketch.", e, false);
    }
  }


  /**
   * Prompt for a sketch to open, and open it in a new window.
   */
  public void handleOpenPrompt() {
    final StringList extensions = new StringList();

    // Add support for pdez files
    extensions.append(SKETCH_BUNDLE_EXT);

    // Add the extensions for each installed Mode
    for (Mode mode : getModeList()) {
      extensions.append(mode.getDefaultExtension());
      // not adding aux extensions b/c we're looking for the main
    }

    final String prompt = Language.text("open");

    if (Preferences.getBoolean("chooser.files.native")) {  //$NON-NLS-1$
      // use the front-most window frame for placing file dialog
      FileDialog openDialog =
        new FileDialog(activeEditor, prompt, FileDialog.LOAD);

      // Only show .pde files as eligible bachelors
      openDialog.setFilenameFilter((dir, name) -> {
        // confirmed to be working properly [fry 110128]
        for (String ext : extensions) {
          if (name.toLowerCase().endsWith("." + ext)) { //$NON-NLS-1$
            return true;
          }
        }
        return false;
      });

      openDialog.setVisible(true);

      String directory = openDialog.getDirectory();
      String filename = openDialog.getFile();
      if (filename != null) {
        File inputFile = new File(directory, filename);
        handleOpen(inputFile.getAbsolutePath());
      }

    } else {
      if (openChooser == null) {
        openChooser = new JFileChooser();
        openChooser.setDialogTitle(prompt);
      }

      openChooser.setFileFilter(new javax.swing.filechooser.FileFilter() {
        public boolean accept(File file) {
          // JFileChooser requires you to explicitly say yes to directories
          // as well (unlike the AWT chooser). Useful, but... different.
          // https://github.com/processing/processing/issues/1189
          if (file.isDirectory()) {
            return true;
          }
          for (String ext : extensions) {
            if (file.getName().toLowerCase().endsWith("." + ext)) { //$NON-NLS-1$
              return true;
            }
          }
          return false;
        }

        public String getDescription() {
          return "Processing Sketch";
        }
      });
      if (openChooser.showOpenDialog(activeEditor) == JFileChooser.APPROVE_OPTION) {
        handleOpen(openChooser.getSelectedFile().getAbsolutePath());
      }
    }
  }


  private Editor openSketchBundle(String path) {
    File zipFile = new File(path);
    try {
      untitledFolder.mkdirs();
      File destFolder = File.createTempFile("zip", "tmp", untitledFolder);
      if (!destFolder.delete() || !destFolder.mkdirs()) {
        // Hard to imagine why this would happen, but...
        System.err.println("Could not create temporary folder " + destFolder);
        return null;
      }
      Util.unzip(zipFile, destFolder);
      File[] fileList = destFolder.listFiles(File::isDirectory);
      if (fileList != null) {
        if (fileList.length == 1) {
          File sketchFile = Sketch.findMain(fileList[0], getModeList());
          if (sketchFile != null) {
            return handleOpenUntitled(sketchFile.getAbsolutePath());
          }
        } else {
          System.err.println("Expecting one folder inside " +
            SKETCH_BUNDLE_EXT + " file, found " + fileList.length + ".");
        }
      } else {
        System.err.println("Could not read " + destFolder);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
    return null;  // no luck
  }


  private void openContribBundle(String path) {
    EventQueue.invokeLater(() -> {
      Editor editor = getActiveEditor();
      if (editor == null) {
        // Shouldn't really happen, but if it's still null, it's a no-go
        Messages.showWarning("Failure is the only option",
            "Please open an Editor window before installing an extension.");
      } else {
        File contribFile = new File(path);
        String baseName = contribFile.getName();
        baseName = baseName.substring(0, baseName.length() - CONTRIB_BUNDLE_EXT.length());
        int result =
          Messages.showYesNoQuestion(editor, "How to Handle " + CONTRIB_BUNDLE_EXT,
          "Install " + baseName + "?",
          "Libraries, Modes, and Tools should<br>" +
          "only be installed from trusted sources.");

        if (result == JOptionPane.YES_OPTION) {
          editor.statusNotice("Installing " + baseName + "...");
          editor.startIndeterminate();

          new Thread(() -> {
            try {
              // do the work of the actual install
              LocalContribution contrib =
                AvailableContribution.install(this, new File(path));

              EventQueue.invokeLater(() -> {
                editor.stopIndeterminate();

                if (contrib != null) {
                  editor.statusEmpty();
                } else {
                  editor.statusError("Could not install " + path);
                }
              });
            } catch (IOException e) {
              EventQueue.invokeLater(() ->
                  Messages.showWarning("Exception During Installation",
                      "Could not install contrib from " + path, e));
            }
          }).start();
        }
      }
    });
  }


  /**
   * Return true if it's an obvious sketch folder: only .pde files,
   * and maybe a data folder. Dot files (.DS_Store, ._blah) are ignored.
   */
  private boolean smellsLikeSketchFolder(File folder) {
    File[] files = folder.listFiles();
    if (files == null) {  // unreadable, assume badness
      return false;
    }
    for (File file : files) {
      String name = file.getName();
      if (!(name.startsWith(".") ||
            name.toLowerCase().endsWith(".pde")) ||
            (file.isDirectory() && name.equals("data"))) {
        return false;
      }
    }
    return true;
  }


  private File moveLikeSketchFolder(File pdeFile, String baseName) throws IOException {
    Object[] options = {
      "Keep", "Move", "Cancel"
    };
    String prompt =
      "Would you like to keep “" + pdeFile.getParentFile().getName() + "” as the sketch folder,\n" +
      "or move “" + pdeFile.getName() + "” to its own folder?\n" +
      "(Usually, “" + pdeFile.getName() + "” would be stored inside a\n" +
      "sketch folder named “" + baseName + "”.)";

    int result = JOptionPane.showOptionDialog(null,
      prompt,
      "Keep it? Move it?",
      JOptionPane.YES_NO_CANCEL_OPTION,
      JOptionPane.QUESTION_MESSAGE,
      null,
      options,
      options[0]);

    if (result == JOptionPane.YES_OPTION) {  // keep
      return pdeFile;

    } else if (result == JOptionPane.NO_OPTION) {  // move
      // create properly named folder
      File properFolder = new File(pdeFile.getParent(), baseName);
      if (properFolder.exists()) {
        throw new IOException("A folder named \"" + baseName + "\" " +
          "already exists. Cannot open sketch.");
      }
      if (!properFolder.mkdirs()) {
        throw new IOException("Could not create the sketch folder.");
      }
      // copy the sketch inside
      File properPdeFile = new File(properFolder, pdeFile.getName());
      Util.copyFile(pdeFile, properPdeFile);

      // remove the original file, so user doesn't get confused
      if (!pdeFile.delete()) {
        Messages.err("Could not delete " + pdeFile);
      }

      // update with the new path
      return properPdeFile;
    }

    // Catch all other cases, including Cancel or ESC
    return null;
  }


  /**
   * Handler for pde:// protocol URIs
   * @param schemeUri the full URI, including pde://
   */
  public Editor handleScheme(String schemeUri) {
    var result = Schema.handleSchema(schemeUri, this);
    if (result != null) {
      return result;
    }

    String location = schemeUri.substring(6);
    if (location.length() > 0) {
      // if it leads with a slash, then it's a file url
      if (location.charAt(0) == '/') {
        File file = new File(location);
        if (file.exists()) {
          handleOpen(location);  // it's a full path to a file
        } else {
          System.err.println(file + " does not exist.");
        }
      } else {
        // turn it into an https url
        final String url = "https://" + location;
        if (location.toLowerCase().endsWith(".pdez") ||
          location.toLowerCase().endsWith(".pdex")) {
          String extension = location.substring(location.length() - 5);
          try {
            File tempFile = File.createTempFile("scheme", extension);
            if (PApplet.saveStream(tempFile, Util.createInput(url))) {
              return handleOpen(tempFile.getAbsolutePath());
            } else {
              System.err.println("Could not open " + tempFile);
            }
          } catch (IOException e) {
            e.printStackTrace();
          }
        }
      }
    }
    return null;
  }


  /**
   * Open a sketch from the path specified. Do not use for untitled sketches.
   * Note that the user may have selected/double-clicked any .pde in a sketch.
   */
  public Editor handleOpen(String path) {
    if (path.startsWith("pde://")) {
      return handleScheme(path);
    }

    if (path.endsWith(SKETCH_BUNDLE_EXT)) {
      return openSketchBundle(path);

    } else if (path.endsWith(CONTRIB_BUNDLE_EXT)) {
      openContribBundle(path);
      return null;  // never returning an Editor for a contrib
    }

    File pdeFile = new File(path);
    if (!pdeFile.exists()) {
      System.err.println(path + " does not exist");
      return null;
    }

    // Cycle through open windows to make sure that it's not already open.
    for (Editor editor : editors) {
      // User may have double-clicked any PDE in the sketch folder,
      // so we have to check each open tab (not just the main one).
      // https://github.com/processing/processing/issues/2506
      for (SketchCode tab : editor.getSketch().getCode()) {
        if (tab.getFile().equals(pdeFile)) {
          editor.toFront();
          // move back to the top of the recent list
          Recent.append(editor);
          return editor;
        }
      }
    }

    File parentFolder = pdeFile.getParentFile();

    try {
      // read the sketch.properties file or get an empty Settings object
      Settings props = Sketch.loadProperties(parentFolder);
      if (!props.isEmpty()) {
        // First check for the Mode, because it may not even be available
        String modeIdentifier = props.get("mode.id");
        if (modeIdentifier != null) {
          if (modeIdentifier.equals("galsasson.mode.tweak.TweakMode")) {
            // Tweak Mode has been built into Processing since 2015,
            // but there are some old sketch.properties files out there.
            // https://github.com/processing/processing4/issues/415
            nextMode = getDefaultMode();

            // Clean up sketch.properties and re-save or remove if necessary
            props.remove("mode");
            props.remove("mode.id");
            props.reckon();

          } else {
            // sketch.properties specifies a Mode, see if it's available
            Mode mode = findMode(modeIdentifier);
            if (mode != null) {
              nextMode = mode;
            } else {
              ContributionManager.openModes();
              Messages.showWarning("Missing Mode",
                "You must first install " + props.get("mode") + " Mode to use this sketch.");
              return null;
            }
          }
        }

        String main = props.get("main");
        if (main != null) {
          // this may be exactly what was passed, but override anyway
          String mainPath = new File(parentFolder, main).getAbsolutePath();
          if (!path.equals(mainPath)) {
            // for now, post a warning if the main was different
            System.out.println(path + " selected, but main is " + mainPath);
          }
          return handleOpenInternal(mainPath, false);
        }
      } else {
        // Switch back to defaultMode, because a sketch.properties
        // file is required whenever not using the default Mode.
        // (Unless being called from, say, the Examples frame, which
        // uses a version of this function that takes a Mode object.)
        nextMode = getDefaultMode();
      }

      // Do some checks to make sure the file can be opened, and identify the
      // Mode that it's using. (In 4.0b8, this became the fall-through case.)

      if (!Sketch.isSanitaryName(pdeFile.getName())) {
        Messages.showWarning("You're tricky, but not tricky enough",
          pdeFile.getName() + " is not a valid name for sketch code.\n" +
          "Better to stick to ASCII, no spaces, and make sure\n" +
          "it doesn't start with a number.", null);
        return null;
      }

      // Check if the name of the file matches the parent folder name.
      String baseName = pdeFile.getName();
      int dot = baseName.lastIndexOf('.');
      if (dot == -1) {
        // Shouldn't really be possible, right?
        System.err.println(pdeFile + " does not have an extension.");
        return null;
      }
      baseName = baseName.substring(0, dot);
      if (!baseName.equals(parentFolder.getName())) {
        // Parent folder name does not match, and because sketch.properties
        // did not exist or did not specify it above, need to determine main.

        // Check whether another .pde file has a matching name, and if so,
        // switch to using that instead. Handles when a user selects a .pde
        // file in the open dialog box that isn't the main tab.
        // (Also important to use nextMode here, because the Mode
        // may be set by sketch.properties when it's loaded above.)
        String filename =
          parentFolder.getName() + "." + nextMode.getDefaultExtension();
        File mainFile = new File(parentFolder, filename);
        if (mainFile.exists()) {
          // User was opening the wrong file in a legit sketch folder.
          pdeFile = mainFile;

        } else if (smellsLikeSketchFolder(parentFolder)) {
          // Looks like a sketch folder, set this as the main.
          props.set("main", pdeFile.getName());
          // Save for later use, then fall through.
          props.save();

        } else {
          // If it's not an obvious sketch folder (just .pde files,
          // maybe a data folder) prompt the user whether to
          // 1) move sketch into its own folder, or
          // 2) call this the main, and write sketch.properties.
          File newFile = moveLikeSketchFolder(pdeFile, baseName);

          if (newFile == pdeFile) {
            // User wanted to keep this sketch folder, so update the
            // property for the main tab and write sketch.properties.
            props.set("main", newFile.getName());
            props.save();

          } else if (newFile == null) {
            // User canceled, so exit handleOpen()
            return null;

          } else {
            // User asked to move the sketch file
            pdeFile = newFile;
          }
        }
      }

      // TODO Remove this selector? Seems too messy/precious. [fry 220205]
      //      Opting to remove in beta 7, because sketches that use another
      //      Mode should have a working sketch.properties. [fry 220302]
      /*
      // If the current Mode cannot open this file, try to find another.
      if (!nextMode.canEdit(pdeFile)) {

        final Mode mode = promptForMode(pdeFile);
        if (mode == null) {
          return null;
        }
        nextMode = mode;
      }
      */
      return handleOpenInternal(pdeFile.getAbsolutePath(), false);

    } catch (IOException e) {
      Messages.showWarning("sketch.properties",
        "Error while reading sketch.properties from\n" + parentFolder, e);
      return null;
    }
  }


  /**
   * Open a (vetted) sketch location using a particular Mode. Used by the
   * Examples window, because Modes like Python and Android do not have
   * "sketch.properties" files in each example folder.
   */
  public Editor handleOpenExample(String path, Mode mode) {
    nextMode = mode;
    return handleOpenInternal(path, true);
  }


  /**
   * Open the sketch associated with this .pde file in a new window
   * as an "Untitled" sketch.
   * @param path Path to the pde file for the sketch in question
   * @return the Editor object, so that properties (like 'untitled')
   *         can be set by the caller
   */
  protected Editor handleOpenUntitled(String path) {
    return handleOpenInternal(path, true);
  }


  /**
   * Internal function to actually open the sketch. At this point, the
   * sketch file/folder must have been vetted, and nextMode set properly.
   */
  protected Editor handleOpenInternal(String path, boolean untitled) {
    try {
      try {
        EditorState state = EditorState.nextEditor(editors);
        Editor editor = nextMode.createEditor(this, path, state);

        // opened successfully, let's go to work
        editor.setUpdatesAvailable(updatesAvailable);
        editor.getSketch().setUntitled(untitled);
        editors.add(editor);
        Recent.append(editor);

        // now that we're ready, show the window
        // (don't do earlier, cuz we might move it based on a window being closed)
        editor.setVisible(true);

        return editor;

      } catch (EditorException ee) {
        if (ee.getMessage() != null) {  // null if the user canceled
          Messages.showWarning("Error opening sketch", ee.getMessage(), ee);
        }
      } catch (NoSuchMethodError me) {
        Messages.showWarning("Mode out of date",
                             nextMode.getTitle() + " is not compatible with this version of Processing.\n" +
                             "Try updating the Mode or contact its author for a new version.", me);
      } catch (Throwable t) {
        if (nextMode.equals(getDefaultMode())) {
          Messages.showTrace("Serious Problem",
                             "An unexpected, unknown, and unrecoverable error occurred\n" +
                             "while opening a new editor window. Please report this.", t, true);
        } else {
          Messages.showTrace("Mode Problems",
                             "A nasty error occurred while trying to use “" + nextMode.getTitle() + "”.\n" +
                             "It may not be compatible with this version of Processing.\n" +
                             "Try updating the Mode or contact its author for a new version.", t, false);
        }
      }
      if (editors.isEmpty()) {
        Mode defaultMode = getDefaultMode();
        if (nextMode == defaultMode) {
          // unreachable? hopefully?
          Messages.showError("Editor Problems", """
              An error occurred while trying to change modes.
              We'll have to quit for now because it's an
              unfortunate bit of indigestion with the default Mode.
              """, null);
        } else {
          // Don't leave the user hanging or the PDE locked up
          // https://github.com/processing/processing/issues/4467
          if (untitled) {
            nextMode = defaultMode;
            handleNew();
            return null;  // ignored by any caller

          } else {
            // This null response will be kicked back to changeMode(),
            // signaling it to re-open the sketch in the default Mode.
            return null;
          }
        }
      }
    } catch (Throwable t) {
      Messages.showTrace("Terrible News",
                         "A serious error occurred while " +
                         "trying to create a new editor window.", t,
                         nextMode == getDefaultMode());  // quit if default
      nextMode = getDefaultMode();
    }
    return null;
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Close a sketch as specified by its editor window.
   * @param editor Editor object of the sketch to be closed.
   * @param preventQuit For platforms that must have a window open,
   *                    prevent a quit because a new window will be opened
   *                    (i.e. when upgrading or changing the Mode)
   * @return true if succeeded in closing, false if canceled.
   */
  public boolean handleClose(Editor editor, boolean preventQuit) {
    if (!editor.checkModified()) {
      return false;
    }

    // Close the running window, avoid window boogers with multiple sketches
    editor.internalCloseRunner();

    if (editors.size() == 1) {
      if (Platform.isMacOS()) {
        // If the central menu bar isn't supported on this macOS JVM,
        // we have to do the old behavior. Yuck!
        if (defaultFileMenu == null) {
          Object[] options = { Language.text("prompt.ok"), Language.text("prompt.cancel") };

          int result = JOptionPane.showOptionDialog(editor,
            Toolkit.formatMessage("Are you sure you want to Quit?",
                "Closing the last open sketch will quit Processing."),
            "Quit",
            JOptionPane.YES_NO_OPTION,
            JOptionPane.QUESTION_MESSAGE,
            null,
            options,
            options[0]);
          if (result == JOptionPane.NO_OPTION ||
              result == JOptionPane.CLOSED_OPTION) {
            return false;
          }
        }
      }

      if (defaultFileMenu == null) {
        if (preventQuit) {
          // need to close this editor, ever so temporarily
          editor.setVisible(false);
          editor.dispose();
          activeEditor = null;
          editors.remove(editor);
        } else {
          // Since this wasn't an actual Quit event, call System.exit()
          System.exit(0);
        }
      } else {  // on OS X, update the default file menu
        editor.setVisible(false);
        editor.dispose();
        defaultFileMenu.insert(Recent.getMenu(), 2);
        activeEditor = null;
        editors.remove(editor);
      }

    } else {
      // More than one editor window open,
      // proceed with closing the current window.
      editor.setVisible(false);
      editor.dispose();
      editors.remove(editor);
    }
    return true;
  }


  /**
   * Handler for File &rarr; Quit. Note that this is *only* for the
   * File menu. On macOS, it will not call System.exit() because the
   * application will handle that. If calling this from elsewhere,
   * you'll need a System.exit() call on macOS.
   * @return false if canceled, true otherwise.
   */
  public boolean handleQuit() {
    // If quit is canceled, this will be replaced anyway
    // by a later handleQuit() that is not canceled.
//    storeSketches();

    if (handleQuitEach()) {
      // make sure running sketches close before quitting
      for (Editor editor : editors) {
        editor.internalCloseRunner();
      }
      // Save out the current prefs state
      Preferences.save();

      // Finished with this guy
      Console.shutdown();

      if (!Platform.isMacOS()) {
        // If this was fired from the menu or an AppleEvent (the Finder),
        // then Mac OS X will send the terminate signal itself.
        System.exit(0);
      }
      return true;
    }
    return false;
  }


  /**
   * Attempt to close each open sketch in preparation for quitting.
   * @return false if canceled along the way
   */
  protected boolean handleQuitEach() {
//    int index = 0;
    for (Editor editor : editors) {
//      if (editor.checkModified()) {
//        // Update to the new/final sketch path for this fella
//        storeSketchPath(editor, index);
//        index++;
//
//      } else {
//        return false;
//      }
      if (!editor.checkModified()) {
        return false;
      }
    }
    return true;
  }


  public void handleRestart() {
    File app = Platform.getProcessingApp();
    System.out.println(app);
    if (app.exists()) {
      if (handleQuitEach()) {  // only if everything saved
        SingleInstance.clearRunning();

        // Launch on quit
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
          try {
            //Runtime.getRuntime().exec(app.getAbsolutePath());
            System.out.println("launching");
            Process p;
            if (Platform.isMacOS()) {
              p = Runtime.getRuntime().exec(new String[] {
                // -n allows more than one instance to be opened at a time
                "open", "-n", "-a", app.getAbsolutePath()
              });
            } else if (Platform.isLinux()) {
              p = Runtime.getRuntime().exec(new String[] {
                app.getAbsolutePath()
              });
            } else {
              p = Runtime.getRuntime().exec(new String[] {
                "cmd", "/c", app.getAbsolutePath()
              });
            }
            System.out.println("launched with result " + p.waitFor());
            System.out.flush();
          } catch (Exception e) {
            e.printStackTrace();
          }
        }));
        handleQuit();
        // handleQuit() does not call System.exit() on macOS
        if (Platform.isMacOS()) {
          System.exit(0);
        }
      }
    } else {
      Messages.showWarning("Cannot Restart",
        "Cannot automatically restart because the Processing\n" +
        "application has been renamed. Please quit and then restart manually.");
    }
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


//  /**
//   * Asynchronous version of menu rebuild to be used on save and rename
//   * to prevent the interface from locking up until the menus are done.
//   */
//  protected void rebuildSketchbookMenusAsync() {
//    EventQueue.invokeLater(this::rebuildSketchbookMenus);
//  }


  public void showExamplesFrame() {
    nextMode.showExamplesFrame();
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  protected SketchbookFrame sketchbookFrame;

  public DefaultMutableTreeNode buildSketchbookTree() {
    DefaultMutableTreeNode sbNode =
      new DefaultMutableTreeNode(Language.text("sketchbook.tree"));
    try {
      addSketches(sbNode, Base.getSketchbookFolder(), false);
    } catch (IOException e) {
      e.printStackTrace();
    }
    return sbNode;
  }


  /** Sketchbook has changed, update it on next viewing. */
  public void rebuildSketchbookFrame() {
    if (sketchbookFrame != null) {
      sketchbookFrame.rebuild();
      /*
      boolean visible = sketchbookFrame.isVisible();
      Rectangle bounds = null;
      if (visible) {
        bounds = sketchbookFrame.getBounds();
        sketchbookFrame.setVisible(false);
        sketchbookFrame.dispose();
      }
      sketchbookFrame = null;
      if (visible) {
        showSketchbookFrame();
        sketchbookFrame.setBounds(bounds);
      }
      */
    }
  }


  public void showSketchbookFrame() {
    if (sketchbookFrame == null) {
      sketchbookFrame = new SketchbookFrame(this);
    }
    sketchbookFrame.setVisible();
  }


  /**
   * Synchronous version of rebuild, used when the sketchbook folder has
   * changed, so that the libraries are properly re-scanned before those menus
   * (and the examples window) are rebuilt.
   */
  public void rebuildSketchbook() {
    for (Mode mode : getModeList()) {
      mode.rebuildImportMenu();  // calls rebuildLibraryList
      mode.rebuildToolbarMenu();
      mode.rebuildExamplesFrame();
    }
    // Unlike libraries, examples, etc. the sketchbook is global
    // (because you need to be able to open sketches from the Mode
    // that you're not currently using).
    rebuildSketchbookFrame();
  }


  public void populateSketchbookMenu(JMenu menu) {
    new Thread(() -> {
      boolean found = false;
      try {
          found = addSketches(menu, getSketchbookFolder());
      } catch (Exception e) {
        Messages.showWarning("Sketchbook Menu Error",
                "An error occurred while trying to list the sketchbook.", e);
      }
      if (!found) {
        JMenuItem empty = new JMenuItem(Language.text("menu.file.sketchbook.empty"));
        empty.setEnabled(false);
        menu.add(empty);
      }
    }).start();
  }


  /**
   * Scan a folder recursively, and add any sketches found to the menu
   * specified. Set the openReplaces parameter to true when opening the sketch
   * should replace the sketch in the current window, or false when the
   * sketch should open in a new window.
   */
  protected boolean addSketches(JMenu menu, File folder) {
    Messages.log("scanning " + folder.getAbsolutePath());
    // skip .DS_Store files, etc. (this shouldn't actually be necessary)
    if (!folder.isDirectory()) {
      return false;
    }

    // Don't look inside the 'android' folders in the sketchbook
    if (folder.getName().equals("android")) {
      return false;
    }

    if (folder.getName().equals("libraries")) {
      return false;  // let's not go there
    }

    if (folder.getName().equals("sdk")) {
      // This could be Android's SDK folder. Let's double-check:
      File suspectSDKPath = new File(folder.getParent(), folder.getName());
        File expectedSDKPath = new File(getSketchbookFolder(), "android" + File.separator + "sdk");
      if (expectedSDKPath.getAbsolutePath().equals(suspectSDKPath.getAbsolutePath())) {
        return false;  // Most likely the SDK folder, skip it
      }
    }

    String[] list = folder.list();
    // If a bad folder or unreadable or whatever, this will come back null
    if (list == null) {
      return false;
    }

    // Alphabetize the list, since it's not always alpha order
    Arrays.sort(list, String.CASE_INSENSITIVE_ORDER);

    ActionListener listener = e -> {
      String path = e.getActionCommand();
      if (new File(path).exists()) {
        handleOpen(path);
      } else {
        Messages.showWarning("Sketch Disappeared","""
            The selected sketch no longer exists.
            You may need to restart Processing to update
            the sketchbook menu.""", null);
      }
    };
    // offers no speed improvement
    //menu.addActionListener(listener);

    boolean found = false;

    for (String name : list) {
      if (name.charAt(0) == '.') {
        continue;
      }

      // TODO Is this necessary any longer? This seems gross [fry 210804]
      if (name.equals("old")) {  // Don't add old contributions
        continue;
      }

      File entry = new File(folder, name);
      File sketchFile = null;
      if (entry.isDirectory()) {
        sketchFile = Sketch.findMain(entry, getModeList());
      } else if (name.toLowerCase().endsWith(SKETCH_BUNDLE_EXT)) {
        name = name.substring(0, name.length() - SKETCH_BUNDLE_EXT.length());
        sketchFile = entry;
      }

      if (sketchFile != null) {
        JMenuItem item = new JMenuItem(name);
        item.addActionListener(listener);
        item.setActionCommand(sketchFile.getAbsolutePath());
        menu.add(item);
        found = true;

      } else if (entry.isDirectory()) {
        // not a sketch folder, but may be a subfolder containing sketches
        JMenu submenu = new JMenu(name);
        // needs to be separate var otherwise would set found to false
        boolean anything = addSketches(submenu, entry);
        if (anything) {
          menu.add(submenu);
          found = true;
        }
      }
    }
    return found;
  }


  /**
   * Mostly identical to the JMenu version above, however the rules are
   * slightly different for how examples are handled, etc.
   */
  public boolean addSketches(DefaultMutableTreeNode node, File folder,
                             boolean examples) throws IOException {
    Messages.log("scanning " + folder.getAbsolutePath());
    // skip .DS_Store files, etc. (this shouldn't actually be necessary)
    if (!folder.isDirectory()) {
      return false;
    }

    final String folderName = folder.getName();

    // Don't look inside the 'android' folders in the sketchbook
    if (folderName.equals("android")) {
      return false;
    }

    // Don't look inside the 'libraries' folders in the sketchbook
    if (folderName.equals("libraries")) {
      return false;
    }

    // When building the sketchbook, don't show the contributed 'examples'
    // like it's a subfolder. But when loading examples, allow the folder
    // to be named 'examples'.
    if (!examples && folderName.equals("examples")) {
      return false;
    }

//    // Conversely, when looking for examples, ignore the other folders
//    // (to avoid going through hoops with the tree node setup).
//    if (examples && !folderName.equals("examples")) {
//      return false;
//    }
//    // Doesn't quite work because the parent will be 'examples', and we want
//    // to walk inside that, but the folder itself will have a different name

    String[] fileList = folder.list();
    // If a bad folder or unreadable or whatever, this will come back null
    if (fileList == null) {
      return false;
    }

    // Alphabetize the list, since it's not always alpha order
    Arrays.sort(fileList, String.CASE_INSENSITIVE_ORDER);

    boolean found = false;
    for (String name : fileList) {
      if (name.charAt(0) == '.') {  // Skip hidden files
        continue;
      }

      File entry = new File(folder, name);
      File sketchFile = null;
      if (entry.isDirectory()) {
        sketchFile = Sketch.findMain(entry, getModeList());
      } else if (name.toLowerCase().endsWith(SKETCH_BUNDLE_EXT)) {
        name = name.substring(0, name.length() - SKETCH_BUNDLE_EXT.length());
        sketchFile = entry;
      }

      if (sketchFile != null) {
        DefaultMutableTreeNode item =
          new DefaultMutableTreeNode(new SketchReference(name, sketchFile));

        node.add(item);
        found = true;

      } else if (entry.isDirectory()) {
        // not a sketch folder, but maybe a subfolder containing sketches
        DefaultMutableTreeNode subNode = new DefaultMutableTreeNode(name);
        // needs to be separate var otherwise would set found to false
        boolean anything = addSketches(subNode, entry, examples);
        if (anything) {
          node.add(subNode);
          found = true;
        }
      }
    }
    return found;
  }


  /*
  static private Mode findSketchMode(File folder, List<Mode> modeList) {
    try {
      Settings props = Sketch.loadProperties(folder);
      if (props != null) {
        String id = props.get("mode.id");
        if (id != null) {
          Mode mode = findMode(id);
          if (mode != null) {
            return mode;
          }
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
    for (Mode mode : modeList) {
      // Test whether a .pde file of the same name as its parent folder exists.
      String defaultName = folder.getName() + "." + mode.getDefaultExtension();
      File entry = new File(folder, defaultName);
      if (entry.exists()) {
        return mode;
      }
    }
    return null;
  }
  */


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Show the Preferences window.
   */
  public void handlePrefs() {
      PDEPreferencesKt.show();
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   * Return a File from inside the Processing 'lib' folder.
   */
  @SuppressWarnings("RedundantThrows")
  static public File getLibFile(String filename) throws IOException {
    return new File(Platform.getContentFile("lib"), filename);
  }


  /**
   * Return an InputStream for a file inside the Processing lib folder.
   */
  static public InputStream getLibStream(String filename) throws IOException {
    return new FileInputStream(getLibFile(filename));
  }


  /**
   * Get the directory that can store settings. (Library on OS X, App Data or
   * something similar on Windows, a dot folder on Linux.) Removed this as a
   * preference for 3.0a3 because we need this to be stable, but adding back
   * for 4.0 beta 4 so that folks can do 'portable' versions again.
   *
   * @deprecated use processing.utils.Settings.getFolder() instead, this method will invoke AWT
   */
  static public File getSettingsFolder() {
      var override = getSettingsOverride();
      if (override != null) {
          return override;
      }
      try {
          return processing.utils.Settings.getFolder();
      } catch (processing.utils.Settings.SettingsFolderException e) {
          switch (e.getType()) {
              case COULD_NOT_CREATE_FOLDER -> Messages.showError("Settings issues",
                      """
                              Processing cannot run because it could not
                              create a folder to store your settings at
                              """ + e.getMessage(), null);
              case WINDOWS_APPDATA_NOT_FOUND -> Messages.showError("Settings issues",
                      """
                              Processing cannot run because it could not
                              find the AppData or LocalAppData folder on your system.
                              """, null);
              case MACOS_LIBRARY_FOLDER_NOT_FOUND -> Messages.showError("Settings issues",
                      """
                              Processing cannot run because it could not
                              find the Library folder on your system.
                              """, null);
              case LINUX_CONFIG_FOLDER_NOT_FOUND -> Messages.showError("Settings issues",
                      """
                              Processing cannot run because either your
                              XDG_CONFIG_HOME or SNAP_USER_COMMON is set
                              but the folder does not exist.
                              """, null);
              case LINUX_SUDO_USER_ERROR -> Messages.showError("Settings issues",
                      """
                              Processing cannot run because it was started
                              with sudo and Processing could not resolve
                              the original users home directory.
                              """, null);
              default -> Messages.showTrace("An rare and unknowable thing happened",
                      """
                              Could not get the settings folder. Please report:
                              http://github.com/processing/processing4/issues/new
                              """,
                      e, true);
          }
      }
      throw new RuntimeException("Unreachable code in Base.getSettingsFolder()");
  }


  static public File getSettingsOverride() {
    return settingsOverride;
  }


  /**
   * Convenience method to get a File object for the specified filename inside
   * the settings folder. Used to get preferences and recent sketch files.
   * @param filename A file inside the settings folder.
   * @return filename wrapped as a File object inside the settings folder
   */
  static public File getSettingsFile(String filename) {
    return new File(getSettingsFolder(), filename);
  }


  static public File getToolsFolder() {
    return Platform.getContentFile("tools");
  }

    static protected File sketchbookFolder;
  static public void locateSketchbookFolder() {
    // If a value is at least set, first check to see if the folder exists.
    // If it doesn't, warn the user that the sketchbook folder is being reset.
    String sketchbookPath = Preferences.getSketchbookPath();
    if (sketchbookPath != null) {
      sketchbookFolder = new File(sketchbookPath);
      if (!sketchbookFolder.exists()) {
        Messages.showWarning("Sketchbook folder disappeared","""
            The sketchbook folder no longer exists.
            Processing will switch to the default sketchbook
            location, and create a new sketchbook folder if
            necessary. Processing will then stop talking
            about itself in the third person.""", null);
        sketchbookFolder = null;
      }
    }

    // If no path is set, get the default sketchbook folder for this platform
    if (sketchbookFolder == null) {
      sketchbookFolder = getDefaultSketchbookFolder();
      Preferences.setSketchbookPath(sketchbookFolder.getAbsolutePath());
      if (!sketchbookFolder.exists()) {
        if (!sketchbookFolder.mkdirs()) {
          Messages.showError("Could not create sketchbook",
                             "Unable to create a sketchbook folder at\n" +
                             sketchbookFolder + "\n" +
                             "Try creating a folder at that path and restart Processing.", null);
        }
      }
    }

    // make sure the libraries/modes/tools directories exist
    makeSketchbookSubfolders();
  }


  public void setSketchbookFolder(File folder) {
    sketchbookFolder = folder;
    Preferences.setSketchbookPath(folder.getAbsolutePath());
    rebuildSketchbook();
    makeSketchbookSubfolders();
  }


  /**
   * Create the libraries, modes, tools, examples folders in the sketchbook.
   */
  @SuppressWarnings("ResultOfMethodCallIgnored")
  static protected void makeSketchbookSubfolders() {
    // ignore result; mkdirs() will return false if the folder already exists
    getSketchbookLibrariesFolder().mkdirs();
    getSketchbookToolsFolder().mkdirs();
    getSketchbookModesFolder().mkdirs();
    getSketchbookExamplesFolder().mkdirs();
    getSketchbookTemplatesFolder().mkdirs();

    /*
      Messages.showWarning("Could not create folder",
                           "Could not create the libraries, tools, modes, examples, and templates\n" +
                           "folders inside " + sketchbookFolder + "\n" +
                           "Try creating them manually to determine the problem.", null);
    */
  }


  static public File getSketchbookFolder() {
      var sketchbookPathOverride = System.getProperty("processing.sketchbook.folder");
      if (sketchbookPathOverride != null && !sketchbookPathOverride.isEmpty()) {
          return new File(sketchbookPathOverride);
      }
      if (sketchbookFolder == null) {
          locateSketchbookFolder();
      }
    return sketchbookFolder;
  }


  static public File getSketchbookLibrariesFolder() {
      return new File(getSketchbookFolder(), "libraries");
  }


  static public File getSketchbookToolsFolder() {
      return new File(getSketchbookFolder(), "tools");
  }


  static public File getSketchbookModesFolder() {
      return new File(getSketchbookFolder(), "modes");
  }


  static public File getSketchbookExamplesFolder() {
      return new File(getSketchbookFolder(), "examples");
  }


  static public File getSketchbookTemplatesFolder() {
      return new File(getSketchbookFolder(), "templates");
  }


    @NotNull
  static protected File getDefaultSketchbookFolder() {
    File sketchbookFolder = null;
    try {
      sketchbookFolder = Platform.getDefaultSketchbookFolder();
    } catch (Exception ignored) { }

    if (sketchbookFolder == null) {
      Messages.showError("No sketchbook",
                         "Problem while trying to get the sketchbook", null);

    } else {
      // create the folder if it doesn't exist already
      boolean result = true;
      if (!sketchbookFolder.exists()) {
        result = sketchbookFolder.mkdirs();
      }

      if (!result) {
        Messages.showError("You forgot your sketchbook",
                           "Processing cannot run because it could not\n" +
                           "create a folder to store your sketchbook.", null);
      }
    }
    return sketchbookFolder;
  }
}
